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:
parent
4f26df99c3
commit
5fd116e447
17 changed files with 2647 additions and 61 deletions
|
|
@ -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과 관련된 외부 잠금은 없다.
|
||||
|
|
|
|||
|
|
@ -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 책임 경계를 소비하는 후속 구현 범위다.
|
||||
|
|
|
|||
|
|
@ -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` 완료 근거를 확인한다.
|
||||
|
|
|
|||
|
|
@ -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` 완료 근거를 확인한다.
|
||||
|
|
|
|||
|
|
@ -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는 트리거하지 않는다.
|
||||
|
|
@ -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로 이동한다.
|
||||
|
|
@ -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로 전환할 수 있다.
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
323
services/core/internal/cicdstate/store.go
Normal file
323
services/core/internal/cicdstate/store.go
Normal 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
|
||||
}
|
||||
513
services/core/internal/cicdstate/store_test.go
Normal file
513
services/core/internal/cicdstate/store_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue