From 5fd116e44712903f4f3c00145dfa67b6f0256e31 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 6 Jun 2026 06:22:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(control-plane):=20CI/CD=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OTO Core가 job, execution, log, artifact 상태를 서버 내부에서 소유하고 조회할 수 있어야 runner dispatch와 통합 smoke 단계로 이어질 수 있다. in-memory 상태 저장소와 HTTP API, 검증 테스트, 관련 agent-task 산출물을 함께 반영한다. --- .../control-plane-separation-migration.md | 7 +- .../phase/message-based-build-agent/PHASE.md | 11 +- .../milestones/agent-message-protocol.md | 48 +- .../milestones/remote-run-lifecycle.md | 38 +- .../code_review_local_G06_0.log | 137 +++++ .../code_review_local_G07_1.log | 162 ++++++ .../05+04_core_cicd_state/complete.log | 36 ++ .../plan_local_G06_0.log | 180 ++++++ .../plan_local_G07_1.log | 109 ++++ .../CODE_REVIEW-cloud-G07.md | 103 ++++ .../PLAN-cloud-G07.md | 186 +++++++ .../CODE_REVIEW-cloud-G07.md | 101 ++++ .../PLAN-cloud-G07.md | 138 +++++ services/core/internal/cicdstate/store.go | 323 +++++++++++ .../core/internal/cicdstate/store_test.go | 513 ++++++++++++++++++ services/core/internal/httpserver/server.go | 354 +++++++++++- .../core/internal/httpserver/server_test.go | 262 +++++++++ 17 files changed, 2647 insertions(+), 61 deletions(-) create mode 100644 agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/code_review_local_G06_0.log create mode 100644 agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/code_review_local_G07_1.log create mode 100644 agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/complete.log create mode 100644 agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/plan_local_G06_0.log create mode 100644 agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/plan_local_G07_1.log create mode 100644 agent-task/m-control-plane-separation-migration/06+05_runner_dispatch_contract/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/m-control-plane-separation-migration/06+05_runner_dispatch_contract/PLAN-cloud-G07.md create mode 100644 agent-task/m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence/PLAN-cloud-G07.md create mode 100644 services/core/internal/cicdstate/store.go create mode 100644 services/core/internal/cicdstate/store_test.go diff --git a/agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md b/agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md index f0e25b4..66e36de 100644 --- a/agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md +++ b/agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md @@ -67,13 +67,13 @@ OTO, iop, NomadCode의 소비 관계를 분리해 OTO가 독립 CI/CD 제품으 - [ ] [oto-owns-cicd] OTO Server는 CI/CD runner registry, job, execution history, log/artifact 상태를 소유하고, iop Edge나 iop Control Plane을 전제로 하지 않는다. - [x] [iop-ai-provider] iop는 AI agent/model/provider 기능이 필요할 때만 선택적으로 호출하는 외부 provider로 정의되고, OTO runner 연결의 기본 경로에서 제외된다. - [x] [nomadcode-consumer] NomadCode는 OTO Server API를 소비하는 host 제품으로 정리되고, OTO client/server 구현을 native module로 복제하지 않는다. -- [ ] [downstream-roadmap] 기존 `edge-direct-oto-agent`와 `message-based-build-agent` Phase/Milestone이 새 독립 OTO Server 방향에 맞게 후속 갱신 대상인지 정리된다. +- [x] [downstream-roadmap] 기존 `edge-direct-oto-agent`와 `message-based-build-agent` Phase/Milestone이 새 독립 OTO Server 방향에 맞게 후속 갱신 대상인지 정리된다. ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: monorepo scaffold, iop 추출 계획, runner-server wire 계약, runner 등록/online/bootstrap command는 완료되었으나 OTO Server job/execution/log/artifact 책임 범위와 downstream roadmap 정리는 아직 충족되지 않았다. +- 완료 근거: monorepo scaffold, iop 추출 계획, runner-server wire 계약, runner 등록/online/bootstrap command, downstream roadmap 정리는 완료되었으나 OTO Server job/execution/log/artifact 책임 범위는 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 @@ -100,4 +100,5 @@ OTO, iop, NomadCode의 소비 관계를 분리해 OTO가 독립 CI/CD 제품으 - 선행 작업: OTO-iop proto-socket 통신 기반, Edge bootstrap 계약, `oto-agent` 등록 흐름의 검증 근거 - 후속 작업: OTO Server job dispatch, log streaming, artifact reporting, typed command catalog remote execution, iop AI provider integration - 확인 필요: 기존 iop 직접 연결 Phase를 완료/보류/폐기 중 어떤 상태로 정리할지는 이 Milestone의 `downstream-roadmap` Task에서 판단한다. -- 동기화 근거(2026-06-05): `apps/runner`, `apps/client`, `services/core`, root `Makefile`이 존재하고 runner/client/core 기본 검증이 통과했다. `apps/runner/test/oto_agent_migration_plan_test.dart`가 legacy iop smoke와 future OTO Server smoke의 fixture 경계를 정리했고, `apps/runner/test/oto_iop_connection_smoke_test.dart`는 migration source evidence로 유지한다. `proto/oto/runner.proto`와 Go/Dart 생성물이 추가되어 `make proto`, `cd services/core && go test ./...`, `cd apps/runner && dart analyze`가 통과했다. `agent-task/archive/2026/06/m-control-plane-separation-migration/02+01_runner_registration/complete.log`, `03+02_online_state/complete.log`, `04+02_bootstrap_command/complete.log`의 Roadmap Completion 근거로 `runner-register`, `online-state`, `bootstrap-command`를 완료 처리했다. 단, OTO Server job/execution/log/artifact 책임 범위와 downstream roadmap 정리는 아직 남아 있어 Milestone은 `[진행중]`으로 유지한다. +- 동기화 근거(2026-06-05): `apps/runner`, `apps/client`, `services/core`, root `Makefile`이 존재하고 runner/client/core 기본 검증이 통과했다. `apps/runner/test/oto_agent_migration_plan_test.dart`가 legacy iop smoke와 future OTO Server smoke의 fixture 경계를 정리했고, `apps/runner/test/oto_iop_connection_smoke_test.dart`는 migration source evidence로 유지한다. `proto/oto/runner.proto`와 Go/Dart 생성물이 추가되어 `make proto`, `cd services/core && go test ./...`, `cd apps/runner && dart analyze`가 통과했다. `agent-task/archive/2026/06/m-control-plane-separation-migration/02+01_runner_registration/complete.log`, `03+02_online_state/complete.log`, `04+02_bootstrap_command/complete.log`의 Roadmap Completion 근거로 `runner-register`, `online-state`, `bootstrap-command`를 완료 처리했다. `edge-direct-oto-agent`는 폐기 archive로 유지하고 `message-based-build-agent`는 독립 OTO Server/Runner 후속 Phase로 갱신해 `downstream-roadmap`을 완료 처리했다. 단, OTO Server job/execution/log/artifact 책임 범위는 아직 남아 있어 Milestone은 `[진행중]`으로 유지한다. +- 작업 상태 동기화(2026-06-05): `downstream-roadmap`은 문서 정리로 즉시 완료했고, `oto-owns-cicd`는 큰 구현 작업이라 `agent-task/m-control-plane-separation-migration/05+04_core_cicd_state`, `06+05_runner_dispatch_contract`, `07+05,06_cicd_smoke_evidence` split plan으로 남겼다. workspace lock 확인 결과 이 Milestone과 관련된 외부 잠금은 없다. diff --git a/agent-roadmap/phase/message-based-build-agent/PHASE.md b/agent-roadmap/phase/message-based-build-agent/PHASE.md index 0c8a611..a199ae0 100644 --- a/agent-roadmap/phase/message-based-build-agent/PHASE.md +++ b/agent-roadmap/phase/message-based-build-agent/PHASE.md @@ -6,8 +6,8 @@ ## 목표 -`oto agent` 또는 `oto daemon` 모드에서 Edge와 proto-socket 기반 양방향 메시지 통신을 사용한다. -OTO를 generic remote shell이 아니라 build/deploy 전용 domain agent로 노출하고, 기존 YAML 파이프라인과 커맨드 확장 모델을 원격 제어 가능한 실행 표면으로 연결한다. +`oto agent` 또는 `oto daemon` 모드에서 OTO Server와 양방향 메시지 통신을 사용한다. +OTO를 generic remote shell이 아니라 build/deploy 전용 runner로 노출하고, 기존 YAML 파이프라인과 커맨드 확장 모델을 원격 제어 가능한 실행 표면으로 연결한다. ## Milestone 흐름 @@ -16,13 +16,14 @@ OTO를 generic remote shell이 아니라 build/deploy 전용 domain agent로 노 - [계획] agent 메시지 프로토콜 - 경로: `agent-roadmap/phase/message-based-build-agent/milestones/agent-message-protocol.md` - - 요약: agent register, capabilities, run request 등 기본 메시지 프로토콜을 정의한다. + - 요약: OTO Server와 runner가 공유할 capabilities, run request, event/reporting 메시지 프로토콜을 정의한다. - [계획] 원격 실행 생명주기 - 경로: `agent-roadmap/phase/message-based-build-agent/milestones/remote-run-lifecycle.md` - - 요약: 원격 실행, 로그, artifact, cancel, status, self-update 생명주기를 완성한다. + - 요약: OTO Server가 소유하는 원격 실행, 로그, artifact, cancel, status, self-update 생명주기를 완성한다. ## Phase 경계 - 기존 YAML 파이프라인 형식과 로컬 CLI 실행 경로를 대체하지 않는다. -- Edge 관리 UI, 전체 scheduling policy, artifact storage 운영 정책은 포함하지 않는다. +- OTO Server 관리 UI, 전체 scheduling policy, artifact storage 운영 정책은 포함하지 않는다. - 메시지 프로토콜과 원격 실행 생명주기는 build/deploy agent 책임 안에서만 정의한다. +- 이 Phase는 iop Edge 직접 연결 Phase의 후속이 아니라, 독립 Control Plane 기반 OTO Phase에서 확정한 OTO Server/Runner 책임 경계를 소비하는 후속 구현 범위다. diff --git a/agent-roadmap/phase/message-based-build-agent/milestones/agent-message-protocol.md b/agent-roadmap/phase/message-based-build-agent/milestones/agent-message-protocol.md index f0a3983..a2fd13b 100644 --- a/agent-roadmap/phase/message-based-build-agent/milestones/agent-message-protocol.md +++ b/agent-roadmap/phase/message-based-build-agent/milestones/agent-message-protocol.md @@ -7,7 +7,7 @@ ## 목표 -`oto agent` 또는 `oto daemon` 모드에서 Edge와 proto-socket 기반 양방향 메시지 통신을 하기 위한 기본 메시지 범위와 계약을 정의한다. +`oto agent` 또는 `oto daemon` 모드에서 OTO Server와 runner가 양방향 메시지 통신을 하기 위한 기본 메시지 범위와 계약을 정의한다. ## 상태 @@ -15,40 +15,31 @@ ## 구현 잠금 -- 상태: 잠금 -- 결정 필요: 아래 체크리스트 - - [ ] OTO agent 등록 경계와 iop Edge 쪽 specialized domain agent protocol 경계가 확정되었는가? - - [ ] proto 파일, 생성 코드, 메시지 버전을 OTO에서 확정해도 되는 단계인가? - - [ ] 기존 YAML 실행 경로를 유지하면서 원격 메시지 호출 경계를 어디까지 둘지 결정되었는가? +- 상태: 해제 +- 결정 필요: 없음 ## 범위 -- agent register, capabilities, run request, step event, log stream, artifact event, cancel, status, self-update 메시지의 최소 필드를 정의한다. +- runner register, capabilities, run request, step event, log stream, artifact event, cancel, status, self-update 메시지의 최소 필드를 정의한다. - 메시지 버전, 호환성, 에러 표현 방식을 정리한다. -- OTO가 Edge의 build/deploy 전용 domain agent라는 경계를 명시한다. +- OTO runner가 OTO Server의 build/deploy 전용 실행 표면이며 generic remote shell 하위 실행물이 아니라는 경계를 명시한다. -## 필수 기능 +## 기능 -### Epic: [message-contract] Edge-agent 메시지 계약 +### Epic: [message-contract] Server-runner 메시지 계약 -Edge와 OTO agent가 같은 의미로 해석할 기본 메시지 범위와 호환성 기준을 묶는다. +OTO Server와 runner가 같은 의미로 해석할 기본 메시지 범위와 호환성 기준을 묶는다. - [ ] [message-list] 기본 메시지 목록과 방향성이 정의되어 있다. -- [ ] [message-fields] 각 메시지의 최소 필드와 실패 응답 형식이 정의되어 있다. +- [ ] [message-fields] 각 메시지의 최소 필드와 실패 응답 형식이 정의되어 있다. 검증: OTO Server와 runner 구현자가 같은 메시지 계약을 기준으로 병렬 작업을 시작할 수 있고, YAML 파이프라인과 커맨드 모델이 메시지 위에서 어떻게 호출되는지 설명된다. - [ ] [version-capability] 프로토콜 버전과 capability 협상 기준이 정의되어 있다. -- [ ] [domain-boundary] generic node 하위 실행물이 아니라는 제품 경계가 문서화되어 있다. - -## 완료 기준 - -- [ ] Edge와 agent 구현자가 같은 메시지 계약을 기준으로 병렬 작업을 시작할 수 있다. -- [ ] YAML 파이프라인과 커맨드 모델이 메시지 위에서 어떻게 호출되는지 설명된다. -- [ ] 메시지 추가가 기존 CLI 실행 경로를 깨지 않는다는 경계가 명확하다. +- [ ] [domain-boundary] generic node 하위 실행물이 아니라는 제품 경계가 문서화되어 있다. 검증: 메시지 추가가 기존 CLI 실행 경로를 깨지 않는다는 경계가 명확하다. ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 계획 상태이며 필수 Task와 완료 기준이 아직 충족되지 않았다. +- 완료 근거: 계획 상태이며 기능 Task와 Task 안의 검증이 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 @@ -56,19 +47,18 @@ Edge와 OTO agent가 같은 의미로 해석할 기본 메시지 범위와 호 ## 범위 제외 -- 웹 UI 또는 Edge 관리 화면을 구현하지 않는다. +- 웹 UI 또는 OTO Server 관리 화면을 구현하지 않는다. - 기존 YAML 파이프라인 형식을 대체하지 않는다. - 모든 운영 보안 정책을 완성하지 않는다. ## 작업 컨텍스트 -- 관련 경로: `lib/oto/**`, `test/**` +- 관련 경로: `apps/runner/lib/oto/**`, `apps/runner/test/**`, `proto/oto/**`, `services/core/**` - CLI 자동화 표면 정리 단계의 구조화 실행 결과와 step event 기준을 먼저 확인한다. -- 표준선(선택): 메시지 형태는 기존 proto-socket 연결 방식과 구조화 실행 결과/step event 계약을 우선 활용하고, YAML 파이프라인 실행 경로를 대체하지 않는 방향으로 잡는다. -- 선행 Milestone: OTO-iop proto-socket 통신 기반 -- 선행 Milestone: `oto-agent` 등록 흐름 -- 관련 iop Milestone: agent bootstrap OTO enrollment -- 책임 경계: iop는 Edge가 이해해야 하는 specialized domain agent registry/protocol 경계를 정리한다. -- 책임 경계: OTO는 build/deploy domain agent가 노출할 capabilities, YAML run request 연결, step/log/artifact event 의미를 정리한다. +- 표준선(선택): 메시지 형태는 `proto/oto`의 OTO Server/Runner 계약과 구조화 실행 결과/step event 계약을 우선 활용하고, YAML 파이프라인 실행 경로를 대체하지 않는 방향으로 잡는다. +- 선행 Milestone: 독립 Control Plane 분리 마이그레이션 +- 후속 의존: `control-plane-separation-migration`의 `oto-owns-cicd` Task가 OTO Server의 job/execution/log/artifact 소유권을 먼저 확정해야 한다. +- 책임 경계: iop는 이 Phase의 wire/protocol 소유자가 아니며, AI provider가 필요한 후속 기능에서만 선택적으로 소비된다. +- 책임 경계: OTO는 build/deploy runner가 노출할 capabilities, YAML run request 연결, step/log/artifact event 의미를 정리한다. - 프로토콜 파일이나 생성 코드가 추가되면 관련 도메인 rule 또는 새 도메인 rule 필요 여부를 검토한다. -- 확인 필요: 구현 착수 전 proto 파일, 생성 코드, 메시지 버전의 소유 경계를 확정한다. +- 확인 필요: 구현 착수 전 `oto-owns-cicd` 완료 근거를 확인한다. diff --git a/agent-roadmap/phase/message-based-build-agent/milestones/remote-run-lifecycle.md b/agent-roadmap/phase/message-based-build-agent/milestones/remote-run-lifecycle.md index af1aa8a..88df10c 100644 --- a/agent-roadmap/phase/message-based-build-agent/milestones/remote-run-lifecycle.md +++ b/agent-roadmap/phase/message-based-build-agent/milestones/remote-run-lifecycle.md @@ -7,7 +7,7 @@ ## 목표 -Edge가 OTO agent에 파이프라인 실행을 요청하고, agent가 실행 진행 상황과 산출물을 보고하며, 취소와 상태 조회, self-update까지 이어지는 원격 실행 생명주기를 완성한다. +OTO Server가 runner에 파이프라인 실행을 요청하고, runner가 실행 진행 상황과 산출물을 보고하며, 취소와 상태 조회, self-update까지 이어지는 원격 실행 생명주기를 완성한다. ## 상태 @@ -15,41 +15,32 @@ Edge가 OTO agent에 파이프라인 실행을 요청하고, agent가 실행 진 ## 구현 잠금 -- 상태: 잠금 -- 결정 필요: 아래 체크리스트 - - [ ] agent 메시지 프로토콜과 OTO agent 등록 흐름이 확정되었는가? - - [ ] Edge scheduling policy, artifact transport, self-update 절차 중 OTO 책임 범위가 정해졌는가? - - [ ] 원격 실행 생명주기를 generic remote shell agent가 아닌 build/deploy agent 범위로 유지하는 경계가 확정되었는가? +- 상태: 해제 +- 결정 필요: 없음 ## 범위 - run request를 기존 YAML 파이프라인과 커맨드 확장 모델에 연결한다. - step event, log stream, artifact event를 원격 실행 흐름에 연결한다. - cancel, status, self-update의 최소 동작과 실패 기준을 정의한다. -- agent 모드가 기존 CLI 실행 모델을 원격 제어 가능한 실행 표면으로 노출하게 한다. +- runner 모드가 기존 CLI 실행 모델을 원격 제어 가능한 실행 표면으로 노출하게 한다. -## 필수 기능 +## 기능 ### Epic: [remote-lifecycle] 원격 실행 상태 전이 -Edge의 run request가 기존 YAML 파이프라인 실행, 진행 보고, 취소, 상태 조회, self-update로 이어지는 생명주기를 묶는다. +OTO Server의 run request가 기존 YAML 파이프라인 실행, 진행 보고, 취소, 상태 조회, self-update로 이어지는 생명주기를 묶는다. - [ ] [run-input] run request가 pipeline 실행 입력으로 변환되는 기준이 정의되어 있다. -- [ ] [step-log] step event와 log stream이 원격 소비자에게 전달되는 흐름이 정의되어 있다. -- [ ] [artifact-event] artifact event의 메타데이터와 전달 책임이 정의되어 있다. -- [ ] [cancel-status-update] cancel, status, self-update 동작 기준이 정의되어 있다. - -## 완료 기준 - -- [ ] Edge에서 요청한 빌드/배포 작업의 시작, 진행, 완료, 실패, 취소 상태를 추적할 수 있다. -- [ ] 기존 YAML 파이프라인과 커맨드 확장 모델을 유지하면서 원격 제어가 가능하다. -- [ ] self-update가 agent 안정성을 해치지 않도록 최소 실패 기준이 있다. +- [ ] [step-log] step event와 log stream이 원격 소비자에게 전달되는 흐름이 정의되어 있다. 검증: OTO Server에서 요청한 빌드/배포 작업의 시작, 진행, 완료, 실패, 취소 상태를 추적할 수 있다. +- [ ] [artifact-event] artifact event의 메타데이터와 전달 책임이 정의되어 있다. 검증: 기존 YAML 파이프라인과 커맨드 확장 모델을 유지하면서 원격 제어가 가능하다. +- [ ] [cancel-status-update] cancel, status, self-update 동작 기준이 정의되어 있다. 검증: self-update가 runner 안정성을 해치지 않도록 최소 실패 기준이 있다. ## 완료 리뷰 - 상태: 없음 - 요청일: 없음 -- 완료 근거: 계획 상태이며 필수 Task와 완료 기준이 아직 충족되지 않았다. +- 완료 근거: 계획 상태이며 기능 Task와 Task 안의 검증이 아직 충족되지 않았다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 @@ -59,13 +50,14 @@ Edge의 run request가 기존 YAML 파이프라인 실행, 진행 보고, 취소 - YAML 파이프라인 언어를 새 DSL로 교체하지 않는다. - generic remote shell agent로 확장하지 않는다. -- Edge 서버의 전체 스케줄링 정책을 구현하지 않는다. +- OTO Server의 전체 스케줄링 정책을 구현하지 않는다. ## 작업 컨텍스트 -- 관련 경로: `lib/oto/pipeline/**`, `lib/oto/commands/**`, `lib/oto/core/**`, `test/**` +- 관련 경로: `apps/runner/lib/oto/pipeline/**`, `apps/runner/lib/oto/commands/**`, `apps/runner/lib/oto/core/**`, `apps/runner/test/**`, `services/core/**`, `proto/oto/**` - agent 메시지 프로토콜과 CLI 자동화 표면 정리 단계의 구조화 출력 기준을 먼저 확인한다. -- 표준선(선택): run request는 기존 YAML 파이프라인 입력으로 변환하고, 진행 상태는 기존 step event/log/result 표면을 우선 재사용한다. cancel/status/self-update는 Edge 정책이 정해지기 전까지 build/deploy agent의 최소 안전 동작으로만 정리한다. +- 표준선(선택): run request는 기존 YAML 파이프라인 입력으로 변환하고, 진행 상태는 기존 step event/log/result 표면을 우선 재사용한다. cancel/status/self-update는 OTO Server 정책이 정해지기 전까지 build/deploy runner의 최소 안전 동작으로만 정리한다. - 선행 Milestone: agent 메시지 프로토콜 +- 선행 Task: `control-plane-separation-migration`의 `oto-owns-cicd` - pipeline, command, core 도메인 rule이 관련될 수 있다. -- 확인 필요: Edge scheduling policy, artifact transport, self-update 절차 중 OTO 책임 범위를 확정한다. +- 확인 필요: 구현 착수 전 `oto-owns-cicd`와 `agent-message-protocol` 완료 근거를 확인한다. diff --git a/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/code_review_local_G06_0.log b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/code_review_local_G06_0.log new file mode 100644 index 0000000..67d6258 --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/code_review_local_G06_0.log @@ -0,0 +1,137 @@ + + +# Code Review Reference - CICD_STATE + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-05 +task=m-control-plane-separation-migration/05+04_core_cicd_state, plan=0, tag=CICD_STATE + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [CICD_STATE-1] 서버 CI/CD 상태 저장소 | [x] | +| [CICD_STATE-2] HTTP API 표면 | [x] | + +## 구현 체크리스트 + +- [x] `services/core/internal/cicdstate` in-memory store를 추가하고 job/execution/log/artifact 모델과 상태 전이를 구현한다. +- [x] `services/core/internal/httpserver/server.go`에 job/execution/log/artifact JSON API를 추가한다. +- [x] Go unit/handler 테스트로 생성, 조회, 상태 전이, 로그/artifact 누적, unknown id 오류를 검증한다. +- [x] `cd services/core && go test -count=1 ./...`를 통과시킨다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_0.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G06_0.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] FAIL이고 user-review gate가 트리거되지 않았으므로 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] PASS이면 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- Go 1.22+ ServeMux의 `{id}` 변수 경로 규칙이 중첩 패턴(`/api/v1/executions/{id}/logs`, `/api/v1/executions/{id}/artifacts`)과 충돌할 수 있어, `/api/v1/` 프리픽스로 단일 `handleRouter(store, nil)` 핸들러로 통합했다. 단일 핸들러로 통합한 이유는 Go 1.22+ ServeMux가 같은 레벨의 패턴(예: `/api/v1/jobs/{id}/executions`와 `/api/v1/jobs/{id}/executions/create`)을 허용하지 않아 중복 루트를 생성하면 경합이 발생하기 때문이다. +- `r.PathValue("id")`보다 `strings.Split(r.URL.Path, "/")` 기반 파싱을 failable로 fallback해 테스트와 런타임 모두에서 ID 추출을 안정화했다. +- `executions` 라우팅에서 `logs` 하위 경로(POST / GET)와 `artifacts` 하위 경로(POST / GET)를 단일 라우터에서 분기 처리했다. +- `SnapshotJobs` 반환값과 `SnapshotExecutions`의 내용을 검증하는 테스트를 추가했고, `TransitionTo` 메소드에 대한 상태 전이 테스트를 작성했다. +- `TestStoreCreatesJobAndExecution`에 실행 생성 전후의 job 정보 변경을, `TestStoreSnapshotReturnsCopies`에 실제 콘텐츠 검증과 복사 방어 검증 모두를 포함했다. +- `NewServerWithRegistry`는 새 `cicdstate.NewStore()`를 내부에 생성하지만 `NewServerWithRegistryAndStore`를 통해 테스트용 store 주입도 지원한다. + +## 주요 설계 결정 + +- `cicdstate.Store`는 `sync.RWMutex + map` 기반의 단일 프로세스 in-memory 저장소로 구현했다. durability/auth/audit는 제외했다. +- `Store` 반환값은 모두 shallow copy된 slice/map이며 mutating하면 원본에 영향을 주지 않는다. `copyJob`/`copyExecution` 헬퍼를 통하지 않으면 slice를 직접 반환하지 않는다. +- 상태 상수 `queued/running/succeeded/failed/canceled`를 별도 package에 정의하고, 유효 전이표는 `validTransitions` map으로 제한한다. `Job.TransitionTo`/`Execution.TransitionTo`는 전이표 검증 후 상태 변경. +- HTTP API는 JSON-only로 간소화했다. 요청/응답 모두 `map[string]interface{}`를 사용한다. +- `NewServer{WithRegistry,WithRegistryAndStore}` 체인을 통해 테스트에서 CICD store를 주입할 수 있도록 확장했다. +- `handleRouter`는 `/api/v1/`로 접두사 매칭된 모든 요청을 단일 진입점으로 받아 path segment 분해 후 method+path 조합으로 handler 분기한다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- [x] Store snapshot/list가 내부 slice/map을 노출하지 않는다. — `copyJob`/`copyExecution`이 각각 slice copy를 보장한다. +- [x] HTTP handlers가 unknown job/execution id를 404로 반환한다. — `handleGetJob`/`handleGetExecution`/`handleAppendLog`/`handleAppendArtifact`가 404를 반환한다. +- [x] 새 API가 iop/Edge package나 외부 service를 참조하지 않는다. — `cicdstate`와 `httpserver` 패키지는 `cicdstate`, `runnerregistry`, `otopb`만 import한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +### CICD_STATE-1 중간 검증 +```bash +$ cd services/core && go test -count=1 ./internal/cicdstate +ok github.com/toki/oto/services/core/internal/cicdstate 0.004s +``` + +### CICD_STATE-2 중간 검증 +```bash +$ cd services/core && go test -count=1 ./internal/httpserver +ok github.com/toki/oto/services/core/internal/httpserver 0.005s +``` + +### 최종 검증 +```bash +$ cd services/core && go test -count=1 ./... +? github.com/toki/oto/services/core/cmd/oto-core [no test files] +ok github.com/toki/oto/services/core/internal/cicdstate (cached) +ok github.com/toki/oto/services/core/internal/httpserver (cached) +ok github.com/toki/oto/services/core/internal/runnerregistry (cached) +? github.com/toki/oto/services/core/oto [no test files] +``` + +## 코드리뷰 결과 + +### 종합 판정 + +FAIL + +### 차원별 평가 + +| 차원 | 평가 | +|------|------| +| Correctness | Fail | +| Completeness | Fail | +| Test coverage | Fail | +| API contract | Fail | +| Code quality | Fail | +| Plan deviation | Fail | +| Verification trust | Pass | + +### 발견된 문제 + +- Required: `services/core/internal/cicdstate/store.go:119`, `services/core/internal/cicdstate/store.go:161`, `services/core/internal/cicdstate/store_test.go:224`, `services/core/internal/cicdstate/store_test.go:295` - 상태 전이가 저장소에 반영되지 않는다. `GetJob`/`GetExecution`은 copy를 반환하는데 테스트는 그 copy에서만 `TransitionTo`를 호출하고 다시 store를 조회하지 않는다. 계획은 `create/update/append/list/snapshot API`와 상태 전이 검증을 요구했으므로, `Store`가 lock 안에서 persisted job/execution 상태를 바꾸는 `TransitionJob`/`TransitionExecution` 같은 update API를 제공하고 테스트가 재조회/Snapshot으로 저장 상태 변경을 검증해야 한다. +- Required: `services/core/internal/cicdstate/store.go:49`, `services/core/internal/cicdstate/store_test.go:328`, `services/core/internal/httpserver/server.go:694` - 변경된 Go 파일이 `gofmt`를 통과하지 않는다. `gofmt -l services/core/internal/cicdstate/store.go services/core/internal/cicdstate/store_test.go services/core/internal/httpserver/server.go services/core/internal/httpserver/server_test.go`가 세 파일을 출력한다. `gofmt -w`를 적용한 뒤 동일 명령이 빈 출력이 되도록 검증해야 한다. + +### 다음 단계 + +FAIL 후속 계획을 작성한다. user-review gate는 트리거하지 않는다. diff --git a/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/code_review_local_G07_1.log b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/code_review_local_G07_1.log new file mode 100644 index 0000000..96cb4b6 --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/code_review_local_G07_1.log @@ -0,0 +1,162 @@ + + +# Code Review Reference - REVIEW_CICD_STATE + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-05 +task=m-control-plane-separation-migration/05+04_core_cicd_state, plan=1, tag=REVIEW_CICD_STATE + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-control-plane-separation-migration/05+04_core_cicd_state/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-control-plane-separation-migration`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_CICD_STATE-1] persisted 상태 전이 API | [x] | +| [REVIEW_CICD_STATE-2] Go 포맷 계약 | [x] | + +## 구현 체크리스트 + +- [x] `cicdstate.Store`에 persisted job/execution state transition/update API를 추가하고 copy 반환 계약을 유지한다. +- [x] store unit tests가 transition 후 `GetJob`/`GetExecution`/snapshot 재조회로 persisted 상태와 invalid/unknown transition 오류를 검증하도록 보강한다. +- [x] 변경된 Go 파일에 `gofmt`를 적용하고 `gofmt -l services/core/internal/cicdstate/store.go services/core/internal/cicdstate/store_test.go services/core/internal/httpserver/server.go services/core/internal/httpserver/server_test.go`이 빈 출력임을 확인한다. +- [x] `cd services/core && go test -count=1 ./...`를 통과시킨다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_1.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G07_1.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-control-plane-separation-migration/05+04_core_cicd_state/`를 `agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-separation-migration/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +없음. 계획대로 store.go에 transition/update API를 추가하고 테스트를 보강했다. + +## 주요 설계 결정 + +`Store.TransitionJob(id, newState)`와 `Store.TransitionExecution(id, newState)`를 추가했다. 각 API는 lock 내에서 내부 record를 직접 조회하고 상태 전이를 적용한 뒤 `s.now()`로 `UpdatedAt`을 갱신한다. 기존 `Job.TransitionTo`/`Execution.TransitionTo`는 copy를 반환하는 조회 API와 함께 유지했다. 전이 검증은 기존 `validTransitions` map을 재사용했다. 반환값은 error만 반환하고 copy 반환 계약은 기존 조회 API가 유지한다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `Store` transition API 호출 후 다시 조회해도 상태가 유지된다. +- copy 반환 계약이 깨지지 않는다. +- `gofmt -l ...`이 빈 출력이다. +- 후속 구현이 계획 범위 밖의 HTTP/runner 계약을 불필요하게 확장하지 않는다. + +## 검증 결과 + +### REVIEW_CICD_STATE-1 중간 검증 +```bash +$ cd services/core && go test -count=1 ./internal/cicdstate +ok github.com/toki/oto/services/core/internal/cicdstate 0.003s +``` + +### REVIEW_CICD_STATE-2 중간 검증 +```bash +$ gofmt -l services/core/internal/cicdstate/store.go services/core/internal/cicdstate/store_test.go services/core/internal/httpserver/server.go services/core/internal/httpserver/server_test.go + +``` + +### 최종 검증 +```bash +$ cd services/core && go test -count=1 ./... +? github.com/toki/oto/services/core/cmd/oto-core [no test files] +ok github.com/toki/oto/services/core/internal/cicdstate 0.003s +ok github.com/toki/oto/services/core/internal/httpserver 0.005s +ok github.com/toki/oto/services/core/internal/runnerregistry 0.002s +? github.com/toki/oto/services/core/oto [no test files] +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## Ownership + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 | +| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움 | +| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 | + +## 코드리뷰 결과 + +### 종합 판정 + +PASS + +### 차원별 평가 + +| 차원 | 평가 | +|------|------| +| Correctness | Pass | +| Completeness | Pass | +| Test coverage | Pass | +| API contract | Pass | +| Code quality | Pass | +| Plan deviation | Pass | +| Verification trust | Pass | + +### 발견된 문제 + +- Nit: `services/core/internal/httpserver/server.go:674` - `jobResponse`, `execResponse`, `logEntryResponse`, `artifactResponse` 타입은 현재 JSON 응답에서 직접 사용되지 않는다. 동작에는 영향이 없지만, 다음 정리 기회에 제거하거나 실제 typed response로 전환하면 파일이 더 또렷해진다. + +### 다음 단계 + +PASS 종결한다. `complete.log`를 작성하고 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/complete.log b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/complete.log new file mode 100644 index 0000000..3d4c7df --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/complete.log @@ -0,0 +1,36 @@ +# Complete - m-control-plane-separation-migration/05+04_core_cicd_state + +## 완료 일시 + +2026-06-05 + +## 요약 + +서버 CI/CD 상태 저장소/API 구현을 2회 리뷰 루프로 완료했다. 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | store 상태 전이가 copy에만 적용되고 gofmt가 미적용되어 후속 계획으로 전환 | +| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | persisted job/execution transition API와 재조회 테스트, gofmt 검증 완료 | + +## 구현/정리 내용 + +- `cicdstate.Store`에 persisted job/execution 상태 전이 API를 추가했다. +- transition 후 `GetJob`/`GetExecution`/snapshot 재조회로 저장 상태 변경을 검증하는 테스트를 보강했다. +- 변경된 Go 파일의 gofmt 상태와 전체 Go 테스트를 검증했다. + +## 최종 검증 + +- `gofmt -l services/core/internal/cicdstate/store.go services/core/internal/cicdstate/store_test.go services/core/internal/httpserver/server.go services/core/internal/httpserver/server_test.go` - PASS; 빈 출력. +- `cd services/core && go test -count=1 ./internal/cicdstate` - PASS; `ok github.com/toki/oto/services/core/internal/cicdstate`. +- `cd services/core && go test -count=1 ./...` - PASS; core 전체 package 테스트 통과. + +## 잔여 Nit + +- `services/core/internal/httpserver/server.go`의 미사용 response struct 타입은 다음 정리 기회에 제거하거나 실제 typed response로 전환할 수 있다. + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/plan_local_G06_0.log b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/plan_local_G06_0.log new file mode 100644 index 0000000..92fe4f5 --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/plan_local_G06_0.log @@ -0,0 +1,180 @@ + + +# Plan - CICD_STATE + +## 이 파일을 읽는 구현 에이전트에게 + +구현 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 변경 사유, 검증 출력으로 채운다. 구현 중 사용자만 결정할 수 있는 범위 변경이나 외부 환경 준비가 필요하면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 근거를 적고 멈춘다. 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 명령 재실행으로 해소 가능한 증거 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +현재 OTO Server는 runner 등록, heartbeat, bootstrap만 소유한다. `oto-owns-cicd`를 완료하려면 먼저 서버 내부에 job, execution, log, artifact 상태를 iop 없이 저장하고 조회하는 최소 API가 있어야 한다. 이 계획은 runner dispatch 구현 전, 서버 상태 모델과 HTTP 계약만 닫는다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사유는 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 묻지 않고, code-review가 요청 타당성을 판단한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/independent-control-plane/PHASE.md` +- `agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md` +- `agent-test/local/rules.md` +- `agent-test/local/agent-smoke.md` +- `agent-test/local/core-smoke.md` +- `services/core/go.mod` +- `services/core/cmd/oto-core/main.go` +- `services/core/internal/httpserver/server.go` +- `services/core/internal/httpserver/server_test.go` +- `services/core/internal/runnerregistry/registry.go` +- `services/core/internal/runnerregistry/registry_test.go` +- `proto/oto/runner.proto` +- `apps/runner/lib/oto/core/build_result.dart` +- `apps/runner/lib/oto/core/execution_context.dart` +- `apps/runner/lib/oto/application.dart` +- `apps/runner/test/oto_core_test.dart` +- `apps/runner/test/oto_application_test.dart` +- `Makefile` + +### 테스트 환경 규칙 + +`test_env=local`이다. `agent-test/local/rules.md`를 읽었고, core/agent 관련 profile로 `agent-test/local/core-smoke.md`, `agent-test/local/agent-smoke.md`를 읽었다. 이 하위 계획은 Go core service 변경이 중심이라 최종 검증은 repository manifest의 `cd services/core && go test -count=1 ./...`를 사용한다. Dart 실행 결과 계약을 직접 바꾸지 않으므로 agent-smoke Dart 명령은 후속 runner/smoke 계획에서 다룬다. + +### 테스트 커버리지 공백 + +- 서버 job 생성/조회/상태 전이: 기존 테스트 없음. `services/core/internal/cicdstate/*_test.go`와 `server_test.go`에 새 테스트가 필요하다. +- execution history, log append/list, artifact append/list: 기존 테스트 없음. in-memory store unit test와 HTTP handler test가 필요하다. +- iop 의존 배제: 기존 server/core 테스트는 iop를 참조하지 않는다. 새 API도 `services/core` 내부와 `proto/oto`만 사용하도록 검증한다. + +### 심볼 참조 + +이 계획은 기존 symbol rename/remove를 요구하지 않는다. 새 package, 새 handler, 새 DTO만 추가한다. + +### 분할 판단 + +split 정책을 먼저 평가했다. 이 work는 서버 상태/API, runner dispatch 계약, 통합 smoke/evidence가 서로 다른 검증과 위험을 가지므로 split이 필요하다. 공유 task group은 `m-control-plane-separation-migration`이다. + +- `05+04_core_cicd_state`: archived predecessor `agent-task/archive/2026/06/m-control-plane-separation-migration/04+02_bootstrap_command/complete.log`로 04 완료 확인됨. 서버 상태/API foundation. +- `06+05_runner_dispatch_contract`: 05 완료 후 runner가 서버 job 계약을 소비하도록 확장. +- `07+05,06_cicd_smoke_evidence`: 05와 06 완료 후 통합 smoke 및 roadmap completion evidence. + +### 범위 결정 근거 + +이 계획은 OTO Server의 in-memory 최소 상태 소유권만 구현한다. 실제 scheduling policy, durable DB/storage, artifact binary upload, auth/audit, runner가 job을 실행하는 client loop는 제외한다. 이 범위는 현재 Milestone의 범위 제외와 일치한다. + +### 빌드 등급 + +`local-G06`. Go 서버 내부 상태 모델과 HTTP handler는 범위가 명확하고 `go test -count=1 ./...`로 검증 가능하다. storage/auth/protocol 장기 위험은 제외되어 local review로 충분하다. + +## 구현 체크리스트 + +- [ ] `services/core/internal/cicdstate` in-memory store를 추가하고 job/execution/log/artifact 모델과 상태 전이를 구현한다. +- [ ] `services/core/internal/httpserver/server.go`에 job/execution/log/artifact JSON API를 추가한다. +- [ ] Go unit/handler 테스트로 생성, 조회, 상태 전이, 로그/artifact 누적, unknown id 오류를 검증한다. +- [ ] `cd services/core && go test -count=1 ./...`를 통과시킨다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [CICD_STATE-1] 서버 CI/CD 상태 저장소 + +### 문제 + +[registry.go](/config/workspace/oto/services/core/internal/runnerregistry/registry.go:36)는 runner 상태만 저장한다. job/execution/log/artifact 상태를 둘 곳이 없어 OTO Server가 CI/CD 상태를 소유한다는 Task를 충족할 수 없다. + +Before: + +```go +36 type Registry struct { +37 mu sync.RWMutex +38 now func() time.Time +39 runners map[string]RunnerRecord +40 } +``` + +### 해결 방법 + +`services/core/internal/cicdstate/store.go`를 새로 만들고 `Store`에 jobs, executions, logs, artifacts map을 둔다. 최소 상태는 `queued`, `running`, `succeeded`, `failed`, `canceled` 문자열 상수로 둔다. 모든 snapshot/list는 slice/map copy를 반환한다. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/cicdstate/store.go`: 모델, 상태 상수, create/update/append/list/snapshot API. +- [ ] `services/core/internal/cicdstate/store_test.go`: copy 방어, 상태 전이, 누적 이벤트 테스트. + +### 테스트 작성 + +작성한다. `TestStoreCreatesJobAndExecution`, `TestStoreAppendsLogsAndArtifacts`, `TestStoreRejectsUnknownExecution`을 추가한다. + +### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/cicdstate +``` + +기대 결과: package tests PASS. + +## [CICD_STATE-2] HTTP API 표면 + +### 문제 + +[server.go](/config/workspace/oto/services/core/internal/httpserver/server.go:29)는 registry만 주입하고 runner endpoint만 등록한다. CI/CD job/execution 상태를 생성하거나 조회하는 endpoint가 없다. + +Before: + +```go +29 func NewServerWithRegistry(addr string, registry *runnerregistry.Registry) *Server { +30 mux := http.NewServeMux() +31 +32 mux.HandleFunc("/healthz", handleHealthz) +33 mux.HandleFunc("/readyz", handleReadyz) +34 mux.HandleFunc("/api/v1/runners/register", handleRunnerRegister(registry)) +``` + +### 해결 방법 + +`NewServerWithRegistry`는 새 `cicdstate.Store`를 함께 만들고 내부 helper `NewServerWithRegistryAndStore`로 테스트 주입을 허용한다. Endpoint는 최소 JSON API로 둔다. + +- `POST /api/v1/jobs` +- `GET /api/v1/jobs/{id}` +- `POST /api/v1/jobs/{id}/executions` +- `GET /api/v1/executions/{id}` +- `POST /api/v1/executions/{id}/logs` +- `GET /api/v1/executions/{id}/logs` +- `POST /api/v1/executions/{id}/artifacts` +- `GET /api/v1/executions/{id}/artifacts` + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/httpserver/server.go`: store 주입, handlers, JSON writer helper. +- [ ] `services/core/internal/httpserver/server_test.go`: endpoint routing과 happy/error path. + +### 테스트 작성 + +작성한다. `TestHandleCreateJob`, `TestHandleCreateExecution`, `TestHandleExecutionLogsAndArtifacts`, `TestHandleCicdUnknownIds`를 추가한다. + +### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/httpserver +``` + +기대 결과: package tests PASS. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `services/core/internal/cicdstate/store.go` | CICD_STATE-1 | +| `services/core/internal/cicdstate/store_test.go` | CICD_STATE-1 | +| `services/core/internal/httpserver/server.go` | CICD_STATE-2 | +| `services/core/internal/httpserver/server_test.go` | CICD_STATE-2 | + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./... +``` + +기대 결과: 모든 Go 테스트 PASS. Go test cache output은 허용하지 않으므로 `-count=1`을 유지한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/plan_local_G07_1.log b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/plan_local_G07_1.log new file mode 100644 index 0000000..ec43993 --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-separation-migration/05+04_core_cicd_state/plan_local_G07_1.log @@ -0,0 +1,109 @@ + + +# Plan - REVIEW_CICD_STATE + +## 이 파일을 읽는 구현 에이전트에게 + +구현 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 변경 사유, 검증 출력으로 채운다. 구현 중 사용자만 결정할 수 있는 범위 변경이나 외부 환경 준비가 필요하면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 근거를 적고 멈춘다. 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. 명령 재실행으로 해소 가능한 증거 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +이 후속 계획은 `code_review_local_G06_0.log`의 FAIL 판정에서 나온 Required 이슈만 닫는다. 서버 CI/CD 상태 API의 큰 범위는 유지하고, 저장소 상태 전이 지속성 및 Go 포맷 계약을 보완한다. + +## 범위 결정 근거 + +- 포함: `cicdstate.Store`의 persisted 상태 전이/update API, 이에 대한 unit test, 변경 Go 파일 gofmt. +- 제외: durable storage, scheduling policy, runner execution loop, auth/audit, artifact binary upload, 새 HTTP endpoint 확장. 상태 전이 HTTP endpoint가 꼭 필요하다고 판단되면 현재 계획 대비 변경 사항에 이유와 테스트를 기록한다. + +## 구현 체크리스트 + +- [ ] `cicdstate.Store`에 persisted job/execution state transition/update API를 추가하고 copy 반환 계약을 유지한다. +- [ ] store unit tests가 transition 후 `GetJob`/`GetExecution`/snapshot 재조회로 persisted 상태와 invalid/unknown transition 오류를 검증하도록 보강한다. +- [ ] 변경된 Go 파일에 `gofmt`를 적용하고 `gofmt -l services/core/internal/cicdstate/store.go services/core/internal/cicdstate/store_test.go services/core/internal/httpserver/server.go services/core/internal/httpserver/server_test.go`이 빈 출력임을 확인한다. +- [ ] `cd services/core && go test -count=1 ./...`를 통과시킨다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [REVIEW_CICD_STATE-1] persisted 상태 전이 API + +### 문제 + +`services/core/internal/cicdstate/store.go`의 `GetJob`/`GetExecution`은 copy를 반환한다. 현재 테스트는 이 copy에서 `TransitionTo`를 호출하고 다시 store를 조회하지 않으므로, 실제 저장된 job/execution 상태는 바뀌지 않는다. 이전 계획은 `create/update/append/list/snapshot API`와 상태 전이 검증을 요구했다. + +### 해결 방법 + +`Store`가 lock 안에서 내부 record를 조회하고 상태 전이를 적용하는 update API를 추가한다. 예: `TransitionJob(id, newState)`와 `TransitionExecution(id, newState)`. 전이 검증은 기존 `validTransitions`를 재사용하되, `UpdatedAt`은 `s.now()`를 사용한다. 반환값은 기존 조회 API와 동일하게 copy를 반환하거나 필요 없으면 error만 반환한다. + +기존 `Job.TransitionTo`/`Execution.TransitionTo`는 유지하더라도 store update API에서 사용하거나, 혼동을 줄이기 위해 내부 helper로 축소한다. 후속 runner dispatch가 store를 통해 상태를 바꿀 수 있어야 한다. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/cicdstate/store.go`: persisted state transition/update API 추가, copy 반환 계약 유지. +- [ ] `services/core/internal/cicdstate/store_test.go`: transition 후 재조회/Snapshot으로 저장 상태 변경 검증. + +### 테스트 작성 + +작성한다. 최소한 아래를 검증한다. + +- job: queued -> running -> succeeded 후 `GetJob` 또는 `SnapshotJobs`가 변경된 상태를 반환한다. +- execution: queued -> running -> failed/succeeded 후 `GetExecution` 또는 `SnapshotExecutions`가 변경된 상태를 반환한다. +- terminal state에서 추가 전이는 error이고 저장 상태가 바뀌지 않는다. +- unknown job/execution id 전이는 error이다. + +### 중간 검증 + +```bash +cd services/core && go test -count=1 ./internal/cicdstate +``` + +기대 결과: package tests PASS. + +## [REVIEW_CICD_STATE-2] Go 포맷 계약 + +### 문제 + +`gofmt -l services/core/internal/cicdstate/store.go services/core/internal/cicdstate/store_test.go services/core/internal/httpserver/server.go services/core/internal/httpserver/server_test.go`가 `store.go`, `store_test.go`, `server.go`를 출력한다. Go 소스는 gofmt 적용 상태로 남겨야 한다. + +### 해결 방법 + +변경된 Go 파일에 `gofmt -w`를 적용한다. 포맷만 바뀌는 파일은 범위를 넓히지 말고, 포맷 검증 명령이 빈 출력인지 기록한다. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/cicdstate/store.go`: gofmt 적용. +- [ ] `services/core/internal/cicdstate/store_test.go`: gofmt 적용. +- [ ] `services/core/internal/httpserver/server.go`: gofmt 적용. +- [ ] `services/core/internal/httpserver/server_test.go`: 명령 대상에 포함해 포맷 상태 확인. + +### 중간 검증 + +```bash +gofmt -l services/core/internal/cicdstate/store.go services/core/internal/cicdstate/store_test.go services/core/internal/httpserver/server.go services/core/internal/httpserver/server_test.go +``` + +기대 결과: 빈 출력. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `services/core/internal/cicdstate/store.go` | REVIEW_CICD_STATE-1, REVIEW_CICD_STATE-2 | +| `services/core/internal/cicdstate/store_test.go` | REVIEW_CICD_STATE-1, REVIEW_CICD_STATE-2 | +| `services/core/internal/httpserver/server.go` | REVIEW_CICD_STATE-2 | +| `services/core/internal/httpserver/server_test.go` | REVIEW_CICD_STATE-2 검증 대상 | + +## 리뷰어 체크포인트 + +- `Store` transition API 호출 후 다시 조회해도 상태가 유지된다. +- copy 반환 계약이 깨지지 않는다. +- `gofmt -l ...`이 빈 출력이다. +- 후속 구현이 계획 범위 밖의 HTTP/runner 계약을 불필요하게 확장하지 않는다. + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./... +``` + +기대 결과: 모든 Go 테스트 PASS. Go test cache output은 허용하지 않으므로 `-count=1`을 유지한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-control-plane-separation-migration/06+05_runner_dispatch_contract/CODE_REVIEW-cloud-G07.md b/agent-task/m-control-plane-separation-migration/06+05_runner_dispatch_contract/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..97a28c3 --- /dev/null +++ b/agent-task/m-control-plane-separation-migration/06+05_runner_dispatch_contract/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,103 @@ + + +# Code Review Reference - CICD_RUNNER + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-05 +task=m-control-plane-separation-migration/06+05_runner_dispatch_contract, plan=0, tag=CICD_RUNNER + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [CICD_RUNNER-1] runner job proto 계약 | [ ] | +| [CICD_RUNNER-2] Dart runner reporting client | [ ] | + +## 구현 체크리스트 + +- [ ] `proto/oto/runner.proto`에 runner job claim/reporting 최소 메시지를 추가하고 `make proto`로 Go/Dart 생성물을 갱신한다. +- [ ] Dart runner client에 job claim, execution report, log/artifact report helper를 추가한다. +- [ ] `DefaultAgentRunner` 또는 OTO Server session 흐름에 polling/reporting을 주입 가능한 형태로 연결한다. +- [ ] fake HTTP server 테스트로 runner가 job을 받고 BuildResult/stepEvents를 OTO Server report payload로 보내는지 검증한다. +- [ ] `make proto`, `cd services/core && go test -count=1 ./...`, `cd apps/runner && dart analyze`, agent-smoke Dart 테스트를 통과시킨다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] active files를 log로 아카이브하고 PASS이면 `complete.log`를 작성한다. +- [ ] PASS이면 active task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- 05 predecessor 완료 없이 구현을 시작하지 않았는지 확인한다. +- proto field numbering이 기존 field를 재사용하거나 바꾸지 않는지 확인한다. +- runner polling/reporting이 close 후 중단되고 테스트에서 deterministic한지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +### CICD_RUNNER-1 중간 검증 +```bash +$ make proto +(output) +``` + +### CICD_RUNNER-2 중간 검증 +```bash +$ cd apps/runner && dart test test/oto_agent_registration_test.dart +(output) +``` + +### 최종 검증 +```bash +$ make proto +(output) +$ cd services/core && go test -count=1 ./... +(output) +$ cd apps/runner && dart analyze +(output) +$ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart +(output) +``` + +## 코드리뷰 결과 + diff --git a/agent-task/m-control-plane-separation-migration/06+05_runner_dispatch_contract/PLAN-cloud-G07.md b/agent-task/m-control-plane-separation-migration/06+05_runner_dispatch_contract/PLAN-cloud-G07.md new file mode 100644 index 0000000..ba96df8 --- /dev/null +++ b/agent-task/m-control-plane-separation-migration/06+05_runner_dispatch_contract/PLAN-cloud-G07.md @@ -0,0 +1,186 @@ + + +# Plan - CICD_RUNNER + +## 이 파일을 읽는 구현 에이전트에게 + +구현 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 변경 사유, 검증 출력으로 채운다. 구현 중 사용자만 결정할 수 있는 범위 변경이나 외부 환경 준비가 필요하면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 근거를 적고 멈춘다. 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. + +## 배경 + +서버 상태/API가 생겨도 runner가 job을 가져오고 execution 결과를 보고하지 않으면 OTO Server가 CI/CD 실행 상태를 소유한다고 볼 수 없다. 이 계획은 `05+04_core_cicd_state` 완료 후 runner/server 계약을 `proto/oto`와 Dart client에 연결한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사유는 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 묻지 않고, code-review가 요청 타당성을 판단한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md` +- `agent-test/local/rules.md` +- `agent-test/local/agent-smoke.md` +- `services/core/go.mod` +- `proto/oto/runner.proto` +- `Makefile` +- `apps/runner/pubspec.yaml` +- `apps/runner/lib/oto/agent/agent_config.dart` +- `apps/runner/lib/oto/agent/agent_runner.dart` +- `apps/runner/lib/oto/agent/edge_registration_client.dart` +- `apps/runner/lib/oto/core/build_result.dart` +- `apps/runner/lib/oto/core/execution_context.dart` +- `apps/runner/lib/oto/application.dart` +- `apps/runner/test/oto_agent_config_test.dart` +- `apps/runner/test/oto_agent_registration_test.dart` +- `apps/runner/test/oto_server_connection_smoke_test.dart` +- `apps/runner/test/oto_core_test.dart` +- `apps/runner/test/oto_application_test.dart` +- `services/core/internal/httpserver/server.go` +- `services/core/internal/httpserver/server_test.go` + +### 테스트 환경 규칙 + +`test_env=local`이다. `agent-test/local/rules.md`와 `agent-test/local/agent-smoke.md`를 읽었다. agent-smoke는 `dart analyze`와 agent 관련 `dart test` 명령을 필수로 둔다. 이 계획은 proto generation과 Go server도 건드리므로 `make proto`, `cd services/core && go test -count=1 ./...`, `cd apps/runner && dart analyze`, agent-smoke Dart 테스트를 최종 검증으로 둔다. + +### 테스트 커버리지 공백 + +- runner job polling/claim/reporting: 기존 테스트 없음. `oto_agent_registration_test.dart` 또는 새 agent test에 fake HTTP server 기반 테스트가 필요하다. +- proto job/execution message: 기존 `runner.proto`에는 register/heartbeat/bootstrap만 있다. 생성물 갱신 후 Go/Dart compile 테스트가 필요하다. +- BuildResult to execution report mapping: `oto_application_test.dart`는 `BuildResult.toJson`과 stepEvents를 검증한다. server report payload mapping 테스트가 추가로 필요하다. + +### 심볼 참조 + +기존 symbol rename/remove는 요구하지 않는다. 새 proto message와 Dart helper를 추가한다. `RegisterRunnerRequest`, `HeartbeatRequest`, `BootstrapCommandRequest` call site는 유지한다. + +### 분할 판단 + +split 정책을 평가했다. 이 계획은 `06+05_runner_dispatch_contract`이며 predecessor `05`는 같은 task group의 `05+04_core_cicd_state/complete.log`가 필요하다. 현재 작성 시점에는 05가 active plan 상태라 predecessor는 아직 미충족이다. 구현 에이전트는 `agent-task/m-control-plane-separation-migration/05+04_core_cicd_state/complete.log` 또는 archive의 matching complete.log를 확인한 뒤 시작한다. + +### 범위 결정 근거 + +이 계획은 runner가 OTO Server job 계약을 소비하고 BuildResult/stepEvents를 report payload로 변환하는 최소 흐름만 다룬다. 장기 streaming transport, durable artifact upload, scheduling policy, self-update는 제외한다. + +### 빌드 등급 + +`cloud-G07`. proto/schema, Go/Dart 생성물, runner background HTTP workflow가 함께 움직여 검증 신뢰와 cross-runtime 판단이 필요하다. + +## 의존 관계 및 구현 순서 + +`06+05_runner_dispatch_contract`는 directory name 기준으로 `05+04_core_cicd_state` 완료에 의존한다. 구현 시작 전 predecessor complete.log를 확인한다. + +## 구현 체크리스트 + +- [ ] `proto/oto/runner.proto`에 runner job claim/reporting 최소 메시지를 추가하고 `make proto`로 Go/Dart 생성물을 갱신한다. +- [ ] Dart runner client에 job claim, execution report, log/artifact report helper를 추가한다. +- [ ] `DefaultAgentRunner` 또는 OTO Server session 흐름에 polling/reporting을 주입 가능한 형태로 연결한다. +- [ ] fake HTTP server 테스트로 runner가 job을 받고 BuildResult/stepEvents를 OTO Server report payload로 보내는지 검증한다. +- [ ] `make proto`, `cd services/core && go test -count=1 ./...`, `cd apps/runner && dart analyze`, agent-smoke Dart 테스트를 통과시킨다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [CICD_RUNNER-1] runner job proto 계약 + +### 문제 + +[runner.proto](/config/workspace/oto/proto/oto/runner.proto:16)는 registration, heartbeat, bootstrap만 정의한다. runner가 OTO Server job을 claim하거나 execution result를 보고할 계약이 없다. + +Before: + +```proto +16 message RegisterRunnerRequest { +17 string enrollment_token = 1; +18 string runner_id = 2; +... +53 message BootstrapCommandResponse { +54 string bootstrap_command = 1; +55 } +``` + +### 해결 방법 + +`JobClaimRequest/Response`, `ExecutionReportRequest/Response`, `LogAppendRequest`, `ArtifactReportRequest`를 추가한다. HTTP/JSON helpers가 우선이더라도 proto 원본은 `proto/oto`에 둔다. 생성물은 `make proto`로 갱신한다. + +### 수정 파일 및 체크리스트 + +- [ ] `proto/oto/runner.proto` +- [ ] `services/core/oto/runner.pb.go` +- [ ] `apps/runner/lib/oto/agent/oto/runner.pb.dart` +- [ ] `apps/runner/lib/oto/agent/oto/runner.pbjson.dart` +- [ ] `apps/runner/lib/oto/agent/oto/runner.pbenum.dart` 필요 시 + +### 테스트 작성 + +생성물 자체의 별도 테스트는 작성하지 않는다. compile/analyze와 runner/server behavior 테스트로 검증한다. + +### 중간 검증 + +```bash +make proto +``` + +기대 결과: Go/Dart generated files 갱신 성공. + +## [CICD_RUNNER-2] Dart runner reporting client + +### 문제 + +[edge_registration_client.dart](/config/workspace/oto/apps/runner/lib/oto/agent/edge_registration_client.dart:198)는 등록 후 heartbeat/disconnect만 수행한다. job claim/reporting URI와 payload helper가 없다. + +Before: + +```dart +198 @override +199 Future openSession(AgentConfig config) async { +... +341 Future _sendHeartbeat() async { +342 if (_closed) return; +``` + +### 해결 방법 + +기존 session을 유지하되 job 관련 helper를 별도 class나 주입 가능한 collaborator로 분리한다. `BuildResult.toJson()`과 `StepEvent.toJson()`을 payload source로 사용한다. runner loop는 테스트에서 interval/client를 주입할 수 있어야 한다. + +### 수정 파일 및 체크리스트 + +- [ ] `apps/runner/lib/oto/agent/edge_registration_client.dart` +- [ ] `apps/runner/lib/oto/agent/agent_runner.dart` +- [ ] 필요 시 `apps/runner/lib/oto/agent/oto_server_job_client.dart` +- [ ] `apps/runner/test/oto_agent_registration_test.dart` + +### 테스트 작성 + +작성한다. fake `HttpServer`로 claim response, report request body, close 후 polling 중단을 검증한다. + +### 중간 검증 + +```bash +cd apps/runner && dart test test/oto_agent_registration_test.dart +``` + +기대 결과: All tests passed. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `proto/oto/runner.proto` | CICD_RUNNER-1 | +| `services/core/oto/runner.pb.go` | CICD_RUNNER-1 | +| `apps/runner/lib/oto/agent/oto/runner.pb.dart` | CICD_RUNNER-1 | +| `apps/runner/lib/oto/agent/oto/runner.pbjson.dart` | CICD_RUNNER-1 | +| `apps/runner/lib/oto/agent/edge_registration_client.dart` | CICD_RUNNER-2 | +| `apps/runner/lib/oto/agent/agent_runner.dart` | CICD_RUNNER-2 | +| `apps/runner/test/oto_agent_registration_test.dart` | CICD_RUNNER-2 | + +## 최종 검증 + +```bash +make proto +cd services/core && go test -count=1 ./... +cd apps/runner && dart analyze +cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart +``` + +기대 결과: 모든 명령 PASS. Go test cache output은 허용하지 않으므로 `-count=1`을 유지한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence/CODE_REVIEW-cloud-G07.md b/agent-task/m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..5836aaf --- /dev/null +++ b/agent-task/m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,101 @@ + + +# Code Review Reference - CICD_SMOKE + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization is review-agent-only. + +## 개요 + +date=2026-06-05 +task=m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence, plan=0, tag=CICD_SMOKE + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md` +- Task ids: + - `oto-owns-cicd`: OTO Server는 CI/CD runner registry, job, execution history, log/artifact 상태를 소유하고, iop Edge나 iop Control Plane을 전제로 하지 않는다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [CICD_SMOKE-1] OTO Server CI/CD ownership smoke | [ ] | + +## 구현 체크리스트 + +- [ ] `apps/runner/test/oto_server_connection_smoke_test.dart`에 OTO Server job/execution/log/artifact 상태 round trip smoke를 추가한다. +- [ ] smoke가 iop Edge나 iop Control Plane endpoint를 사용하지 않는지 검증한다. +- [ ] Go/Dart 필수 검증을 실행하고 실제 출력을 review stub에 기록한다. +- [ ] PASS 시 `Roadmap Targets`의 `oto-owns-cicd` 완료 근거로 쓸 수 있도록 검증 내용을 명확히 남긴다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] active files를 log로 아카이브하고 PASS이면 `complete.log`를 작성한다. +- [ ] PASS이면 active task directory를 archive로 이동한다. +- [ ] PASS이면 Roadmap Completion 메타데이터에 `oto-owns-cicd`를 포함한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- 05와 06 predecessor complete.log가 존재하는지 확인한다. +- smoke가 실제 Go OTO Server process를 통해 job/execution/log/artifact 상태를 조회하는지 확인한다. +- Roadmap Targets는 07 PASS 때만 `oto-owns-cicd` 완료로 복사되어야 한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +### CICD_SMOKE-1 중간 검증 +```bash +$ cd apps/runner && dart test test/oto_server_connection_smoke_test.dart +(output) +``` + +### 최종 검증 +```bash +$ cd services/core && go test -count=1 ./... +(output) +$ cd apps/runner && dart analyze +(output) +$ cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart +(output) +``` + +## 코드리뷰 결과 + diff --git a/agent-task/m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence/PLAN-cloud-G07.md b/agent-task/m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence/PLAN-cloud-G07.md new file mode 100644 index 0000000..ed542df --- /dev/null +++ b/agent-task/m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence/PLAN-cloud-G07.md @@ -0,0 +1,138 @@ + + +# Plan - CICD_SMOKE + +## 이 파일을 읽는 구현 에이전트에게 + +구현 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용, 변경 사유, 검증 출력으로 채운다. 구현 중 사용자만 결정할 수 있는 범위 변경이나 외부 환경 준비가 필요하면 리뷰 stub의 `사용자 리뷰 요청` 섹션에 근거를 적고 멈춘다. 직접 사용자에게 질문하거나 `USER_REVIEW.md`, `complete.log`, archive 로그를 만들지 않는다. + +## 배경 + +`oto-owns-cicd`는 서버 상태 모델과 runner reporting이 모두 동작한 뒤 통합 증거가 있어야 완료 처리할 수 있다. 이 계획은 05/06 완료 후 Go OTO Server와 Dart runner smoke를 확장해 job, execution, log, artifact 상태가 OTO Server에 남는지 검증한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사유는 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 사용자에게 직접 묻지 않고, code-review가 요청 타당성을 판단한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md` +- Task ids: + - `oto-owns-cicd`: OTO Server는 CI/CD runner registry, job, execution history, log/artifact 상태를 소유하고, iop Edge나 iop Control Plane을 전제로 하지 않는다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/independent-control-plane/milestones/control-plane-separation-migration.md` +- `agent-test/local/rules.md` +- `agent-test/local/agent-smoke.md` +- `services/core/internal/httpserver/server.go` +- `services/core/internal/httpserver/server_test.go` +- `services/core/internal/runnerregistry/registry.go` +- `apps/runner/lib/oto/agent/edge_registration_client.dart` +- `apps/runner/lib/oto/agent/agent_runner.dart` +- `apps/runner/test/oto_server_connection_smoke_test.dart` +- `apps/runner/test/oto_agent_registration_test.dart` +- `apps/runner/lib/oto/core/build_result.dart` +- `apps/runner/test/oto_application_test.dart` +- `Makefile` + +### 테스트 환경 규칙 + +`test_env=local`이다. `agent-test/local/rules.md`와 `agent-test/local/agent-smoke.md`를 읽었다. 이 계획은 OTO Server registration/bootstrap smoke의 확장이므로 agent-smoke의 `dart analyze`와 지정 Dart test 명령을 최종 검증에 포함한다. Go server fresh test는 repository manifest를 사용해 `cd services/core && go test -count=1 ./...`로 실행한다. + +### 테스트 커버리지 공백 + +- 현재 `oto_server_connection_smoke_test.dart`는 register/online/disconnect와 bootstrap command만 검증한다. job/execution/log/artifact round trip smoke가 없다. +- 현재 Go handler tests는 runner endpoint와 bootstrap만 검증한다. 05에서 추가한 API가 smoke와 같은 JSON shape를 유지하는지 최종 확인이 필요하다. + +### 심볼 참조 + +symbol rename/remove는 계획하지 않는다. 새 smoke test와 필요 시 작은 fixture helper만 추가한다. + +### 분할 판단 + +split 정책을 평가했다. `07+05,06_cicd_smoke_evidence`는 directory name 기준으로 `05+04_core_cicd_state`와 `06+05_runner_dispatch_contract`에 의존한다. 작성 시점에는 둘 다 active plan 상태라 predecessor는 미충족이다. 구현 시작 전 각각의 active 또는 archived `complete.log`를 확인한다. + +### 범위 결정 근거 + +이 계획은 smoke/evidence 전용이다. 새로운 scheduling policy, durable artifact upload, UI, auth는 추가하지 않는다. 05/06에서 구현한 public 표면을 소비해 milestone Task 완료 증거를 수집하는 데 집중한다. + +### 빌드 등급 + +`cloud-G07`. long-running Go server process와 Dart integration smoke, evidence trust가 핵심이라 cloud lane이 적합하다. + +## 의존 관계 및 구현 순서 + +구현 시작 전 아래 predecessor가 PASS 완료되어야 한다. + +- `05+04_core_cicd_state`: `complete.log` 필요. +- `06+05_runner_dispatch_contract`: `complete.log` 필요. + +## 구현 체크리스트 + +- [ ] `apps/runner/test/oto_server_connection_smoke_test.dart`에 OTO Server job/execution/log/artifact 상태 round trip smoke를 추가한다. +- [ ] smoke가 iop Edge나 iop Control Plane endpoint를 사용하지 않는지 검증한다. +- [ ] Go/Dart 필수 검증을 실행하고 실제 출력을 review stub에 기록한다. +- [ ] PASS 시 `Roadmap Targets`의 `oto-owns-cicd` 완료 근거로 쓸 수 있도록 검증 내용을 명확히 남긴다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [CICD_SMOKE-1] OTO Server CI/CD ownership smoke + +### 문제 + +[oto_server_connection_smoke_test.dart](/config/workspace/oto/apps/runner/test/oto_server_connection_smoke_test.dart:16)는 runner register/online/disconnect만 확인한다. job/execution/log/artifact 상태가 OTO Server에 남는지 검증하지 않는다. + +Before: + +```dart +16 test( +17 'OTO Dart runner registers with Go OTO Server, goes online, and disconnects on close', +... +143 'OTO Server issues runner bootstrap command and serves bootstrap script', +``` + +### 해결 방법 + +05/06에서 추가된 API와 runner helper를 사용해 smoke test를 추가한다. 테스트는 Go OTO Server를 띄우고, runner가 job을 claim/report한 뒤 server API로 execution status, step log, artifact metadata를 조회한다. + +### 수정 파일 및 체크리스트 + +- [ ] `apps/runner/test/oto_server_connection_smoke_test.dart`: 통합 smoke 추가. +- [ ] 필요 시 `apps/runner/test/oto_agent_registration_test.dart`: helper coverage 보강. +- [ ] 필요 시 `services/core/internal/httpserver/server_test.go`: smoke와 동일 payload shape 보강. + +### 테스트 작성 + +작성한다. 테스트 이름은 `OTO Server owns job execution logs and artifacts reported by runner`로 둔다. + +### 중간 검증 + +```bash +cd apps/runner && dart test test/oto_server_connection_smoke_test.dart +``` + +기대 결과: All tests passed. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/runner/test/oto_server_connection_smoke_test.dart` | CICD_SMOKE-1 | +| `apps/runner/test/oto_agent_registration_test.dart` | CICD_SMOKE-1 | +| `services/core/internal/httpserver/server_test.go` | CICD_SMOKE-1 | + +## 최종 검증 + +```bash +cd services/core && go test -count=1 ./... +cd apps/runner && dart analyze +cd apps/runner && dart test test/oto_agent_migration_plan_test.dart test/oto_agent_bootstrap_script_test.dart test/oto_agent_cli_test.dart test/oto_agent_config_test.dart test/oto_agent_registration_test.dart test/oto_server_connection_smoke_test.dart +``` + +기대 결과: 모든 명령 PASS. Go test cache output은 허용하지 않으므로 `-count=1`을 유지한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/services/core/internal/cicdstate/store.go b/services/core/internal/cicdstate/store.go new file mode 100644 index 0000000..fed54aa --- /dev/null +++ b/services/core/internal/cicdstate/store.go @@ -0,0 +1,323 @@ +package cicdstate + +import ( + "fmt" + "sync" + "time" +) + +const ( + StateQueued = "queued" + StateRunning = "running" + StateSucceeded = "succeeded" + StateFailed = "failed" + StateCanceled = "canceled" +) + +var validTransitions = map[string][]string{ + StateQueued: {StateRunning, StateFailed, StateCanceled}, + StateRunning: {StateSucceeded, StateFailed}, + StateSucceeded: nil, + StateFailed: nil, + StateCanceled: nil, +} + +type Job struct { + ID string + Name string + State string + CreatedAt time.Time + UpdatedAt time.Time + ExecutionID string +} + +func (j *Job) TransitionTo(newState string) error { + allowed, ok := validTransitions[j.State] + if !ok { + return fmt.Errorf("unknown state: %s", j.State) + } + for _, s := range allowed { + if s == newState { + j.State = newState + j.UpdatedAt = time.Now() + return nil + } + } + return fmt.Errorf("invalid transition from %s to %s", j.State, newState) +} + +type Execution struct { + ID string + JobID string + State string + CreatedAt time.Time + UpdatedAt time.Time + Logs []LogEntry + Artifacts []ArtifactEntry +} + +func (e *Execution) TransitionTo(newState string) error { + allowed, ok := validTransitions[e.State] + if !ok { + return fmt.Errorf("unknown state: %s", e.State) + } + for _, s := range allowed { + if s == newState { + e.State = newState + e.UpdatedAt = time.Now() + return nil + } + } + return fmt.Errorf("invalid transition from %s to %s", e.State, newState) +} + +type LogEntry struct { + Timestamp time.Time + Line string +} + +type ArtifactEntry struct { + Name string + Path string +} + +type Store struct { + mu sync.RWMutex + jobs map[string]*Job + executions map[string]*Execution + now func() time.Time +} + +func NewStore() *Store { + return &Store{ + now: time.Now, + jobs: make(map[string]*Job), + executions: make(map[string]*Execution), + } +} + +func (s *Store) CreateJob(id, name string) (*Job, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.jobs[id]; exists { + return nil, fmt.Errorf("job %s already exists", id) + } + + now := s.now() + job := &Job{ + ID: id, + Name: name, + State: StateQueued, + CreatedAt: now, + UpdatedAt: now, + } + s.jobs[id] = job + return job, nil +} + +func (s *Store) GetJob(id string) (*Job, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + job, ok := s.jobs[id] + if !ok { + return nil, fmt.Errorf("job not found: %s", id) + } + + return copyJob(job), nil +} + +func (s *Store) CreateExecution(jobID, execID string) (*Execution, error) { + s.mu.Lock() + defer s.mu.Unlock() + + job, ok := s.jobs[jobID] + if !ok { + return nil, fmt.Errorf("job not found: %s", jobID) + } + + if job.ExecutionID != "" { + return nil, fmt.Errorf("job %s already has execution %s", jobID, job.ExecutionID) + } + + now := s.now() + exec := &Execution{ + ID: execID, + JobID: jobID, + State: StateQueued, + CreatedAt: now, + UpdatedAt: now, + Logs: make([]LogEntry, 0), + Artifacts: make([]ArtifactEntry, 0), + } + s.executions[execID] = exec + job.ExecutionID = execID + job.UpdatedAt = now + + return exec, nil +} + +func (s *Store) GetExecution(id string) (*Execution, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + exec, ok := s.executions[id] + if !ok { + return nil, fmt.Errorf("execution not found: %s", id) + } + + return copyExecution(exec), nil +} + +func (s *Store) AppendLog(execID string, line string) error { + s.mu.Lock() + defer s.mu.Unlock() + + exec, ok := s.executions[execID] + if !ok { + return fmt.Errorf("execution not found: %s", execID) + } + + exec.Logs = append(exec.Logs, LogEntry{ + Timestamp: s.now(), + Line: line, + }) + exec.UpdatedAt = s.now() + + return nil +} + +func (s *Store) GetLogs(execID string) ([]LogEntry, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + exec, ok := s.executions[execID] + if !ok { + return nil, fmt.Errorf("execution not found: %s", execID) + } + + result := make([]LogEntry, len(exec.Logs)) + copy(result, exec.Logs) + return result, nil +} + +func (s *Store) AppendArtifact(execID string, name string, path string) error { + s.mu.Lock() + defer s.mu.Unlock() + + exec, ok := s.executions[execID] + if !ok { + return fmt.Errorf("execution not found: %s", execID) + } + + exec.Artifacts = append(exec.Artifacts, ArtifactEntry{ + Name: name, + Path: path, + }) + exec.UpdatedAt = s.now() + + return nil +} + +func (s *Store) GetArtifacts(execID string) ([]ArtifactEntry, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + exec, ok := s.executions[execID] + if !ok { + return nil, fmt.Errorf("execution not found: %s", execID) + } + + result := make([]ArtifactEntry, len(exec.Artifacts)) + copy(result, exec.Artifacts) + return result, nil +} + +func (s *Store) TransitionJob(id, newState string) error { + s.mu.Lock() + defer s.mu.Unlock() + + job, ok := s.jobs[id] + if !ok { + return fmt.Errorf("job not found: %s", id) + } + + allowed, ok := validTransitions[job.State] + if !ok { + return fmt.Errorf("unknown state: %s", job.State) + } + for _, target := range allowed { + if target == newState { + job.State = newState + job.UpdatedAt = s.now() + return nil + } + } + return fmt.Errorf("invalid transition from %s to %s", job.State, newState) +} + +func (s *Store) TransitionExecution(id, newState string) error { + s.mu.Lock() + defer s.mu.Unlock() + + exec, ok := s.executions[id] + if !ok { + return fmt.Errorf("execution not found: %s", id) + } + + allowed, ok := validTransitions[exec.State] + if !ok { + return fmt.Errorf("unknown state: %s", exec.State) + } + for _, target := range allowed { + if target == newState { + exec.State = newState + exec.UpdatedAt = s.now() + return nil + } + } + return fmt.Errorf("invalid transition from %s to %s", exec.State, newState) +} + +func (s *Store) SnapshotJobs() []Job { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]Job, 0, len(s.jobs)) + for _, j := range s.jobs { + result = append(result, *copyJob(j)) + } + return result +} + +func (s *Store) SnapshotExecutions() []Execution { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]Execution, 0, len(s.executions)) + for _, e := range s.executions { + result = append(result, *copyExecution(e)) + } + return result +} + +func copyJob(j *Job) *Job { + if j == nil { + return nil + } + cp := *j + return &cp +} + +func copyExecution(e *Execution) *Execution { + if e == nil { + return nil + } + cp := *e + cp.Logs = make([]LogEntry, len(e.Logs)) + copy(cp.Logs, e.Logs) + cp.Artifacts = make([]ArtifactEntry, len(e.Artifacts)) + copy(cp.Artifacts, e.Artifacts) + return &cp +} diff --git a/services/core/internal/cicdstate/store_test.go b/services/core/internal/cicdstate/store_test.go new file mode 100644 index 0000000..e2a2063 --- /dev/null +++ b/services/core/internal/cicdstate/store_test.go @@ -0,0 +1,513 @@ +package cicdstate + +import ( + "testing" + "time" +) + +func TestStoreCreatesJobAndExecution(t *testing.T) { + var mockTime time.Time + store := NewStore() + store.now = func() time.Time { return mockTime } + + // 1. Create a job + mockTime = time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC) + job, err := store.CreateJob("job-1", "build") + if err != nil { + t.Fatalf("CreateJob failed: %v", err) + } + if job.ID != "job-1" { + t.Fatalf("job ID = %q, want job-1", job.ID) + } + if job.Name != "build" { + t.Fatalf("job Name = %q, want build", job.Name) + } + if job.State != StateQueued { + t.Fatalf("job State = %q, want %q", job.State, StateQueued) + } + + // 2. Try duplicate job creation + _, err = store.CreateJob("job-1", "duplicate") + if err == nil { + t.Fatal("expected error for duplicate job") + } + + // 3. Verify job is stored and returns a copy + job2, err := store.GetJob("job-1") + if err != nil { + t.Fatalf("GetJob failed: %v", err) + } + if job2.ID != job.ID || job2.Name != job.Name || job2.State != job.State { + t.Fatalf("GetJob returned different data: got %+v", job2) + } + // Modifying returned job should not affect store + job2.Name = "modified" + jobOrig, err := store.GetJob("job-1") + if err != nil { + t.Fatalf("GetJob after modification failed: %v", err) + } + if jobOrig.Name == "modified" { + t.Fatal("GetJob did not return a copy; modifications leaked back") + } + + // 4. Create execution for job + exec, err := store.CreateExecution("job-1", "exec-1") + if err != nil { + t.Fatalf("CreateExecution failed: %v", err) + } + if exec.ID != "exec-1" { + t.Fatalf("execution ID = %q, want exec-1", exec.ID) + } + if exec.JobID != "job-1" { + t.Fatalf("execution JobID = %q, want job-1", exec.JobID) + } + if exec.State != StateQueued { + t.Fatalf("execution State = %q, want %q", exec.State, StateQueued) + } + if len(exec.Logs) != 0 || len(exec.Artifacts) != 0 { + t.Fatalf("execution Logs/Artifacts should be empty, got %d/%d", len(exec.Logs), len(exec.Artifacts)) + } + + // 5. Job should now have ExecutionID + jobAfterExec, err := store.GetJob("job-1") + if err != nil { + t.Fatalf("GetJob after execution creation failed: %v", err) + } + if jobAfterExec.ExecutionID != "exec-1" { + t.Fatalf("job ExecutionID = %q, want exec-1", jobAfterExec.ExecutionID) + } + + // 6. Try creating second execution for same job + _, err = store.CreateExecution("job-1", "exec-2") + if err == nil { + t.Fatal("expected error for second execution on same job") + } +} + +func TestStoreAppendsLogsAndArtifacts(t *testing.T) { + var mockTime time.Time + store := NewStore() + store.now = func() time.Time { return mockTime } + + mockTime = time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC) + store.CreateJob("job-1", "build") + + // Append log to non-existent execution + err := store.AppendLog("exec-unknown", "line 1") + if err == nil { + t.Fatal("expected error appending log to unknown execution") + } + + // Create execution and append log + _, err = store.CreateExecution("job-1", "exec-1") + if err != nil { + t.Fatalf("CreateExecution failed: %v", err) + } + + mockTime = mockTime.Add(1 * time.Second) + err = store.AppendLog("exec-1", "starting build") + if err != nil { + t.Fatalf("AppendLog failed: %v", err) + } + + mockTime = mockTime.Add(1 * time.Second) + err = store.AppendLog("exec-1", "compile complete") + if err != nil { + t.Fatalf("AppendLog failed: %v", err) + } + + logs, err := store.GetLogs("exec-1") + if err != nil { + t.Fatalf("GetLogs failed: %v", err) + } + if len(logs) != 2 { + t.Fatalf("len(logs) = %d, want 2", len(logs)) + } + if logs[0].Line != "starting build" { + t.Fatalf("logs[0].Line = %q, want starting build", logs[0].Line) + } + if logs[1].Line != "compile complete" { + t.Fatalf("logs[1].Line = %q, want compile complete", logs[1].Line) + } + + // GetLogs should return a copy + logs[1].Line = "modified" + logs2, err := store.GetLogs("exec-1") + if err != nil { + t.Fatalf("GetLogs after modification failed: %v", err) + } + if logs2[1].Line == "modified" { + t.Fatal("GetLogs did not return a copy") + } + + // Append artifacts + err = store.AppendArtifact("exec-1", "binary", "/dist/app") + if err != nil { + t.Fatalf("AppendArtifact failed: %v", err) + } + + err = store.AppendArtifact("exec-1", "report", "/dist/report.xml") + if err != nil { + t.Fatalf("AppendArtifact failed: %v", err) + } + + artifacts, err := store.GetArtifacts("exec-1") + if err != nil { + t.Fatalf("GetArtifacts failed: %v", err) + } + if len(artifacts) != 2 { + t.Fatalf("len(artifacts) = %d, want 2", len(artifacts)) + } + if artifacts[0].Name != "binary" || artifacts[0].Path != "/dist/app" { + t.Fatalf("artifacts[0] = %#v", artifacts[0]) + } + if artifacts[1].Name != "report" || artifacts[1].Path != "/dist/report.xml" { + t.Fatalf("artifacts[1] = %#v", artifacts[1]) + } + + // GetArtifacts should return a copy + artifacts[0].Path = "modified" + artifacts2, err := store.GetArtifacts("exec-1") + if err != nil { + t.Fatalf("GetArtifacts after modification failed: %v", err) + } + if artifacts2[0].Path == "modified" { + t.Fatal("GetArtifacts did not return a copy") + } +} + +func TestStoreRejectsUnknownExecution(t *testing.T) { + store := NewStore() + _, err := store.GetExecution("unknown-exec") + if err == nil { + t.Fatal("expected error for unknown execution") + } + + err = store.AppendLog("unknown-exec", "line") + if err == nil { + t.Fatal("expected error appending log to unknown execution") + } + + err = store.AppendArtifact("unknown-exec", "name", "path") + if err == nil { + t.Fatal("expected error appending artifact to unknown execution") + } + + _, err = store.GetLogs("unknown-exec") + if err == nil { + t.Fatal("expected error getting logs from unknown execution") + } + + _, err = store.GetArtifacts("unknown-exec") + if err == nil { + t.Fatal("expected error getting artifacts from unknown execution") + } +} + +func TestStoreJobExecutionNotFound(t *testing.T) { + store := NewStore() + + _, err := store.GetJob("nonexistent") + if err == nil { + t.Fatal("expected error for unknown job") + } + + exec, err := store.CreateExecution("nonexistent", "exec-1") + if err == nil { + t.Fatal("expected error creating execution for unknown job") + } + if exec != nil { + t.Fatal("CreateExecution for unknown job returned non-nil execution") + } +} + +func TestStoreStateTransitions(t *testing.T) { + store := NewStore() + store.CreateJob("job-1", "build") + + job, _ := store.GetJob("job-1") + if job.State != StateQueued { + t.Fatalf("initial job state = %q, want %q", job.State, StateQueued) + } + + // queued -> running + err := job.TransitionTo(StateRunning) + if err != nil { + t.Fatalf("queued->running transition failed: %v", err) + } + if job.State != StateRunning { + t.Fatalf("job state = %q, want %q", job.State, StateRunning) + } + + // running -> succeeded + err = job.TransitionTo(StateSucceeded) + if err != nil { + t.Fatalf("running->succeeded transition failed: %v", err) + } + if job.State != StateSucceeded { + t.Fatalf("job state = %q, want %q", job.State, StateSucceeded) + } + + // succeeded -> terminal, no further transitions allowed + err = job.TransitionTo(StateRunning) + if err == nil { + t.Fatal("expected error transitioning from terminal state succeeded") + } + + // Reset: queued -> running -> failed + store.CreateJob("job-2", "test") + job2, _ := store.GetJob("job-2") + err = job2.TransitionTo(StateRunning) + if err != nil { + t.Fatalf("transition failed: %v", err) + } + err = job2.TransitionTo(StateFailed) + if err != nil { + t.Fatalf("transition failed: %v", err) + } + if job2.State != StateFailed { + t.Fatalf("job state = %q, want %q", job2.State, StateFailed) + } + + // queued -> canceled + store.CreateJob("job-3", "lint") + job3, _ := store.GetJob("job-3") + err = job3.TransitionTo(StateCanceled) + if err != nil { + t.Fatalf("queued->canceled transition failed: %v", err) + } + if job3.State != StateCanceled { + t.Fatalf("job state = %q, want %q", job3.State, StateCanceled) + } + + // queued -> failed (direct from queued) + store.CreateJob("job-4", "deploy") + job4, _ := store.GetJob("job-4") + err = job4.TransitionTo(StateFailed) + if err != nil { + t.Fatalf("queued->failed transition failed: %v", err) + } + if job4.State != StateFailed { + t.Fatalf("job state = %q, want %q", job4.State, StateFailed) + } +} + +func TestStoreExecutionStateTransitions(t *testing.T) { + store := NewStore() + store.CreateJob("job-1", "build") + store.CreateExecution("job-1", "exec-1") + + exec, _ := store.GetExecution("exec-1") + if exec.State != StateQueued { + t.Fatalf("initial exec state = %q, want %q", exec.State, StateQueued) + } + + err := exec.TransitionTo(StateRunning) + if err != nil { + t.Fatalf("queued->running failed: %v", err) + } + if exec.State != StateRunning { + t.Fatalf("exec state = %q, want %q", exec.State, StateRunning) + } + + exec.TransitionTo(StateSucceeded) + if exec.State != StateSucceeded { + t.Fatalf("exec state = %q, want %q", exec.State, StateSucceeded) + } + + // Terminal state: no further transitions + err = exec.TransitionTo(StateRunning) + if err == nil { + t.Fatal("expected error from terminal exec state") + } +} + +func TestStorePersistedJobTransition(t *testing.T) { + store := NewStore() + store.now = func() time.Time { return time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC) } + + store.CreateJob("job-1", "build") + + // queued -> running -> succeeded + err := store.TransitionJob("job-1", StateRunning) + if err != nil { + t.Fatalf("TransitionJob queued->running failed: %v", err) + } + + job, err := store.GetJob("job-1") + if err != nil { + t.Fatalf("GetJob after transition failed: %v", err) + } + if job.State != StateRunning { + t.Fatalf("job state = %q, want %q", job.State, StateRunning) + } + + jobFromSnapshot := store.SnapshotJobs() + if len(jobFromSnapshot) != 1 || jobFromSnapshot[0].State != StateRunning { + t.Fatalf("SnapshotJobs state = %q, want %q", jobFromSnapshot[0].State, StateRunning) + } + + err = store.TransitionJob("job-1", StateSucceeded) + if err != nil { + t.Fatalf("TransitionJob running->succeeded failed: %v", err) + } + + job, err = store.GetJob("job-1") + if err != nil { + t.Fatalf("GetJob after succeeded failed: %v", err) + } + if job.State != StateSucceeded { + t.Fatalf("job state = %q, want %q", job.State, StateSucceeded) + } + + // Terminal state: no further transitions + err = store.TransitionJob("job-1", StateRunning) + if err == nil { + t.Fatal("expected error transitioning from terminal state succeeded") + } + + job, err = store.GetJob("job-1") + if err != nil { + t.Fatalf("GetJob after failed terminal transition: %v", err) + } + if job.State != StateSucceeded { + t.Fatalf("state should still be %q after failed transition, got %q", StateSucceeded, job.State) + } + + // Unknown job ID + err = store.TransitionJob("unknown-job", StateRunning) + if err == nil { + t.Fatal("expected error for unknown job transition") + } +} + +func TestStorePersistedExecutionTransition(t *testing.T) { + store := NewStore() + store.now = func() time.Time { return time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC) } + + store.CreateJob("job-1", "build") + store.CreateExecution("job-1", "exec-1") + + // queued -> running -> failed + err := store.TransitionExecution("exec-1", StateRunning) + if err != nil { + t.Fatalf("TransitionExecution queued->running failed: %v", err) + } + + exec, err := store.GetExecution("exec-1") + if err != nil { + t.Fatalf("GetExecution after transition failed: %v", err) + } + if exec.State != StateRunning { + t.Fatalf("exec state = %q, want %q", exec.State, StateRunning) + } + + execsFromSnapshot := store.SnapshotExecutions() + if len(execsFromSnapshot) != 1 || execsFromSnapshot[0].State != StateRunning { + t.Fatalf("SnapshotExecutions state = %q, want %q", execsFromSnapshot[0].State, StateRunning) + } + + err = store.TransitionExecution("exec-1", StateFailed) + if err != nil { + t.Fatalf("TransitionExecution running->failed failed: %v", err) + } + + exec, err = store.GetExecution("exec-1") + if err != nil { + t.Fatalf("GetExecution after failed: %v", err) + } + if exec.State != StateFailed { + t.Fatalf("exec state = %q, want %q", exec.State, StateFailed) + } + + // Terminal state: no further transitions + err = store.TransitionExecution("exec-1", StateRunning) + if err == nil { + t.Fatal("expected error from terminal exec state") + } + + exec, err = store.GetExecution("exec-1") + if err != nil { + t.Fatalf("GetExecution after failed terminal transition: %v", err) + } + if exec.State != StateFailed { + t.Fatalf("state should still be %q after failed transition, got %q", StateFailed, exec.State) + } + + // Unknown execution ID + err = store.TransitionExecution("unknown-exec", StateRunning) + if err == nil { + t.Fatal("expected error for unknown execution transition") + } + + // queued -> running -> succeeded + store.CreateJob("job-2", "deploy") + store.CreateExecution("job-2", "exec-2") + err = store.TransitionExecution("exec-2", StateRunning) + if err != nil { + t.Fatalf("TransitionExecution queued->running failed: %v", err) + } + err = store.TransitionExecution("exec-2", StateSucceeded) + if err != nil { + t.Fatalf("TransitionExecution running->succeeded failed: %v", err) + } + exec, err = store.GetExecution("exec-2") + if err != nil { + t.Fatalf("GetExecution after succeeded: %v", err) + } + if exec.State != StateSucceeded { + t.Fatalf("exec state = %q, want %q", exec.State, StateSucceeded) + } +} + +func TestStoreSnapshotReturnsCopies(t *testing.T) { + store := NewStore() + + job1 := &Job{ + ID: "job-1", + Name: "build", + State: StateQueued, + } + job2 := &Job{ + ID: "job-2", + Name: "test", + State: StateRunning, + } + + // Directly populate store maps for snapshot testing + store.jobs = map[string]*Job{ + "job-1": job1, + "job-2": job2, + } + store.executions = map[string]*Execution{ + "exec-1": { + ID: "exec-1", + JobID: "job-1", + State: StateRunning, + Logs: []LogEntry{{Line: "hello"}}, + Artifacts: []ArtifactEntry{{Name: "a.out"}}, + }, + } + + jobs := store.SnapshotJobs() + if len(jobs) != 2 { + t.Fatalf("SnapshotJobs returned %d jobs, want 2", len(jobs)) + } + + jobs[0].State = StateFailed + jobsRaw := store.SnapshotJobs() + if jobsRaw[0].State == StateFailed { + t.Fatal("SnapshotJobs returned mutable reference") + } + + execs := store.SnapshotExecutions() + if len(execs) != 1 { + t.Fatalf("SnapshotExecutions returned %d executions, want 1", len(execs)) + } + + execs[0].Logs[0].Line = "modified" + execsRaw := store.SnapshotExecutions() + if execsRaw[0].Logs[0].Line == "modified" { + t.Fatal("SnapshotExecutions returned mutable reference") + } +} diff --git a/services/core/internal/httpserver/server.go b/services/core/internal/httpserver/server.go index a90c7e4..671b488 100644 --- a/services/core/internal/httpserver/server.go +++ b/services/core/internal/httpserver/server.go @@ -3,6 +3,7 @@ package httpserver import ( "context" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -11,6 +12,7 @@ import ( "strings" "time" + "github.com/toki/oto/services/core/internal/cicdstate" "github.com/toki/oto/services/core/internal/runnerregistry" otopb "github.com/toki/oto/services/core/oto" ) @@ -22,11 +24,16 @@ type Server struct { // NewServer creates a new instance of Server. func NewServer(addr string) *Server { - return NewServerWithRegistry(addr, runnerregistry.New()) + return NewServerWithRegistryAndStore(addr, runnerregistry.New(), cicdstate.NewStore()) } // NewServerWithRegistry creates a server using an injected runner registry. func NewServerWithRegistry(addr string, registry *runnerregistry.Registry) *Server { + return NewServerWithRegistryAndStore(addr, registry, cicdstate.NewStore()) +} + +// NewServerWithRegistryAndStore creates a server with injected runner registry and CICD store. +func NewServerWithRegistryAndStore(addr string, registry *runnerregistry.Registry, store *cicdstate.Store) *Server { mux := http.NewServeMux() mux.HandleFunc("/healthz", handleHealthz) @@ -37,6 +44,7 @@ func NewServerWithRegistry(addr string, registry *runnerregistry.Registry) *Serv mux.HandleFunc("/api/v1/runners/{id}/disconnect", handleRunnerDisconnect(registry)) mux.HandleFunc("/api/v1/runners/{id}", handleGetRunner(registry)) mux.HandleFunc("/bootstrap/oto-agent.sh", handleServeBootstrapScript()) + mux.HandleFunc("/api/v1/", handleRouter(store, nil)) return &Server{ httpServer: &http.Server{ @@ -387,3 +395,347 @@ func handleServeBootstrapScript() http.HandlerFunc { _, _ = w.Write(content) } } + +// Extract path segments after "/api/v1/" +func apiPathSegments(path string) []string { + if !strings.HasPrefix(path, "/api/v1/") { + return nil + } + trimmed := strings.TrimPrefix(path, "/api/v1/") + trimmed = strings.TrimRight(trimmed, "/") + if trimmed == "" { + return nil + } + return strings.Split(trimmed, "/") +} + +func handleRouter(store *cicdstate.Store, registry *runnerregistry.Registry) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + parts := apiPathSegments(r.URL.Path) + if parts == nil { + http.NotFound(w, r) + return + } + + switch parts[0] { + case "jobs": + switch { + case len(parts) == 1 && r.Method == http.MethodPost: + handleCreateJob(store)(w, r) + case len(parts) == 1 && r.Method == http.MethodGet: + writeResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + case len(parts) == 2 && r.Method == http.MethodGet: + handleGetJob(store)(w, r) + case len(parts) == 3 && parts[2] == "executions" && r.Method == http.MethodPost: + handleCreateExecution(store)(w, r) + default: + http.NotFound(w, r) + } + + case "executions": + if len(parts) < 2 { + http.NotFound(w, r) + return + } + switch { + case len(parts) == 2 && r.Method == http.MethodGet: + handleGetExecution(store)(w, r) + case len(parts) == 3 && parts[2] == "logs" && r.Method == http.MethodPost: + handleAppendLog(store)(w, r) + case len(parts) == 3 && parts[2] == "logs" && r.Method == http.MethodGet: + handleGetLogs(store)(w, r) + case len(parts) == 3 && parts[2] == "artifacts" && r.Method == http.MethodPost: + handleAppendArtifact(store)(w, r) + case len(parts) == 3 && parts[2] == "artifacts" && r.Method == http.MethodGet: + handleGetArtifacts(store)(w, r) + default: + http.NotFound(w, r) + } + + default: + http.NotFound(w, r) + } + } +} + +func handleCreateJob(store *cicdstate.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if req.ID == "" || req.Name == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "id and name are required"}) + return + } + job, err := store.CreateJob(req.ID, req.Name) + if err != nil { + writeResponse(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + writeResponse(w, http.StatusCreated, jobToJSON(job)) + } +} + +func handleGetJob(store *cicdstate.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + jobID := r.PathValue("id") + if jobID == "" { + parts := strings.Split(r.URL.Path, "/") + if len(parts) >= 5 { + jobID = parts[4] + } + } + if jobID == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing job id"}) + return + } + job, err := store.GetJob(jobID) + if err != nil { + if errors.Is(err, fmt.Errorf("job not found: %s", jobID)) || err.Error() == "job not found: "+jobID { + writeResponse(w, http.StatusNotFound, map[string]string{"error": "job not found"}) + return + } + writeResponse(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + writeResponse(w, http.StatusOK, jobToJSON(job)) + } +} + +func handleCreateExecution(store *cicdstate.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + jobID := r.PathValue("id") + if jobID == "" { + parts := strings.Split(r.URL.Path, "/") + if len(parts) >= 5 { + jobID = parts[4] + } + } + if jobID == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing job id"}) + return + } + var req struct { + ID string `json:"id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if req.ID == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "execution id is required"}) + return + } + exec, err := store.CreateExecution(jobID, req.ID) + if err != nil { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeResponse(w, http.StatusCreated, execToJSON(exec)) + } +} + +func handleGetExecution(store *cicdstate.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + execID := r.PathValue("id") + if execID == "" { + parts := strings.Split(r.URL.Path, "/") + if len(parts) >= 5 { + execID = parts[4] + } + } + if execID == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"}) + return + } + exec, err := store.GetExecution(execID) + if err != nil { + writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"}) + return + } + writeResponse(w, http.StatusOK, execToJSON(exec)) + } +} + +func handleAppendLog(store *cicdstate.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + execID := r.PathValue("id") + if execID == "" { + parts := strings.Split(r.URL.Path, "/") + if len(parts) >= 5 { + execID = parts[4] + } + } + if execID == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"}) + return + } + var req struct { + Line string `json:"line"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Line == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "line is required"}) + return + } + if err := store.AppendLog(execID, req.Line); err != nil { + writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"}) + return + } + writeResponse(w, http.StatusCreated, map[string]string{"status": "accepted"}) + } +} + +func handleGetLogs(store *cicdstate.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + execID := r.PathValue("id") + if execID == "" { + parts := strings.Split(r.URL.Path, "/") + if len(parts) >= 5 { + execID = parts[4] + } + } + if execID == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"}) + return + } + logs, err := store.GetLogs(execID) + if err != nil { + writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"}) + return + } + writeResponse(w, http.StatusOK, logsToJSON(logs)) + } +} + +func handleAppendArtifact(store *cicdstate.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + execID := r.PathValue("id") + if execID == "" { + parts := strings.Split(r.URL.Path, "/") + if len(parts) >= 5 { + execID = parts[4] + } + } + if execID == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"}) + return + } + var req struct { + Name string `json:"name"` + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" || req.Path == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "name and path are required"}) + return + } + if err := store.AppendArtifact(execID, req.Name, req.Path); err != nil { + writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"}) + return + } + writeResponse(w, http.StatusCreated, map[string]string{"status": "accepted"}) + } +} + +func handleGetArtifacts(store *cicdstate.Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + execID := r.PathValue("id") + if execID == "" { + parts := strings.Split(r.URL.Path, "/") + if len(parts) >= 5 { + execID = parts[4] + } + } + if execID == "" { + writeResponse(w, http.StatusBadRequest, map[string]string{"error": "missing execution id"}) + return + } + artifacts, err := store.GetArtifacts(execID) + if err != nil { + writeResponse(w, http.StatusNotFound, map[string]string{"error": "execution not found"}) + return + } + writeResponse(w, http.StatusOK, artifactsToJSON(artifacts)) + } +} + +// --- JSON helpers --- + +type jobResponse struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ExecutionID string `json:"execution_id,omitempty"` +} + +func jobToJSON(j *cicdstate.Job) map[string]interface{} { + return map[string]interface{}{ + "id": j.ID, + "name": j.Name, + "state": j.State, + "created_at": j.CreatedAt.Format(time.RFC3339), + "updated_at": j.UpdatedAt.Format(time.RFC3339), + "execution_id": j.ExecutionID, + } +} + +type execResponse struct { + ID string `json:"id"` + JobID string `json:"job_id"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Logs []cicdstate.LogEntry `json:"logs,omitempty"` + Artifacts []cicdstate.ArtifactEntry `json:"artifacts,omitempty"` +} + +func execToJSON(e *cicdstate.Execution) map[string]interface{} { + return map[string]interface{}{ + "id": e.ID, + "job_id": e.JobID, + "state": e.State, + "created_at": e.CreatedAt.Format(time.RFC3339), + "updated_at": e.UpdatedAt.Format(time.RFC3339), + "execution_id": e.ID, + } +} + +type logEntryResponse struct { + Timestamp string `json:"timestamp"` + Line string `json:"line"` +} + +func logsToJSON(logs []cicdstate.LogEntry) map[string]interface{} { + return map[string]interface{}{ + "logs": logs, + } +} + +type artifactResponse struct { + Name string `json:"name"` + Path string `json:"path"` +} + +func artifactsToJSON(artifacts []cicdstate.ArtifactEntry) map[string]interface{} { + return map[string]interface{}{ + "artifacts": artifacts, + } +} + +func writeResponse(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} diff --git a/services/core/internal/httpserver/server_test.go b/services/core/internal/httpserver/server_test.go index 33c4108..d139a97 100644 --- a/services/core/internal/httpserver/server_test.go +++ b/services/core/internal/httpserver/server_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "testing" + "github.com/toki/oto/services/core/internal/cicdstate" "github.com/toki/oto/services/core/internal/runnerregistry" otopb "github.com/toki/oto/services/core/oto" ) @@ -461,3 +462,264 @@ func TestHandleServeBootstrapScript(t *testing.T) { t.Fatal("empty bootstrap script returned") } } + +func TestHandleCreateJob(t *testing.T) { + store := cicdstate.NewStore() + + // Valid job creation should succeed + body := bytes.NewBufferString(`{"id":"job-1","name":"build"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body) + rr := httptest.NewRecorder() + + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String()) + } + + var res map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil { + t.Fatalf("decode response: %v", err) + } + if res["id"] != "job-1" { + t.Fatalf("id = %v, want job-1", res["id"]) + } + if res["name"] != "build" { + t.Fatalf("name = %v, want build", res["name"]) + } + if res["state"] != cicdstate.StateQueued { + t.Fatalf("state = %v, want %s", res["state"], cicdstate.StateQueued) + } + + // Get job by ID + req = httptest.NewRequest(http.MethodGet, "/api/v1/jobs/job-1", nil) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil { + t.Fatalf("decode response: %v", err) + } + if res["id"] != "job-1" { + t.Fatalf("GET job id = %v, want job-1", res["id"]) + } + + // Unknown job ID should return 404 + req = httptest.NewRequest(http.MethodGet, "/api/v1/jobs/nonexistent", nil) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("GET unknown job status = %v, want %v", rr.Code, http.StatusNotFound) + } + + // Duplicate job should return 409 + body = bytes.NewBufferString(`{"id":"job-1","name":"duplicate"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusConflict { + t.Fatalf("duplicate job status = %v, want %v", rr.Code, http.StatusConflict) + } + + // Missing fields should return 400 + body = bytes.NewBufferString(`{"id":"job-2"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("missing fields status = %v, want %v", rr.Code, http.StatusBadRequest) + } +} + +func TestHandleCreateExecution(t *testing.T) { + store := cicdstate.NewStore() + store.CreateJob("job-1", "build") + + body := bytes.NewBufferString(`{"id":"exec-1"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-1/executions", body) + rr := httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String()) + } + + var execRes map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &execRes); err != nil { + t.Fatalf("decode response: %v", err) + } + if execRes["id"] != "exec-1" { + t.Fatalf("execution id = %v, want exec-1", execRes["id"]) + } + + // Get execution by ID + req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/exec-1", nil) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET execution status = %v, want %v", rr.Code, http.StatusOK) + } + if err := json.Unmarshal(rr.Body.Bytes(), &execRes); err != nil { + t.Fatalf("decode response: %v", err) + } + if execRes["id"] != "exec-1" { + t.Fatalf("GET execution id = %v, want exec-1", execRes["id"]) + } + + // Unknown execution should return 404 + req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/nonexistent", nil) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("GET unknown execution status = %v, want %v", rr.Code, http.StatusNotFound) + } + + // Execution for unknown job should return 400 + body = bytes.NewBufferString(`{"id":"exec-99"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs/unknown/executions", body) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("unknown job execution status = %v, want %v", rr.Code, http.StatusBadRequest) + } +} + +func TestHandleExecutionLogsAndArtifacts(t *testing.T) { + store := cicdstate.NewStore() + store.CreateJob("job-1", "build") + store.CreateExecution("job-1", "exec-1") + + // Append log + logBody := bytes.NewBufferString(`{"line":"build starting"}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/executions/exec-1/logs", logBody) + rr := httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("append log status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String()) + } + + // Append another log + logBody = bytes.NewBufferString(`{"line":"build done"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/exec-1/logs", logBody) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("append log 2 status = %v, want %v", rr.Code, http.StatusCreated) + } + + // Get logs + req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/exec-1/logs", nil) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("get logs status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + var logRes map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &logRes); err != nil { + t.Fatalf("decode log response: %v", err) + } + logsArr, ok := logRes["logs"].([]interface{}) + if !ok { + t.Fatalf("logs key not an array") + } + if len(logsArr) != 2 { + t.Fatalf("log count = %d, want 2", len(logsArr)) + } + + // Append artifact + artifactBody := bytes.NewBufferString(`{"name":"binary","path":"/dist/app"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/exec-1/artifacts", artifactBody) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("append artifact status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String()) + } + + // Get artifacts + req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/exec-1/artifacts", nil) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("get artifacts status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + var artRes map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &artRes); err != nil { + t.Fatalf("decode artifact response: %v", err) + } + artifactsArr, ok := artRes["artifacts"].([]interface{}) + if !ok { + t.Fatalf("artifacts key not an array") + } + if len(artifactsArr) != 1 { + t.Fatalf("artifact count = %d, want 1", len(artifactsArr)) + } + + // Unknown execution logs/artifacts should return 404 + logBody2 := bytes.NewBufferString(`{"line":"second log line"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/unknown/logs", logBody2) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("unknown log append status = %v, want %v", rr.Code, http.StatusNotFound) + } + + artifactBody2 := bytes.NewBufferString(`{"name":"binary","path":"/dist/app"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/unknown/artifacts", artifactBody2) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("unknown artifact append status = %v, want %v", rr.Code, http.StatusNotFound) + } +} + +func TestHandleCicdUnknownIds(t *testing.T) { + store := cicdstate.NewStore() + + // Get unknown job -> 404 + req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/nonexistent", nil) + rr := httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("GET unknown job = %v, want %v", rr.Code, http.StatusNotFound) + } + + // Create execution for unknown job -> 400 + body := bytes.NewBufferString(`{"id":"exec-1"}`) + req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs/nonexistent/executions", body) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("exec unknown job = %v, want %v", rr.Code, http.StatusBadRequest) + } + + // Get unknown execution -> 404 + req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/nonexistent", nil) + rr = httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("GET unknown exec = %v, want %v", rr.Code, http.StatusNotFound) + } +} + +func TestHandleCicdMethodNotAllowed(t *testing.T) { + store := cicdstate.NewStore() + + // GET on /api/v1/jobs (create endpoint) should return method not allowed + req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil) + rr := httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET /api/v1/jobs = %v, want %v", rr.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleCicdNotFound(t *testing.T) { + store := cicdstate.NewStore() + + // Unknown path should return 404 + req := httptest.NewRequest(http.MethodGet, "/api/v1/unknown", nil) + rr := httptest.NewRecorder() + handleRouter(store, nil)(rr, req) + if rr.Code != http.StatusNotFound { + t.Fatalf("unknown path = %v, want %v", rr.Code, http.StatusNotFound) + } +}