feat(control-plane): CI/CD 상태 API를 추가한다

OTO Core가 job, execution, log, artifact 상태를 서버 내부에서 소유하고 조회할 수 있어야 runner dispatch와 통합 smoke 단계로 이어질 수 있다. in-memory 상태 저장소와 HTTP API, 검증 테스트, 관련 agent-task 산출물을 함께 반영한다.
This commit is contained in:
toki 2026-06-06 06:22:47 +09:00
parent 4f26df99c3
commit 5fd116e447
17 changed files with 2647 additions and 61 deletions

View file

@ -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과 관련된 외부 잠금은 없다.

View file

@ -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 책임 경계를 소비하는 후속 구현 범위다.

View file

@ -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` 완료 근거를 확인한다.

View file

@ -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` 완료 근거를 확인한다.

View file

@ -0,0 +1,137 @@
<!-- task=m-control-plane-separation-migration/05+04_core_cicd_state plan=0 tag=CICD_STATE -->
# 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는 트리거하지 않는다.

View file

@ -0,0 +1,162 @@
<!-- task=m-control-plane-separation-migration/05+04_core_cicd_state plan=1 tag=REVIEW_CICD_STATE -->
# 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다.

View file

@ -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로 전환할 수 있다.
## 후속 작업
- 없음

View file

@ -0,0 +1,180 @@
<!-- task=m-control-plane-separation-migration/05+04_core_cicd_state plan=0 tag=CICD_STATE -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,109 @@
<!-- task=m-control-plane-separation-migration/05+04_core_cicd_state plan=1 tag=REVIEW_CICD_STATE -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,103 @@
<!-- task=m-control-plane-separation-migration/06+05_runner_dispatch_contract plan=0 tag=CICD_RUNNER -->
# 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)
```
## 코드리뷰 결과

View file

@ -0,0 +1,186 @@
<!-- task=m-control-plane-separation-migration/06+05_runner_dispatch_contract plan=0 tag=CICD_RUNNER -->
# 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<EdgeAgentSession> openSession(AgentConfig config) async {
...
341 Future<void> _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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,101 @@
<!-- task=m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence plan=0 tag=CICD_SMOKE -->
# 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)
```
## 코드리뷰 결과

View file

@ -0,0 +1,138 @@
<!-- task=m-control-plane-separation-migration/07+05,06_cicd_smoke_evidence plan=0 tag=CICD_SMOKE -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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)
}

View file

@ -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)
}
}