feat: milestone work-item creation sync 및 roadmap sync pipeline 구현
- Plane 프로젝트의 work-item 생성 시 Orchestrator로 동기화 - scheduler에 roadmap sync job 등록 및 river client 지원 - roadmapsycpipeline adapter 패키지 추가 - 관련 테스트 파일 및 브리지 문서 추가
This commit is contained in:
parent
91e3aabd16
commit
32fda2a844
14 changed files with 1728 additions and 41 deletions
|
|
@ -20,7 +20,11 @@
|
|||
|
||||
- [진행중] Milestone Work Item Creation Sync
|
||||
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
|
||||
- 요약: Plane-origin 방식으로 Milestone을 생성하고 Plane Todo 티켓까지 동기화한 뒤 멈추는 진행중 마일스톤이다. Workspace agent 실행은 IOP CLI 1차 통로를 선행으로 두며, Agent chat/IDE-origin 생성 projection은 이번 범위에서 제외한다.
|
||||
- 요약: Plane-origin 방식으로 Milestone을 생성하고 Plane Todo 티켓까지 동기화한 뒤 멈추는 마일스톤이다. 코드레벨 검토에서 develop scan 결과를 identity/step ledger와 Plane `Todo` projection에 연결하는 runtime sync orchestrator 누락이 확인되어 `creation-sync-orchestrator` 보완 계획을 진행한다.
|
||||
|
||||
- [계획] Gito Branch Event Creation Sync Bridge
|
||||
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md`
|
||||
- 요약: Gito가 proto-socket `branch.updated` wakeup을 제공하면 NomadCode core가 이를 구독하고 target branch를 재검증/스캔한 뒤 creation sync job으로 enqueue하는 bridge를 구현한다.
|
||||
|
||||
- [계획] Roadmap Driven Agent-Ops Automation
|
||||
- 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/roadmap-driven-agent-ops-automation.md`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
# Milestone: Gito Branch Event Creation Sync Bridge
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-roadmap/ROADMAP.md`
|
||||
- Phase: `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
Gito가 제공하는 proto-socket `branch.updated` wakeup을 NomadCode core가 소비하고, `develop` target branch를 재검증/스캔한 뒤 Plane-origin creation sync job으로 enqueue한다. 이 Milestone이 끝나면 Forgejo/Gito branch event가 단순 UI wakeup에 머물지 않고 NomadCode backend sync loop의 결정적 입력으로 연결된다.
|
||||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
- 상태: 해제
|
||||
- 결정 필요: 없음
|
||||
|
||||
## 범위
|
||||
|
||||
- Gito 제공 계약 `gito.forgejo-branch-events.v1`의 `event.subscribe` / `branch.updated` 소비
|
||||
- NomadCode core runtime에서 Gito `/proto-socket` endpoint에 연결하는 client/runner
|
||||
- `repo_id=nomadcode`, `branch=develop` 구독과 reconnect/error handling의 최소 기준
|
||||
- `branch.updated` payload의 `changed_files`를 wakeup signal로만 사용하고 target branch revision을 별도 검증/스캔하는 흐름
|
||||
- `agent-roadmap/phase/*/milestones/*.md` 변경과 provider identity를 확인해 `roadmapsync.ScanResult`와 `RoadmapCreationSyncJobArgs`를 구성하는 흐름
|
||||
- 기존 `scheduler.EnqueueRoadmapCreationSync` seam으로 creation sync worker를 깨우는 흐름
|
||||
- 중복 event, 동일 revision, not-ready scan의 idempotency 기준
|
||||
|
||||
## 기능
|
||||
|
||||
### Epic: [gito-wakeup] Gito branch event wakeup bridge
|
||||
|
||||
Gito가 provider webhook과 branch watch를 정규화해 `branch.updated`를 발행하면, NomadCode core가 이를 backend sync wakeup으로 받아 Plane creation sync orchestrator에 전달한다.
|
||||
|
||||
- [ ] [contract-consume] Gito 제공 계약 `gito.forgejo-branch-events.v1`을 NomadCode core 소비 계약으로 반영한다. 검증: `event.subscribe` 요청과 `branch.updated` payload 필드가 계약 fixture 또는 테스트에 고정된다.
|
||||
- [ ] [core-client] NomadCode core에 Gito proto-socket event client/runner를 추가한다. 검증: fake proto-socket server 또는 transport로 `event.subscribe`를 보내고 `branch.updated`를 수신한다.
|
||||
- [ ] [branch-filter] `repo_id`, `branch`, `changed_files` 기준으로 roadmap Milestone 후보 event만 선별한다. 검증: 다른 repo/branch/path event는 creation sync enqueue를 만들지 않는다.
|
||||
- [ ] [revision-scan] 이벤트 payload를 최종 증거로 단정하지 않고 target branch revision을 fetch/scan해 `roadmapsync.ScanResult`를 만든다. 검증: scanned Milestone identity가 원래 Plane work item identity와 맞을 때만 ready 후보가 된다.
|
||||
- [ ] [creation-enqueue] scan 결과와 provider identity를 `RoadmapCreationSyncJobArgs`로 변환해 `scheduler.EnqueueRoadmapCreationSync`를 호출한다. 검증: fake scheduler가 정확한 job args를 받으며 duplicate/retry 입력이 중복 side effect를 만들지 않는다.
|
||||
- [ ] [runtime-wiring] server/scheduler lifecycle에 Gito event consumer를 선택적으로 wiring한다. 검증: Gito endpoint 미설정 시 기존 core 동작은 유지되고, 설정 시 runner가 시작/종료된다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 없음
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- Gito의 Forgejo webhook 수신/branch watch 제공 구현
|
||||
- Gito provider delivery dedupe, branch watch persistence 구현
|
||||
- Plane `Todo -> In Progress` 이후 실행 lifecycle
|
||||
- Milestone Task와 Plane 하위 티켓 동기화
|
||||
- Flutter UI에서 branch event를 표시하거나 수동 재시도 버튼을 제공하는 작업
|
||||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 관련 경로: `services/core/internal/protosocket/`, `services/core/internal/roadmapsync/`, `services/core/internal/roadmapsyncpipeline/`, `services/core/internal/scheduler/`, `services/core/cmd/server/main.go`, `packages/contracts/notes/flutter-core-api-candidates.md`
|
||||
- 외부 계약: Gito `agent-contract/provided/gito-forgejo-branch-events-v1.md`
|
||||
- 표준선(선택): Gito는 branch event producer이고, NomadCode core는 이 event를 wakeup signal로 소비한 뒤 target branch를 재검증/스캔해 Plane mutation 여부를 결정한다.
|
||||
- 선행 작업: `Milestone Work Item Creation Sync`의 `creation-sync-orchestrator`
|
||||
- 후속 작업: `Milestone Execution Lifecycle Sync`
|
||||
- 확인 필요: 없음
|
||||
|
|
@ -93,28 +93,34 @@ Plane-origin sync가 workspace agent를 실행할 수 있도록 IOP Edge OpenAI-
|
|||
|
||||
Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토 상태까지 수렴시킨다.
|
||||
|
||||
- [ ] [plane-trigger] Plane `Backlog + AGENT assignee` 상위 티켓을 Milestone 생성 trigger로 처리한다. 검증: NomadCode가 자신이 만든 후속 변경을 다시 사용자 trigger로 오인하지 않는 actor guard가 설명되어 있다. 계획: `agent-task/m-milestone-work-item-creation-sync/09_creation_trigger_gate/`
|
||||
- [x] [plane-trigger] Plane `Backlog + AGENT assignee` 상위 티켓을 Milestone 생성 trigger로 처리한다. 검증: NomadCode가 자신이 만든 후속 변경을 다시 사용자 trigger로 오인하지 않는 actor guard가 설명되어 있다. 계획: `agent-task/m-milestone-work-item-creation-sync/09_creation_trigger_gate/`
|
||||
- [x] [plane-agent-authoring] Plane 티켓 제목/본문을 workspace agent authoring run의 입력으로 전달하는 경계를 정의한다. 검증: 정상 happy path에서 필수 agent/model 개입은 1회이며, 결과물은 별도 변환 응답이 아니라 `slots/<index>` workspace 안의 `agent-roadmap` Milestone 파일 작성/갱신과 `develop` commit/push다. 이 단계는 IOP Edge OpenAI-compatible HTTP 통로가 준비된 뒤에만 실행된다.
|
||||
- [ ] [plane-draft] Plane 티켓 본문을 읽어 workspace agent가 roadmap skill로 Milestone 초안을 생성하거나 갱신하고 `develop`에 반영한다. 검증: 1상위 티켓은 1마일스톤으로 매핑되고 같은 Plane 티켓 재처리 시 중복 Milestone을 만들지 않는다. 이번 범위는 Plane-origin에 한정하되, Milestone 파일 작성과 push는 IDE-origin과 같은 workspace agent 경로를 쓴다. 계획: `agent-task/m-milestone-work-item-creation-sync/10+09_plane_draft_authoring/`
|
||||
- [x] [plane-draft] Plane 티켓 본문을 읽어 workspace agent가 roadmap skill로 Milestone 초안을 생성하거나 갱신하고 `develop`에 반영한다. 검증: 1상위 티켓은 1마일스톤으로 매핑되고 같은 Plane 티켓 재처리 시 중복 Milestone을 만들지 않는다. 이번 범위는 Plane-origin에 한정하되, Milestone 파일 작성과 push는 IDE-origin과 같은 workspace agent 경로를 쓴다. 계획: `agent-task/m-milestone-work-item-creation-sync/10+09_plane_draft_authoring/`
|
||||
- [x] [develop-truth] `develop` branch의 agent-roadmap을 sync source of truth로 정의한다. 검증: feature branch나 로컬 초안은 Plane `Todo` projection 대상이 아니며, Plane `Todo` 티켓은 `develop`에 Milestone 변경이 commit/push된 뒤에만 생성/갱신된다.
|
||||
- [x] [pushed-milestone-match] Sync layer가 pushed commit과 Plane 티켓을 매칭하는 규칙을 정의한다. 검증: pushed commit 또는 roadmap scan에서 실제 `agent-roadmap/phase/*/milestones/*.md` 변경이 있고, 해당 Milestone에 provider와 Plane work item id가 확인될 때만 Plane 갱신 단계로 넘어간다.
|
||||
- [x] [plane-preserve] 원본 Plane 본문을 `사용자 요청:` 댓글로 보존한 뒤, `develop`에 반영된 Milestone 내용을 기준으로 Plane 본문을 치환한다. 검증: 댓글 보존 실패 시 본문 치환으로 넘어가지 않고, develop 반영 전에는 Todo 이동을 하지 않는다.
|
||||
- [x] [plane-todo] sync layer가 `develop` push 완료 또는 roadmap scan으로 감지한 Milestone을 기준으로 Plane 제목을 `[milestone-id] 제목` 형식으로 바꾸고 티켓을 `Todo`로 이동한다. 검증: milestone id는 `develop`의 Milestone 파일 slug를 기본값으로 쓰고, 실제 identity는 제목이 아니라 identity map의 Milestone path와 provider work item id로 추적된다.
|
||||
- [x] [creation-idmap] Milestone path, Milestone id, provider, work item id, provider revision, roadmap revision을 identity map으로 연결한다. 검증: 같은 Plane 티켓 또는 같은 Milestone path가 재처리되어도 같은 Milestone/work item pair를 찾을 수 있다.
|
||||
- [x] [creation-retry] 생성 동기화의 idempotency와 부분 실패 재시도 정책을 정한다. 검증: develop 반영, 댓글 보존, 본문 치환, 제목 변경, Todo 이동 중 일부만 성공해도 재시도 시 중복 생성 없이 남은 단계만 적용된다.
|
||||
- [ ] [creation-sync-orchestrator] develop Milestone match, identity/step ledger, Plane 원문 보존/본문·제목 갱신/`Todo` 이동을 하나의 runtime sync orchestrator로 연결한다. 검증: provider fake와 store fake 기반 테스트에서 not-ready는 provider 호출 없음, ready는 ledger 단계별 재시도와 Plane projection 순서를 보장한다. 계획: `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 없음
|
||||
- 상태: 보완 필요
|
||||
- 요청일: 2026-06-14
|
||||
- 완료 근거:
|
||||
- 코드레벨 검토 결과, 현재 Core에는 `roadmapsync` helper와 storage wrapper가 있으나 develop scan 결과를 identity/step ledger와 Plane `Todo` projection에 연결하는 runtime orchestrator/caller가 없다.
|
||||
- `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check`는 PASS였지만 helper 미연결을 검출하지 못한다.
|
||||
- 보완 계획은 `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`에 작성했다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] `creation-sync-orchestrator` 계획이 PASS되고 runtime caller가 검증되었다
|
||||
- [ ] 이후 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
- 리뷰 코멘트: 코드레벨 검토 결과 runtime sync orchestrator 누락이 확인되어 완료/아카이브를 보류한다.
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- Gito `/proto-socket`의 `branch.updated`를 NomadCode core가 구독하고 target branch를 재검증/스캔해 creation sync job을 enqueue하는 backend bridge 구현. 이 범위는 후속 `Gito Branch Event Creation Sync Bridge` Milestone으로 분리한다.
|
||||
- Plane `Todo -> In Progress` 실행 시작
|
||||
- Milestone Task를 Plane 하위 티켓으로 생성하거나 동기화하는 구현
|
||||
- `agent-task/m-<milestone-id>` plan/code-review 실행 lifecycle
|
||||
|
|
@ -128,6 +134,7 @@ Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토
|
|||
- 관련 경로: `services/core/internal/workitem/`, `services/core/internal/scheduler/`, `services/core/internal/http/`, `services/core/internal/db/`, `services/core/migrations/`, `services/core/queries/`, `packages/contracts/`, `agent-roadmap/`
|
||||
- 선행 작업: Roadmap Driven Agent-Ops Automation의 sync domain 방향 정리
|
||||
- 후속 작업: `Milestone Execution Lifecycle Sync`
|
||||
- 후속 분리(2026-06-14): Gito는 Forgejo/webhook 기반 `branch.updated` event producer이고, NomadCode core는 이를 proto-socket으로 소비해 target branch를 재검증/스캔한 뒤 `EnqueueRoadmapCreationSync`를 호출해야 한다. 이 backend consumer/bridge는 `Gito Branch Event Creation Sync Bridge` Milestone으로 분리했다. 현재 `creation-sync-orchestrator` task는 matched scan/job 입력 이후의 orchestrator, worker, enqueue seam을 닫는 범위로 본다.
|
||||
- 현재 지점: 2026-06-08부터 이 마일스톤을 활성 진행 대상으로 둔다. 우선 Plane-origin Milestone 생성 sync를 `Todo` 검토 상태까지 수렴시키는 범위에서 확인한다.
|
||||
- Source of truth: `develop` branch의 `agent-roadmap`을 Milestone sync의 source of truth로 둔다. Plane은 이 마일스톤의 intake/projection/review UI이며, agent chat/IDE-origin 생성은 이번 범위에서 제외한다.
|
||||
- Develop 반영 기준: 이 마일스톤에서 `develop`에 반영되었다는 말은 PR 요청이나 PR 생성이 아니라 `develop` branch에 Milestone 변경 commit/push가 완료된 상태를 뜻한다.
|
||||
|
|
@ -151,9 +158,11 @@ Plane-origin 생성 경로를 `develop` agent-roadmap 기준의 `Todo` 검토
|
|||
- Workspace provision pipeline 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/07+06_workspace_provision_pipeline/complete.log`의 `Roadmap Completion` 근거로 `[workspace-provision]`을 완료 처리했다. 검증 근거는 workspace provision hook의 slot 예약/provider fetch/task creation 이전 실행, HTTP binder의 default `slots/000` 등록, `ErrWorkspaceProvisionNotReady` 409 매핑, `cd services/core && go test -count=1 ./internal/projectsync ./internal/workitempipeline ./internal/http`, `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check` PASS다.
|
||||
- Agent bridge 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `authoring.BuildAuthoringGenerateInput`, OpenAI-compatible `/v1/responses` adapter, scheduler authoring route/state metadata, `roadmapsync` identity/develop match 기준을 근거로 `[iop-http-channel]`, `[authoring-state]`, `[provider-identity]`, `[agent-bridge-lock]`을 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/authoring ./internal/scheduler ./internal/roadmapsync` PASS. `[agent-run-result]`는 model bridge 응답 성공과 `develop` push 검증 사이의 성공 판정 경계를 보강해야 하므로 `agent-task/m-milestone-work-item-creation-sync/08_agent_run_result_gate/` 계획으로 분리했다.
|
||||
- Agent run result gate 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/08_agent_run_result_gate/complete.log`의 `Roadmap Completion` 근거로 `[agent-run-result]`를 완료 처리했다. 검증 근거는 `DecideAuthoringResult` helper, authoring failure category metadata, scheduler authoring state 보정, `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check` PASS다.
|
||||
- Creation sync 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `workitem.BuildCreateTaskInput`, `authoring.BuildAuthoringGenerateInput`, scheduler authoring route, `roadmapsync.MatchDevelopMilestone`, `ParseMilestoneIdentity`, `ProjectPlaneTodo`, `FormatPlaneProjection`, roadmap sync identity/step store, `ReconcileCreationCycle` 근거로 `[plane-agent-authoring]`, `[develop-truth]`, `[pushed-milestone-match]`, `[plane-preserve]`, `[plane-todo]`, `[creation-idmap]`, `[creation-retry]`를 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline ./internal/authoring ./internal/roadmapsync ./internal/adapters/plane ./internal/storage ./internal/http ./internal/scheduler` PASS, `git diff --check` PASS. `[plane-trigger]`는 Backlog + AGENT assignee eligibility gate가 아직 없고, `[plane-draft]`는 Milestone 초안 작성/갱신의 provider identity와 중복 방지 authoring contract 보강이 필요해 각각 plan으로 분리했다.
|
||||
- 중지 지점: project sync 설정, workspace topology, IOP Edge HTTP authoring run 경계, authoring run 성공/실패 판정, pushed Milestone identity match, Plane 원문 댓글 보존/본문·제목 갱신/`Todo` 이동 projection, identity map 기반 idempotency/retry는 완료 근거가 있다.
|
||||
- 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/<index>` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 최상위 `plane-nomad-iop-cycle` 실행 단위는 완료 근거가 있으며, `In Progress` 이후 실행은 후속 잠금 범위다.
|
||||
- Creation sync 소형 항목 동기화(2026-06-14): 현재 Core 코드의 `workitem.BuildCreateTaskInput`, `authoring.BuildAuthoringGenerateInput`, scheduler authoring route, `roadmapsync.MatchDevelopMilestone`, `ParseMilestoneIdentity`, `ProjectPlaneTodo`, `FormatPlaneProjection`, roadmap sync identity/step store, `ReconcileCreationCycle` 근거로 `[plane-agent-authoring]`, `[develop-truth]`, `[pushed-milestone-match]`, `[plane-preserve]`, `[plane-todo]`, `[creation-idmap]`, `[creation-retry]`를 완료 처리했다. 검증: `cd services/core && go test -count=1 ./internal/workitem ./internal/workitempipeline ./internal/authoring ./internal/roadmapsync ./internal/adapters/plane ./internal/storage ./internal/http ./internal/scheduler` PASS, `git diff --check` PASS. 당시 남은 `[plane-trigger]`, `[plane-draft]`는 후속 완료 로그 기준으로 아래 현황 동기화에서 닫았다.
|
||||
- Creation trigger/draft 현황 동기화(2026-06-14): `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/09_creation_trigger_gate/complete.log`의 `Roadmap Completion` 근거로 `[plane-trigger]`를 완료 처리했다. 검증 근거는 ignored trigger를 workspace provision/slot reservation 전에 판정하는 gate, legacy Plane route trigger 전달 테스트, `cd services/core && go test -count=1 ./internal/workitempipeline ./internal/http`, `cd services/core && go test ./...`, `git diff --check` PASS다. `agent-task/archive/2026/06/m-milestone-work-item-creation-sync/10+09_plane_draft_authoring/complete.log`의 `Roadmap Completion` 근거로 `[plane-draft]`를 완료 처리했다. 검증 근거는 full provider identity context 보존, idempotent Milestone draft instructions, scheduler authoring route regression, `cd services/core && go test -count=1 ./internal/authoring ./internal/roadmapsync ./internal/adapters/openai ./internal/scheduler`, `cd services/core && go test -count=1 ./...`, `git diff --check` PASS다.
|
||||
- 완료 보완 리뷰(2026-06-14): 현재 Core 코드에는 `roadmapsync` helper와 저장소는 있으나 이를 호출해 develop scan 결과를 identity/step ledger와 Plane `Todo` projection에 연결하는 runtime sync orchestrator가 없다. 보완 계획: `agent-task/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`.
|
||||
- 중지 지점: project sync 설정, workspace topology, IOP Edge HTTP authoring run 경계, authoring run 성공/실패 판정, pushed Milestone identity match, Plane 원문 댓글 보존/본문·제목 갱신/`Todo` 이동 projection, identity map 기반 idempotency/retry는 helper와 단위 경계 완료 근거가 있다. runtime orchestrator 연결은 `creation-sync-orchestrator` 완료 전까지 미완료다.
|
||||
- 루프 목표: Plane `Backlog + AGENT assignee` -> `slots/<index>` workspace 예약/준비 -> IOP Edge HTTP로 workspace agent authoring run 실행 -> agent가 Milestone 파일 작성/갱신 후 `develop` push -> sync layer가 `branches/develop` 또는 remote `develop`에서 push 완료/roadmap scan을 감지 -> Plane 원문 댓글 보존/본문 치환/제목 변경/`Todo` 이동 -> 사용자가 확인 후 `In Progress`로 이동하는 지점까지다. 최상위 `plane-nomad-iop-cycle` 실행 단위의 완료 판정은 `creation-sync-orchestrator` 완료 후 재검토하며, `In Progress` 이후 실행은 후속 잠금 범위다.
|
||||
- 후속 잠금: `Todo -> In Progress` 이후 실행 lifecycle은 사용자가 명시적으로 잠금을 해제할 때까지 진행하지 않는다.
|
||||
- Plane work item 경계: 이 마일스톤에서 말하는 Plane Milestone은 Plane native milestone object가 아니라 1상위 티켓 = 1 agent-roadmap Milestone으로 보는 work item mapping이다.
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
# User Review Required - m-milestone-work-item-creation-sync/11_creation_sync_orchestrator
|
||||
|
||||
## 요청 일시
|
||||
|
||||
2026-06-14
|
||||
|
||||
## 상태
|
||||
|
||||
RESOLVED
|
||||
|
||||
## 해소 결과
|
||||
|
||||
- 해소일: 2026-06-14
|
||||
- 최종 해소 판정: PASS/RESOLVED
|
||||
- 사용자 결정: Gito proto-socket `branch.updated` backend bridge는 현재 task의 미완료로 남기지 않고, 별도 Milestone `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md`로 분리한다.
|
||||
- 현재 task 완료 범위: matched develop scan/job 입력 이후의 creation sync orchestrator, River worker, server wiring, `EnqueueRoadmapCreationSync` seam까지로 축소해 닫는다.
|
||||
|
||||
## 사유
|
||||
|
||||
- 유형: implementation-blocked
|
||||
- 현재 리뷰 회차: 1
|
||||
- 최종 판정: FAIL
|
||||
- 요약: creation sync orchestrator와 River worker/enqueue seam은 구현됐지만, 이를 실제로 호출할 develop scan producer가 repo에 없다. producer를 이번 slice에 포함할지와 트리거 메커니즘을 무엇으로 둘지는 public/internal API 경계와 proto-socket/REST 예외 판단에 걸려 있어 자동 follow-up이 임의 결정하지 않는다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | `EnqueueRoadmapCreationSync` caller가 없어 REVIEW_SYNC-2가 사용자 범위 결정 대기 상태로 남음 |
|
||||
| `USER_REVIEW.md` | user decision | PASS/RESOLVED | Gito event consumer/producer bridge를 새 Milestone으로 분리하고 현재 task는 orchestrator/worker/enqueue seam 범위로 종료 |
|
||||
|
||||
## 차단 근거
|
||||
|
||||
- 문제: `services/core/internal/scheduler/river.go:99`에 enqueue seam이 있고 `services/core/internal/scheduler/roadmap_sync_jobs.go:18`은 develop-scan handoff가 호출한다고 설명하지만, 실제 repo에는 그 handoff/producer가 없다.
|
||||
- 현재 archive plan: `plan_cloud_G07_0.log`
|
||||
- 현재 archive review: `code_review_cloud_G07_0.log`
|
||||
- 검증 명령: `rg --sort path "scheduler\\.New\\(|EnqueueRoadmapCreationSync|RoadmapCreationSync|CreationSyncRunner" services/core`
|
||||
- 실제 출력: `EnqueueRoadmapCreationSync` 호출자는 `services/core/internal/scheduler/river.go`의 method 정의뿐이며, 나머지는 worker/job/test/주입 코드다. 추가 검증 `cd services/core && go test -count=1 ./...`, `cd services/core && go vet ./...`, `git diff --check`는 모두 PASS했다.
|
||||
- 차단 판단 근거: 계획은 producer가 없으면 dead worker만으로 PASS하지 말고 "runtime scan producer 범위 결정 필요"를 사용자 리뷰 요청으로 남기라고 명시했다. 새 producer를 만들려면 callback route, scheduled scan, 기존 authoring handoff 확장 등 트리거 형태를 결정해야 하며, 이는 contracts/core domain boundary와 직결되는 범위 결정이다.
|
||||
|
||||
## 사용자 결정 필요
|
||||
|
||||
- [ ] 자동 follow-up plan/review를 계속 진행한다.
|
||||
- [ ] 계획을 재작성한다.
|
||||
- [ ] 테스트 환경, secret, 외부 서비스, SDK, 장비 조건을 준비한 뒤 재시도한다.
|
||||
- [x] 작업 범위를 줄이거나 보류/폐기한다.
|
||||
|
||||
구체적으로 아래 중 하나가 필요하다.
|
||||
|
||||
- producer를 이번 slice에 포함하고, 선택한 트리거 메커니즘에서 `EnqueueRoadmapCreationSync`를 호출하도록 구현한다.
|
||||
- producer를 이번 slice 범위에서 제외하고, 현재 worker/seam/server 주입과 단위 테스트만으로 이 task를 종료할지 결정한다.
|
||||
- producer 설계를 별도 plan으로 재작성한다.
|
||||
|
||||
해소: producer 설계는 별도 Milestone으로 분리했고, 현재 task는 producer를 포함하지 않는 worker/seam/server wiring 범위로 종료한다.
|
||||
|
||||
## 재개 조건
|
||||
|
||||
- producer를 포함한다면 트리거 메커니즘이 정해져 있고, 해당 caller가 `RoadmapCreationSyncJobArgs`를 만들 수 있는 입력 출처가 정해져 있어야 한다.
|
||||
- producer를 제외한다면 이번 task의 완료 범위를 worker/seam/server wiring으로 축소한다는 사용자 결정이 필요하다.
|
||||
- 계획 재작성이라면 새 scope가 public HTTP, proto-socket, scheduled scan, 기존 authoring handoff 중 어느 경계를 사용할지 명시돼야 한다.
|
||||
|
||||
## 다음 실행 힌트
|
||||
|
||||
- 사용자 결정 후 `plan` 스킬로 이 `USER_REVIEW.md`를 `user_review_0.log`로 아카이브하고, 같은 task path에서 새 `PLAN-*-G??.md` / `CODE_REVIEW-*-G??.md`를 작성한다.
|
||||
- producer 구현을 계속한다면 우선 `services/core/internal/authoring/result.go`, `services/core/internal/workitempipeline/service.go`, `services/core/internal/scheduler/river.go`, `services/core/internal/scheduler/roadmap_sync_jobs.go`의 책임 경계를 비교한다.
|
||||
|
||||
## 종료 규칙
|
||||
|
||||
- 사용자가 이 stop state를 완료/PASS로 해소하면 `USER_REVIEW.md`를 해소 상태로 갱신하고, `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
- 새 구현이 필요하면 `plan` 스킬이 `USER_REVIEW.md`를 `user_review_N.log`로 아카이브한 뒤 새 `PLAN-*-G??.md` / `CODE_REVIEW-*-G??.md`를 작성한다.
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
<!-- task=m-milestone-work-item-creation-sync/11_creation_sync_orchestrator plan=0 tag=REVIEW_SYNC -->
|
||||
|
||||
# Code Review Reference - REVIEW_SYNC
|
||||
|
||||
> **[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-14
|
||||
task=m-milestone-work-item-creation-sync/11_creation_sync_orchestrator, plan=0, tag=REVIEW_SYNC
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
|
||||
- Task ids:
|
||||
- `creation-sync-orchestrator`: develop Milestone match, identity/step ledger, Plane 원문 보존/본문·제목 갱신/`Todo` 이동 runtime sync orchestrator
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_SYNC-1] Creation sync orchestrator | [x] |
|
||||
| [REVIEW_SYNC-2] Runtime wiring | [x] (scan producer 범위는 `사용자 리뷰 요청` 참고) |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] [REVIEW_SYNC-1] develop scan candidate를 identity/step ledger와 Plane projection 단계에 연결하는 creation sync orchestrator를 구현한다.
|
||||
- [x] [REVIEW_SYNC-2] orchestrator가 dead code로 남지 않도록 scheduler/server runtime wiring 또는 기존 develop scan handoff에 연결하고 테스트로 증명한다.
|
||||
- [x] 중간 및 최종 검증 명령을 fresh 실행하고 실제 stdout/stderr를 `CODE_REVIEW-cloud-G07.md`에 붙여 넣는다.
|
||||
- [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_cloud_G07_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.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-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`를 `agent-task/archive/YYYY/MM/m-milestone-work-item-creation-sync/11_creation_sync_orchestrator/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-milestone-work-item-creation-sync/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [x] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
|
||||
- [x] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- `plane_projection.go`의 step helper 분리 방식: 계획은 "unexported helper로 쪼개거나 동등 단계별 호출을 orchestrator에 구현" 둘 중 하나를 허용했다. orchestrator가 `roadmapsync` 외부 package(`roadmapsyncpipeline`)에 있어 unexported helper를 호출할 수 없으므로, exported 단계별 helper(`PreserveOriginalComment`, `UpdatePlaneBody`, `MovePlaneTodo`)와 입력 검증 helper(`ValidateProjectionInput`)로 분리했다. 기존 `ProjectPlaneTodo`는 이 helper들을 재사용하도록 바꿔 공개 동작과 기존 테스트를 그대로 유지한다.
|
||||
- `StepDevelopMatched`에는 provider side effect가 없다(매칭 통과 사실만 기록). orchestrator의 `runStep`에서 이 단계는 no-op로 처리하고 ledger mark만 기록한다. 계획의 step 목록(`StepDevelopMatched` 포함)을 그대로 따른 결과다.
|
||||
- self actor: `cmd/server/main.go`에서 전용 NomadCode Plane 계정 id 설정값이 아직 없어 `selfActor`를 빈 문자열로 주입했다. `ReconcileCreationCycle`은 빈 selfActor일 때 self-mutation guard를 no-op로 두므로 실제 트리거를 잘못 skip하지 않는다. 계정 id 설정은 후속 범위.
|
||||
- 검증 명령은 계획 그대로 실행했다. 추가로 gofmt가 신규 테스트 파일의 struct 정렬을 한 번 정리했으나(`gofmt -w internal/roadmapsyncpipeline/service_test.go`) 동작 변경은 없고 최종 `go test`/`go vet`/`git diff --check`는 재실행해 PASS를 확인했다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **새 side-effect package `internal/roadmapsyncpipeline`**: 계획대로 `roadmapsync`는 provider-neutral helper 중심으로 유지하고, identity/step 저장소 + provider facet을 묶어 호출하며 단계별 ledger를 기록하는 orchestration만 새 package에 두었다. core domain rule의 "provider별 세부를 workflow/service에 직접 흘리지 않는다" 경계를 따른다.
|
||||
- **narrow seam interface**: orchestrator는 `Store`(identity get/upsert, completed steps, mark step)와 `roadmapsync.PlaneTodoProvider`만 의존한다. `*storage.Store`와 plane adapter가 그대로 만족하고, 테스트는 fake로 DB/Plane 없이 단위 검증한다.
|
||||
- **단계 성공 직후 ledger 기록**: `runRemainingSteps`는 `OrderedSteps` 순서로 nextStep부터 실행하며, 각 provider 호출이 성공한 직후에만 `MarkStepCompleted`를 호출한다. 중간 실패 시 이후 provider 호출과 ledger mark가 모두 일어나지 않아, retry가 남은 단계만 재실행한다(테스트 `...StopsBeforeLaterStepOnProviderError`, `...RetrySkipsCompletedSteps`로 증명).
|
||||
- **not-ready/conflict/self-mutation/complete는 provider side effect 없이 typed result 반환**: develop match gate와 `ReconcileCreationCycle` 결과를 `SyncAction`으로 매핑하고, resume일 때만 provider를 호출한다.
|
||||
- **identity 입력 검증 시점**: 어떤 identity write나 provider 호출 전에 `ValidateProjectionInput`으로 projection 입력(ref/original body/todo state/milestone markdown)을 먼저 거른다.
|
||||
- **scheduler 선택적 worker 등록**: `scheduler.New`에 `CreationSyncRunner`(nil 허용) 인자를 추가하고, non-nil일 때만 `RoadmapCreationSyncWorker`를 등록한다. nil이면 기존 task worker만 등록되어 기본 동작이 유지된다(테스트 `...KeepsTaskWorkerOnlyBehavior`). enqueue seam `EnqueueRoadmapCreationSync`는 `EnqueueTask`와 분리된 내부 seam이며 public HTTP route를 추가하지 않았다.
|
||||
- **worker terminal outcome 처리**: not-ready/conflict/self-mutation/complete는 orchestrator가 이미 side effect 없이 보장하는 정상 종결이므로 worker는 로그 후 nil을 반환한다. provider/storage 오류만 error로 반환해 River가 재시도한다.
|
||||
- **새 DB migration 없음**: 기존 `roadmap_sync_identities`/`roadmap_sync_steps`와 storage wrapper만 사용했다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 필요
|
||||
- 사유 유형: 계획 범위 결정 (runtime develop scan producer)
|
||||
- 결정 필요: `EnqueueRoadmapCreationSync`를 실제로 호출하는 develop scan producer를 이번 슬라이스에서 구현할지, 그리고 그 트리거 메커니즘(예: 새 internal/public develop-scan callback route, scheduled 주기 scan, 기존 authoring handoff 확장 중 어느 것)을 무엇으로 둘지에 대한 사용자 결정이 필요하다.
|
||||
- 차단 근거: 계획 REVIEW_SYNC-2가 "기존 develop scan producer가 있으면 그 producer에서 `EnqueueRoadmapCreationSync`를 호출한다. 현재 repo에 producer가 끝내 없으면 dead worker만으로 완료하지 말고 `사용자 리뷰 요청`에 'runtime scan producer 범위 결정 필요'를 기록한다"고 명시한다. 코드 전수 검색 결과 repo에 develop scan producer가 없다(아래 grep 출력은 주석 1건만 매칭). producer 트리거를 무엇으로 둘지는 contracts/도메인 경계(proto-socket 우선, REST 예외 사유)와 직결되어 임의 선택이 부적절하다. core domain rule상 새 internal 런타임 통신은 proto-socket 우선이고 REST/HTTP는 예외 사유를 남겨야 하므로, 트리거 형태 자체가 사용자/설계 결정 사항이다.
|
||||
- 실행한 검증/명령:
|
||||
- `grep -rn "ScanResult\|MatchDevelopMilestone\|ProjectPlaneTodo\|develop scan\|DevelopScan\|ScanDevelop" --include=*.go internal cmd | grep -v "_test.go" | grep -v "roadmapsync/"` → 출력은 `internal/workitempipeline/service.go:65:// distinguish the develop scanning base from the slot's independent checkout.` (주석) 1건뿐. 실제 scan producer/caller 없음을 확인.
|
||||
- 본 슬라이스가 추가한 worker/enqueue seam/server 주입과 단위 테스트는 모두 PASS(아래 검증 결과 참고). worker는 등록되고 `EnqueueRoadmapCreationSync` seam은 호출 가능하지만, 아직 이를 호출하는 producer가 없다.
|
||||
- 자동 후속 불가 이유: producer를 추가하려면 트리거 메커니즘(callback route 신설 여부, proto-socket vs REST 예외, scan 입력 출처)을 결정해야 하며 이는 명령 재실행이나 산출물 수집으로 채울 수 있는 검증 공백이 아니라 설계/범위 결정이다.
|
||||
- 재개 조건: 사용자가 (1) producer를 이번 슬라이스 범위에 포함할지, (2) 포함한다면 트리거 메커니즘을 무엇으로 둘지 결정하면, 결정된 seam에서 `EnqueueRoadmapCreationSync`를 호출하도록 연결하고 해당 caller 테스트를 추가해 재개한다. 범위에서 제외하기로 하면 worker/seam/server 주입 + 단위 테스트로 본 슬라이스를 마무리한다.
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- helper-level 테스트만 추가하고 runtime caller/wiring 없이 PASS 처리하지 않았는지 확인한다.
|
||||
- not-ready/self-mutation/conflict 경로에서 provider side effect가 없는지 확인한다.
|
||||
- comment, body, status 각 provider 성공 직후 ledger mark가 기록되고 retry가 남은 단계만 실행하는지 확인한다.
|
||||
- scheduler/server wiring이 실제로 compile되고 nil service regression이 유지되는지 확인한다.
|
||||
- 새 public HTTP route나 DB migration을 몰래 추가하지 않았는지 확인한다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### REVIEW_SYNC-1 중간 검증
|
||||
|
||||
```bash
|
||||
$ cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/storage 0.004s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s
|
||||
```
|
||||
|
||||
### REVIEW_SYNC-2 중간 검증
|
||||
|
||||
```bash
|
||||
$ cd services/core && go test -count=1 ./internal/scheduler ./internal/http ./internal/adapters/plane
|
||||
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.013s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/http 0.005s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.007s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
|
||||
```bash
|
||||
$ cd services/core && go test -count=1 ./...
|
||||
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.005s
|
||||
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
|
||||
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.008s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/adapters/jira 0.008s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/adapters/mattermost 0.007s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.007s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 0.009s
|
||||
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
|
||||
ok github.com/nomadcode/nomadcode-core/internal/authoring 0.003s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/config 0.002s
|
||||
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
|
||||
ok github.com/nomadcode/nomadcode-core/internal/http 0.005s
|
||||
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
|
||||
ok github.com/nomadcode/nomadcode-core/internal/notification 0.002s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/projectsync 0.004s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.017s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsync 0.003s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline 0.003s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/scheduler 2.012s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/storage 0.005s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/workflow 0.004s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/workitem 0.006s
|
||||
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 0.004s
|
||||
```
|
||||
|
||||
```bash
|
||||
$ cd services/core && go vet ./...
|
||||
(출력 없음)
|
||||
```
|
||||
|
||||
```bash
|
||||
$ git diff --check
|
||||
(출력 없음)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[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
|
||||
|
||||
| Section | Owner | Note |
|
||||
|---------|-------|------|
|
||||
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
|
||||
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify |
|
||||
| 구현 항목별 완료 여부 | Implementing agent checks only | Item names are fixed |
|
||||
| 구현 체크리스트 | Implementing agent checks only | Text/order are fixed |
|
||||
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
|
||||
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
|
||||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
|
||||
| 검증 결과 | Implementing agent fills outputs only | Command changes require `계획 대비 변경 사항` |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
### 2026-06-14 - cloud-G07
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Fail
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Fail
|
||||
- verification trust: Pass
|
||||
- 발견된 문제:
|
||||
- Required `services/core/internal/scheduler/river.go:99`: `EnqueueRoadmapCreationSync` seam과 worker는 추가됐지만, repo 안에는 이를 호출하는 develop scan producer가 없다. `services/core/internal/scheduler/roadmap_sync_jobs.go:18`은 internal develop-scan handoff가 enqueue한다고 설명하지만, 실제 검색 결과 `EnqueueRoadmapCreationSync` 호출자는 seam 자신뿐이고 기존 `workitempipeline`/`authoring` 경로도 authoring task 생성과 결과 판정까지만 담당한다. 계획이 명시한 대로 이 상태는 dead worker만으로 PASS할 수 없으며, 사용자 결정 후 선택된 producer/callback/scheduled scan 경로에서 enqueue를 연결하거나 이번 slice에서 producer를 제외한다고 명시적으로 범위를 줄여야 한다.
|
||||
- 다음 단계: USER_REVIEW - 구현 파일의 `사용자 리뷰 요청`은 구체적인 범위/트리거 결정, 검색 근거, 재개 조건을 포함하고 있어 user-review gate가 트리거된다. active plan/review를 로그로 아카이브한 뒤 `USER_REVIEW.md`를 작성한다.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Complete - m-milestone-work-item-creation-sync/11_creation_sync_orchestrator
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-14
|
||||
|
||||
## 요약
|
||||
|
||||
creation sync orchestrator task는 1회 code-review에서 Gito/develop scan producer 범위 미결정으로 FAIL/USER_REVIEW가 되었고, 사용자 결정에 따라 해당 bridge를 별도 Milestone으로 분리한 뒤 PASS/RESOLVED로 종료했다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | `EnqueueRoadmapCreationSync` caller가 없어 Gito/proto-socket producer 범위 결정이 필요하다고 판정 |
|
||||
| `USER_REVIEW.md` | user decision | PASS/RESOLVED | Gito branch event backend bridge를 별도 Milestone으로 분리하고 현재 task는 orchestrator/worker/enqueue seam 범위로 종료 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `roadmapsyncpipeline.Service`가 matched develop scan candidate를 identity/step ledger와 Plane projection 단계에 연결한다.
|
||||
- scheduler/server에 optional `RoadmapCreationSyncWorker`와 `EnqueueRoadmapCreationSync` seam을 wiring했다.
|
||||
- Gito proto-socket `branch.updated` backend bridge는 `gito-branch-event-creation-sync-bridge.md` Milestone으로 분리했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline` - PASS; `roadmapsync`, `storage`, `roadmapsyncpipeline` 패키지 모두 ok.
|
||||
- `cd services/core && go test -count=1 ./internal/scheduler ./internal/http ./internal/adapters/plane` - PASS; `scheduler`, `http`, `adapters/plane` 패키지 모두 ok.
|
||||
- `cd services/core && go test -count=1 ./...` - PASS; core 전체 패키지 ok.
|
||||
- `cd services/core && go vet ./...` - PASS; 출력 없음.
|
||||
- `git diff --check` - PASS; 출력 없음.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
|
||||
- Completed task ids:
|
||||
- `creation-sync-orchestrator`: PASS; evidence=`plan_cloud_G07_0.log`, `code_review_cloud_G07_0.log`, `USER_REVIEW.md`; verification=`cd services/core && go test -count=1 ./...`, `cd services/core && go vet ./...`, `git diff --check`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- Gito branch event backend bridge: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/gito-branch-event-creation-sync-bridge.md`
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
<!-- task=m-milestone-work-item-creation-sync/11_creation_sync_orchestrator plan=0 tag=REVIEW_SYNC -->
|
||||
|
||||
# Plan - REVIEW_SYNC Creation Sync Orchestrator
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 필수 마지막 단계다. 구현 후 검증 명령의 실제 출력, 계획 대비 변경 사항, 주요 설계 결정을 기록하고 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용이다.
|
||||
|
||||
구현 중 사용자만 결정할 수 있는 외부 환경 전제, scope 충돌, 또는 안전한 진행을 막는 결정이 생기면 사용자에게 직접 묻지 말고 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정 필요 사항, 근거, 실행한 명령과 출력, 재개 조건을 기록하고 멈춘다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 마일스톤은 Plane-origin Milestone 생성 sync를 `Todo` 검토 상태까지 수렴시킨다고 되어 있지만, 코드레벨 검토 결과 helper와 저장소만 있고 이를 호출하는 런타임 orchestration 경로가 없다. `roadmapsync`의 match/projection/retry 함수, Plane adapter projection facet, storage ledger는 각각 테스트되어 있으나 develop scan 결과에서 Plane comment/body/status projection으로 이어지는 caller가 없다. 따라서 마일스톤 완료 대신 이 보완 작업을 먼저 구현해야 한다.
|
||||
|
||||
## 사용자 리뷰 요청 흐름
|
||||
|
||||
구현 중 차단 사항은 active `CODE_REVIEW-cloud-G07.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 채팅에서 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. code-review가 요청의 타당성을 확인한 뒤 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
|
||||
- Task ids:
|
||||
- `creation-sync-orchestrator`: develop Milestone match, identity/step ledger, Plane 원문 보존/본문·제목 갱신/`Todo` 이동 runtime sync orchestrator
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- 규칙/로드맵:
|
||||
- `agent-ops/rules/project/rules.md`
|
||||
- `agent-ops/rules/private/rules.md`
|
||||
- `agent-ops/rules/common/rules-roadmap.md`
|
||||
- `agent-ops/skills/common/router.md`
|
||||
- `agent-ops/skills/common/code-review/SKILL.md`
|
||||
- `agent-ops/skills/common/update-roadmap/SKILL.md`
|
||||
- `agent-ops/skills/common/analyze-roadmap-position/SKILL.md`
|
||||
- `agent-ops/skills/common/plan/SKILL.md`
|
||||
- `agent-ops/skills/common/_templates/roadmap-position-report-template.md`
|
||||
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
|
||||
- `agent-roadmap/ROADMAP.md`
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md`
|
||||
- `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-work-item-creation-sync.md`
|
||||
- 도메인 룰:
|
||||
- `agent-ops/rules/project/domain/core/rules.md`
|
||||
- `agent-ops/rules/project/domain/contracts/rules.md`
|
||||
- `agent-ops/rules/project/domain/workspace-ops/rules.md`
|
||||
- 테스트 규칙:
|
||||
- `agent-test/local/rules.md`
|
||||
- `agent-test/local/core-smoke.md`
|
||||
- `agent-test/local/contracts-smoke.md`
|
||||
- `agent-test/local/workspace-ops-smoke.md`
|
||||
- 소스:
|
||||
- `services/core/cmd/server/main.go`
|
||||
- `services/core/internal/adapters/openai/client.go`
|
||||
- `services/core/internal/adapters/plane/client.go`
|
||||
- `services/core/internal/authoring/request.go`
|
||||
- `services/core/internal/authoring/result.go`
|
||||
- `services/core/internal/db/models.go`
|
||||
- `services/core/internal/http/handlers.go`
|
||||
- `services/core/internal/http/router.go`
|
||||
- `services/core/internal/model/model.go`
|
||||
- `services/core/internal/projectsync/checkout.go`
|
||||
- `services/core/internal/projectsync/provision.go`
|
||||
- `services/core/internal/roadmapsync/develop_match.go`
|
||||
- `services/core/internal/roadmapsync/identity.go`
|
||||
- `services/core/internal/roadmapsync/milestone_parser.go`
|
||||
- `services/core/internal/roadmapsync/plane_format.go`
|
||||
- `services/core/internal/roadmapsync/plane_projection.go`
|
||||
- `services/core/internal/roadmapsync/retry.go`
|
||||
- `services/core/internal/scheduler/jobs.go`
|
||||
- `services/core/internal/scheduler/river.go`
|
||||
- `services/core/internal/storage/roadmap_sync_identities.go`
|
||||
- `services/core/internal/storage/roadmap_sync_steps.go`
|
||||
- `services/core/internal/storage/store.go`
|
||||
- `services/core/internal/workflow/model.go`
|
||||
- `services/core/internal/workflow/lifecycle.go`
|
||||
- `services/core/internal/workitem/provider.go`
|
||||
- `services/core/internal/workitempipeline/service.go`
|
||||
- 테스트:
|
||||
- `services/core/internal/adapters/openai/client_test.go`
|
||||
- `services/core/internal/adapters/plane/client_test.go`
|
||||
- `services/core/internal/http/handlers_test.go`
|
||||
- `services/core/internal/roadmapsync/develop_match_test.go`
|
||||
- `services/core/internal/roadmapsync/identity_test.go`
|
||||
- `services/core/internal/roadmapsync/milestone_parser_test.go`
|
||||
- `services/core/internal/roadmapsync/plane_format_test.go`
|
||||
- `services/core/internal/roadmapsync/plane_projection_test.go`
|
||||
- `services/core/internal/roadmapsync/retry_test.go`
|
||||
- `services/core/internal/scheduler/jobs_test.go`
|
||||
- `services/core/internal/storage/roadmap_sync_identities_test.go`
|
||||
- `services/core/internal/storage/roadmap_sync_steps_test.go`
|
||||
|
||||
### 테스트 환경 규칙
|
||||
|
||||
- `test_env=local`.
|
||||
- `agent-test/local/rules.md`가 존재해 전체를 읽었다.
|
||||
- 매칭 profile로 `core-smoke`, `contracts-smoke`, `workspace-ops-smoke`를 읽었다. 이번 변경은 Core 런타임 orchestration이며 Flutter/mobile 검증은 대상이 아니다.
|
||||
- 적용 명령:
|
||||
- 중간: `cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage`
|
||||
- 중간: `cd services/core && go test -count=1 ./internal/scheduler ./internal/http ./internal/adapters/plane`
|
||||
- 최종: `cd services/core && go test -count=1 ./...`
|
||||
- 최종: `cd services/core && go vet ./...`
|
||||
- 최종: `git diff --check`
|
||||
- fresh 실행이 중요하므로 최종 `go test`는 cache 출력이 아니라 `-count=1`을 사용한다.
|
||||
- 기존 검토 중 `cd services/core && go test ./...`, `cd services/core && go vet ./...`, `git diff --check`는 PASS였지만 helper 미연결을 잡지 못했으므로 이 계획의 PASS 근거로 충분하지 않다.
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- `MatchDevelopMilestone`, `ParseMilestoneIdentity`, `ProjectPlaneTodo`, `FormatPlaneProjection`, `ReconcileCreationCycle`, storage identity/step ledger는 helper 또는 storage 단위 테스트가 있다.
|
||||
- `workitempipeline`과 `scheduler` 테스트는 Plane trigger gate와 authoring bridge route를 다룬다.
|
||||
- 공백: develop scan 결과가 identity upsert, completed-step ledger, actor/revision guard, Plane comment/body/status projection을 단계별로 호출한다는 orchestrator/caller 테스트가 없다.
|
||||
- 공백: `StepOriginalCommentPreserved`, `StepPlaneBodyUpdated`, `StepPlaneTodoMoved`가 provider 성공 직후 각각 기록되고 재시도에서 건너뛰는지 검증하는 테스트가 없다.
|
||||
- 공백: `scheduler.New` 또는 server wiring에서 roadmap creation sync worker/service가 등록되어 실제 runtime path가 생기는지 검증하는 테스트가 없다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- 이름 변경 또는 제거 예정 심볼: 없음.
|
||||
- 새 symbol을 추가한다. 기존 `ProjectPlaneTodo`, `ReconcileCreationCycle`, `UpsertRoadmapSyncIdentity`, `CompletedSteps`, `MarkStepCompleted`, `MatchDevelopMilestone`, `ParseMilestoneIdentity`는 유지한다.
|
||||
- 현재 `rg --sort path` 결과 주요 helper는 package test와 storage wrapper 외 runtime caller가 없다. 이 계획의 핵심은 호출부 추가다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
- split decision policy를 계획 파일 선택 전에 평가했다.
|
||||
- 이 작업은 `m-milestone-work-item-creation-sync/11_creation_sync_orchestrator` 한 subtask의 단일 plan으로 둔다.
|
||||
- foundation과 call-site가 자연스럽게 보이지만, helper API만 먼저 끝내면 마일스톤을 막은 문제인 "실제 호출 경로 없음"이 해소되지 않는다. provider/store fake 기반 orchestrator 테스트와 scheduler/server wiring 테스트를 한 리뷰 단위에서 보는 편이 더 안전하다.
|
||||
- ownership은 Core sync runtime 하나이며, 새 DB migration이나 public HTTP/contract 변경을 기본 범위로 잡지 않는다.
|
||||
- 선행 subtask dependency는 없다. `agent-task/archive/**`는 이번 plan 작성에 필요한 predecessor 확인 대상이 아니므로 읽지 않았다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
- Plane-origin 생성 sync만 다룬다. Jira, mobile UI, project settings UI, `Todo -> In Progress` 실행 lifecycle은 제외한다.
|
||||
- 새 public HTTP API는 기본 범위에서 제외한다. develop scan producer가 반드시 process boundary를 넘어야 해서 public route가 필요해지면 contracts/domain rule을 다시 확인하고 `사용자 리뷰 요청` 또는 별도 follow-up으로 멈춘다.
|
||||
- 새 DB migration은 기본 범위에서 제외한다. 기존 `roadmap_sync_identities`와 `roadmap_sync_steps`로 identity/retry를 구현한다.
|
||||
- helper 단위 테스트를 단순히 더 추가하는 것으로 완료 처리하지 않는다. runtime caller 또는 worker/service wiring 테스트가 있어야 한다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
- `cloud-G07`: Core runtime, provider side effect, storage idempotency, scheduler/server wiring을 함께 판단해야 하고 기존 테스트가 helper-level에 치우쳐 있어 larger context와 강한 리뷰가 필요하다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] [REVIEW_SYNC-1] develop scan candidate를 identity/step ledger와 Plane projection 단계에 연결하는 creation sync orchestrator를 구현한다.
|
||||
- [ ] [REVIEW_SYNC-2] orchestrator가 dead code로 남지 않도록 scheduler/server runtime wiring 또는 기존 develop scan handoff에 연결하고 테스트로 증명한다.
|
||||
- [ ] 중간 및 최종 검증 명령을 fresh 실행하고 실제 stdout/stderr를 `CODE_REVIEW-cloud-G07.md`에 붙여 넣는다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## [REVIEW_SYNC-1] Creation Sync Orchestrator
|
||||
|
||||
### 문제
|
||||
|
||||
- `services/core/internal/roadmapsync/develop_match.go:44`는 develop scan candidate 준비 여부만 판단한다.
|
||||
- `services/core/internal/roadmapsync/retry.go:105`는 step ledger 기반 next action만 결정한다.
|
||||
- `services/core/internal/storage/roadmap_sync_identities.go:67`와 `services/core/internal/storage/roadmap_sync_steps.go:50`는 identity/step 저장소 wrapper만 제공한다.
|
||||
- `services/core/internal/roadmapsync/plane_projection.go:62`는 Plane projection을 한 번에 실행하지만, 현재 코드에는 위 helper들을 묶어 호출하고 각 단계 성공 직후 ledger를 기록하는 orchestrator가 없다.
|
||||
|
||||
현재 before:
|
||||
|
||||
```go
|
||||
// services/core/internal/roadmapsync/plane_projection.go:77
|
||||
if err := p.AppendComment(ctx, workitem.CommentInput{
|
||||
Ref: in.Ref,
|
||||
Body: preserveCommentPrefix + " " + in.OriginalBody,
|
||||
Format: workitem.CommentFormatHTML,
|
||||
ExternalSource: in.ExternalSource,
|
||||
ExternalID: in.ExternalID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := p.ProjectBody(ctx, workitem.BodyProjection{
|
||||
Ref: in.Ref,
|
||||
Title: projection.Title,
|
||||
DescriptionHTML: projection.BodyHTML,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return p.ProjectStatus(ctx, workitem.StatusProjection{
|
||||
Ref: in.Ref,
|
||||
ProviderState: in.TodoStateID,
|
||||
})
|
||||
```
|
||||
|
||||
### 해결 방법
|
||||
|
||||
- 새 side-effect package를 `services/core/internal/roadmapsyncpipeline/`에 만든다. 기존 `roadmapsync` package는 provider-neutral helper 중심으로 유지한다.
|
||||
- `Service.SyncCreation(ctx, input)`을 추가한다. input은 expected identity, scan result, work item ref, original Plane body, Todo state id, Milestone markdown, actor/self actor, provider/roadmap revision, external source/id를 가진다.
|
||||
- `ParseMilestoneIdentity`가 필요한 경우 scan result 구성 전에 사용하거나 service input에서 `ScannedMilestones`가 비어 있을 때만 milestone markdown/path에서 identity를 보강한다.
|
||||
- 기존 identity를 work item 기준으로 찾고 없으면 upsert한다. 이후 `CompletedSteps`를 읽어 `ReconcileCreationCycle`에 전달한다.
|
||||
- `ActionSkipSelfMutation`, `ActionConflict`, not-ready candidate는 provider side effect 없이 typed result를 반환한다.
|
||||
- ready + resume이면 다음 순서로 실행하고 각 provider 성공 직후 ledger를 기록한다.
|
||||
- `StepDevelopMatched`
|
||||
- `StepOriginalCommentPreserved`
|
||||
- `StepPlaneBodyUpdated`
|
||||
- `StepPlaneTodoMoved`
|
||||
- `ProjectPlaneTodo`는 기존 공개 동작을 유지한다. orchestrator가 단계별 ledger를 기록할 수 있도록 projection formatting과 provider step 호출을 재사용 가능한 unexported helper로 쪼개거나, 동등한 단계별 호출을 orchestrator 안에 구현한다. 기존 `ProjectPlaneTodo` 테스트는 계속 통과해야 한다.
|
||||
|
||||
목표 after:
|
||||
|
||||
```go
|
||||
candidate := roadmapsync.MatchDevelopMilestone(roadmapsync.MatchInput{
|
||||
Scan: in.Scan,
|
||||
Expected: in.Expected,
|
||||
})
|
||||
if !candidate.Ready {
|
||||
return SyncCreationResult{Action: SyncActionNotReady, Reason: candidate.Reason}, nil
|
||||
}
|
||||
|
||||
identityRow, completed, err := s.loadOrUpsertIdentityAndSteps(ctx, in, candidate)
|
||||
if err != nil {
|
||||
return SyncCreationResult{}, err
|
||||
}
|
||||
decision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{
|
||||
Identity: candidate.Identity,
|
||||
ProviderRevision: in.ProviderRevision,
|
||||
RoadmapRevision: in.RoadmapRevision,
|
||||
Actor: in.Actor,
|
||||
SelfActor: in.SelfActor,
|
||||
Existing: toExistingIdentity(identityRow),
|
||||
CompletedSteps: toRoadmapSyncCompleted(completed),
|
||||
})
|
||||
if decision.Action != roadmapsync.ActionResume {
|
||||
return resultFromDecision(decision), nil
|
||||
}
|
||||
|
||||
for _, step := range remainingSteps(decision.NextStep, completed) {
|
||||
if err := s.runStep(ctx, step, in, candidate); err != nil {
|
||||
return SyncCreationResult{Action: SyncActionRetryable, NextStep: step}, err
|
||||
}
|
||||
if _, err := s.store.MarkStepCompleted(ctx, identityRow.ID, toStorageStep(step)); err != nil {
|
||||
return SyncCreationResult{}, err
|
||||
}
|
||||
}
|
||||
return SyncCreationResult{Action: SyncActionComplete}, nil
|
||||
```
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/core/internal/roadmapsyncpipeline/service.go`를 추가한다.
|
||||
- [ ] `services/core/internal/roadmapsyncpipeline/service_test.go`를 추가한다.
|
||||
- [ ] 필요한 경우 `services/core/internal/roadmapsync/plane_projection.go` 내부 provider step helper를 분리하되 기존 API와 테스트를 유지한다.
|
||||
- [ ] 필요한 경우 `services/core/internal/roadmapsync/plane_projection_test.go`에 기존 `ProjectPlaneTodo` regression을 보강한다.
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
- 작성: `services/core/internal/roadmapsyncpipeline/service_test.go`
|
||||
- `TestSyncCreationNotReadyDoesNotCallProvider`: develop match not-ready면 provider 호출과 ledger mark가 없다.
|
||||
- `TestSyncCreationReadyRunsStepsAndMarksLedger`: ready scan이면 comment, body, status 순서와 step ledger 기록 순서가 일치한다.
|
||||
- `TestSyncCreationRetrySkipsCompletedSteps`: completed ledger가 있으면 남은 단계만 실행한다.
|
||||
- `TestSyncCreationStopsBeforeLaterStepOnProviderError`: 중간 provider 실패 시 이후 provider 호출과 ledger mark가 없다.
|
||||
- `TestSyncCreationSkipsSelfMutation`: actor guard가 provider 호출을 막는다.
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -count=1 ./internal/roadmapsync ./internal/storage ./internal/roadmapsyncpipeline
|
||||
```
|
||||
|
||||
기대 결과: fresh test PASS. `./internal/roadmapsyncpipeline`가 새 package로 존재하지 않는 중간 상태에서 이 명령을 통과했다고 기록하면 안 된다.
|
||||
|
||||
## [REVIEW_SYNC-2] Runtime Wiring
|
||||
|
||||
### 문제
|
||||
|
||||
- `services/core/internal/scheduler/river.go:26`의 scheduler constructor는 `TaskWorker`만 등록한다.
|
||||
- `services/core/cmd/server/main.go:97`은 scheduler를 task lifecycle, notification, agent/model client만으로 구성한다.
|
||||
- `services/core/internal/scheduler/jobs.go:127`부터의 authoring route는 model bridge 응답 후 `authoring_run_state: "in_progress"` 결과를 반환하고, develop scan/projection orchestration을 호출하지 않는다.
|
||||
- `services/core/internal/http/router.go:33`부터의 API에는 integrations/tasks/task enqueue만 있고 roadmap sync callback이나 내부 handoff route가 없다.
|
||||
- 따라서 REVIEW_SYNC-1의 service만 추가하면 여전히 runtime dead code가 된다.
|
||||
|
||||
현재 before:
|
||||
|
||||
```go
|
||||
// services/core/internal/scheduler/river.go:26
|
||||
func New(pool *pgxpool.Pool, lifecycle TaskLifecycle, notifications *notification.Service, agentClient agent.Client, modelClient model.Client, runTimeout time.Duration, logger *slog.Logger) (*Client, error) {
|
||||
workers := river.NewWorkers()
|
||||
river.AddWorker(workers, &TaskWorker{
|
||||
Lifecycle: lifecycle,
|
||||
Notifications: notifications,
|
||||
Agent: agentClient,
|
||||
Model: modelClient,
|
||||
RunTimeout: runTimeout,
|
||||
Logger: logger,
|
||||
})
|
||||
```
|
||||
|
||||
### 해결 방법
|
||||
|
||||
- `scheduler`에 `RoadmapCreationSyncJobArgs`와 worker를 추가한다. worker는 REVIEW_SYNC-1 service를 호출하고, provider side effect 없이 complete/conflict/not-ready/skip 결과를 구분해 로그와 error를 안정적으로 반환한다.
|
||||
- `Client`에 `EnqueueRoadmapCreationSync(ctx, args)`를 추가한다. 이 method는 기존 task enqueue와 별도이며 scan handoff가 호출하는 내부 seam이다.
|
||||
- `scheduler.New`는 선택적 creation sync service를 받아 worker를 등록한다. nil이면 worker를 등록하지 않아 기존 테스트와 기본 task scheduler 동작을 유지한다.
|
||||
- `cmd/server/main.go`에서 Plane client와 store로 creation sync service를 구성해 scheduler에 전달한다. Plane-origin 범위만 다루며 Jira는 연결하지 않는다.
|
||||
- 기존 develop scan producer가 있으면 그 producer에서 `EnqueueRoadmapCreationSync`를 호출한다. 현재 repo에 producer가 끝내 없으면 구현 에이전트는 dead worker만으로 완료하지 말고 `사용자 리뷰 요청`에 "runtime scan producer 범위 결정 필요"를 기록한다.
|
||||
- public HTTP route는 기본 추가하지 않는다. 구현 중 process boundary가 필요하다고 확인되면 contracts/domain rule을 다시 확인하고 별도 범위로 멈춘다.
|
||||
|
||||
목표 after:
|
||||
|
||||
```go
|
||||
func New(
|
||||
pool *pgxpool.Pool,
|
||||
lifecycle TaskLifecycle,
|
||||
notifications *notification.Service,
|
||||
agentClient agent.Client,
|
||||
modelClient model.Client,
|
||||
creationSync CreationSyncRunner,
|
||||
runTimeout time.Duration,
|
||||
logger *slog.Logger,
|
||||
) (*Client, error) {
|
||||
workers := river.NewWorkers()
|
||||
river.AddWorker(workers, &TaskWorker{ /* existing fields */ })
|
||||
if creationSync != nil {
|
||||
river.AddWorker(workers, &RoadmapCreationSyncWorker{
|
||||
Sync: creationSync,
|
||||
Logger: logger,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `services/core/internal/scheduler/river.go`에 optional sync worker registration과 enqueue method를 추가한다.
|
||||
- [ ] `services/core/internal/scheduler/jobs.go` 또는 새 `roadmap_sync_jobs.go`에 job args/worker를 추가한다.
|
||||
- [ ] `services/core/cmd/server/main.go`에서 `roadmapsyncpipeline.Service`를 구성해 scheduler에 주입한다.
|
||||
- [ ] 기존 develop scan handoff가 확인되면 해당 caller를 연결한다. 없으면 `사용자 리뷰 요청`을 작성하고 dead-code-only PASS를 금지한다.
|
||||
- [ ] public HTTP route를 추가하지 않은 상태로 끝낼 수 있는지 확인한다. 추가가 필요하면 contracts follow-up 없이 몰래 확장하지 않는다.
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
- 작성 또는 갱신: `services/core/internal/scheduler/jobs_test.go` 또는 `services/core/internal/scheduler/river_test.go`
|
||||
- `TestRoadmapCreationSyncWorkerCallsService`: job args가 service input으로 전달된다.
|
||||
- `TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured`: service가 있을 때 worker/enqueue seam이 작동한다.
|
||||
- `TestSchedulerWithoutRoadmapSyncKeepsTaskWorkerOnlyBehavior`: nil service에서도 기존 task enqueue 동작이 깨지지 않는다.
|
||||
- 필요 시 `services/core/cmd/server`는 package test가 없으므로 `go test -count=1 ./...`와 `go vet ./...`로 compile wiring을 검증한다.
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -count=1 ./internal/scheduler ./internal/http ./internal/adapters/plane
|
||||
```
|
||||
|
||||
기대 결과: fresh test PASS. scheduler nil-service regression과 sync worker route가 모두 검증되어야 한다.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|------|------|
|
||||
| `services/core/internal/roadmapsyncpipeline/service.go` | REVIEW_SYNC-1 |
|
||||
| `services/core/internal/roadmapsyncpipeline/service_test.go` | REVIEW_SYNC-1 |
|
||||
| `services/core/internal/roadmapsync/plane_projection.go` | REVIEW_SYNC-1 |
|
||||
| `services/core/internal/roadmapsync/plane_projection_test.go` | REVIEW_SYNC-1 |
|
||||
| `services/core/internal/scheduler/river.go` | REVIEW_SYNC-2 |
|
||||
| `services/core/internal/scheduler/jobs.go` 또는 `services/core/internal/scheduler/roadmap_sync_jobs.go` | REVIEW_SYNC-2 |
|
||||
| `services/core/internal/scheduler/jobs_test.go` 또는 `services/core/internal/scheduler/river_test.go` | REVIEW_SYNC-2 |
|
||||
| `services/core/cmd/server/main.go` | REVIEW_SYNC-2 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
cd services/core && go test -count=1 ./...
|
||||
```
|
||||
|
||||
기대 결과: fresh test PASS.
|
||||
|
||||
```bash
|
||||
cd services/core && go vet ./...
|
||||
```
|
||||
|
||||
기대 결과: PASS.
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
기대 결과: 출력 없음.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -21,6 +21,7 @@ import (
|
|||
apphttp "github.com/nomadcode/nomadcode-core/internal/http"
|
||||
"github.com/nomadcode/nomadcode-core/internal/notification"
|
||||
"github.com/nomadcode/nomadcode-core/internal/protosocket"
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline"
|
||||
"github.com/nomadcode/nomadcode-core/internal/scheduler"
|
||||
"github.com/nomadcode/nomadcode-core/internal/storage"
|
||||
"github.com/nomadcode/nomadcode-core/internal/workflow"
|
||||
|
|
@ -94,7 +95,14 @@ func run(logger *slog.Logger) error {
|
|||
|
||||
lifecycle := workflow.NewLifecycle(store, logger)
|
||||
|
||||
taskScheduler, err := scheduler.New(pool, lifecycle, notificationService, agentClient, modelClient, time.Duration(cfg.WorkflowTaskTimeoutSec)*time.Second, logger)
|
||||
// Plane-origin Milestone creation sync orchestrator. Jira is intentionally
|
||||
// not wired here; this slice handles the Plane projection path only. The
|
||||
// self-actor is left empty until a dedicated NomadCode Plane account id is
|
||||
// configured, so the reconcile's self-mutation guard stays a no-op rather
|
||||
// than mis-skipping a real trigger.
|
||||
creationSync := roadmapsyncpipeline.NewService(store, planeClient, "")
|
||||
|
||||
taskScheduler, err := scheduler.New(pool, lifecycle, notificationService, agentClient, modelClient, creationSync, time.Duration(cfg.WorkflowTaskTimeoutSec)*time.Second, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,66 @@ type ProjectPlaneTodoInput struct {
|
|||
// without calling the provider, so a Plane Todo is never produced for an
|
||||
// unmatched or unpushed Milestone.
|
||||
func ProjectPlaneTodo(ctx context.Context, p PlaneTodoProvider, in ProjectPlaneTodoInput) error {
|
||||
if err := ValidateProjectionInput(p, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Preserve the original Plane body as a comment before anything mutates it.
|
||||
if err := PreserveOriginalComment(ctx, p, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Replace title/body with the develop Milestone projection.
|
||||
if err := UpdatePlaneBody(ctx, p, in); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Move the work item to the Todo state.
|
||||
return MovePlaneTodo(ctx, p, in)
|
||||
}
|
||||
|
||||
// PreserveOriginalComment is step 1 of the Plane Todo projection: it preserves
|
||||
// the original Plane body as a `사용자 요청:` comment before any later step
|
||||
// overwrites the title/body. It is split out of ProjectPlaneTodo so an
|
||||
// orchestrator can record a per-step ledger mark right after the provider call
|
||||
// succeeds and skip the step on a resume. Inputs are not re-validated here;
|
||||
// ProjectPlaneTodo and the orchestrator validate before calling.
|
||||
func PreserveOriginalComment(ctx context.Context, p workitem.Commenter, in ProjectPlaneTodoInput) error {
|
||||
return p.AppendComment(ctx, workitem.CommentInput{
|
||||
Ref: in.Ref,
|
||||
Body: preserveCommentPrefix + " " + in.OriginalBody,
|
||||
Format: workitem.CommentFormatHTML,
|
||||
ExternalSource: in.ExternalSource,
|
||||
ExternalID: in.ExternalID,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePlaneBody is step 2 of the Plane Todo projection: it replaces the work
|
||||
// item title/body with the develop Milestone projection. Split out for the same
|
||||
// per-step ledger reason as PreserveOriginalComment.
|
||||
func UpdatePlaneBody(ctx context.Context, p workitem.BodyProjector, in ProjectPlaneTodoInput) error {
|
||||
projection := FormatPlaneProjection(in.Candidate.Identity.RoadmapMilestonePath, in.MilestoneMarkdown)
|
||||
return p.ProjectBody(ctx, workitem.BodyProjection{
|
||||
Ref: in.Ref,
|
||||
Title: projection.Title,
|
||||
DescriptionHTML: projection.BodyHTML,
|
||||
})
|
||||
}
|
||||
|
||||
// MovePlaneTodo is step 3 of the Plane Todo projection: it moves the work item
|
||||
// to the Todo state. Split out for the same per-step ledger reason as
|
||||
// PreserveOriginalComment.
|
||||
func MovePlaneTodo(ctx context.Context, p workitem.StatusProjector, in ProjectPlaneTodoInput) error {
|
||||
return p.ProjectStatus(ctx, workitem.StatusProjection{
|
||||
Ref: in.Ref,
|
||||
ProviderState: in.TodoStateID,
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateProjectionInput reports the same input guard ProjectPlaneTodo
|
||||
// enforces, so an orchestrator that drives the steps individually rejects the
|
||||
// same missing-input cases before calling any provider facet.
|
||||
func ValidateProjectionInput(p PlaneTodoProvider, in ProjectPlaneTodoInput) error {
|
||||
if !in.Candidate.Ready {
|
||||
return ErrNotReady
|
||||
}
|
||||
|
|
@ -71,32 +131,5 @@ func ProjectPlaneTodo(ctx context.Context, p PlaneTodoProvider, in ProjectPlaneT
|
|||
strings.TrimSpace(in.MilestoneMarkdown) == "" {
|
||||
return ErrInvalidProjectionInput
|
||||
}
|
||||
|
||||
projection := FormatPlaneProjection(in.Candidate.Identity.RoadmapMilestonePath, in.MilestoneMarkdown)
|
||||
|
||||
// 1. Preserve the original Plane body as a comment before anything mutates it.
|
||||
if err := p.AppendComment(ctx, workitem.CommentInput{
|
||||
Ref: in.Ref,
|
||||
Body: preserveCommentPrefix + " " + in.OriginalBody,
|
||||
Format: workitem.CommentFormatHTML,
|
||||
ExternalSource: in.ExternalSource,
|
||||
ExternalID: in.ExternalID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Replace title/body with the develop Milestone projection.
|
||||
if err := p.ProjectBody(ctx, workitem.BodyProjection{
|
||||
Ref: in.Ref,
|
||||
Title: projection.Title,
|
||||
DescriptionHTML: projection.BodyHTML,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Move the work item to the Todo state.
|
||||
return p.ProjectStatus(ctx, workitem.StatusProjection{
|
||||
Ref: in.Ref,
|
||||
ProviderState: in.TodoStateID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
319
services/core/internal/roadmapsyncpipeline/service.go
Normal file
319
services/core/internal/roadmapsyncpipeline/service.go
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// Package roadmapsyncpipeline owns the runtime side-effect orchestration that
|
||||
// drives a Plane-origin Milestone creation sync from a develop scan result all
|
||||
// the way to the projected Plane Todo. The provider-neutral decisions live in
|
||||
// internal/roadmapsync (match, reconcile, projection formatting) and the
|
||||
// persisted identity/step ledger lives in internal/storage; this package is the
|
||||
// caller that wires them together and records a per-step ledger mark right after
|
||||
// each provider side effect succeeds, so a retry resumes only the remaining
|
||||
// steps.
|
||||
package roadmapsyncpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/nomadcode/nomadcode-core/internal/db"
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
||||
"github.com/nomadcode/nomadcode-core/internal/storage"
|
||||
"github.com/nomadcode/nomadcode-core/internal/workitem"
|
||||
)
|
||||
|
||||
// SyncAction is the typed outcome of a single SyncCreation call. It mirrors the
|
||||
// reconcile decision and the develop match gate so a worker can log and branch
|
||||
// without re-deriving the cause.
|
||||
type SyncAction string
|
||||
|
||||
const (
|
||||
// SyncActionNotReady means the develop scan did not match the expected work
|
||||
// item identity, so no provider side effect ran.
|
||||
SyncActionNotReady SyncAction = "not_ready"
|
||||
// SyncActionComplete means every creation-cycle step is already recorded;
|
||||
// nothing was projected this call.
|
||||
SyncActionComplete SyncAction = "complete"
|
||||
// SyncActionConflict means the trigger disagreed with the persisted identity
|
||||
// (revision or identity-key mismatch); a human/upstream decision is required
|
||||
// rather than a silent overwrite, so no provider side effect ran.
|
||||
SyncActionConflict SyncAction = "conflict"
|
||||
// SyncActionSkipSelfMutation means the trigger was NomadCode's own mutation,
|
||||
// so it is not treated as a new authoring trigger and no side effect ran.
|
||||
SyncActionSkipSelfMutation SyncAction = "skip_self_mutation"
|
||||
// SyncActionProjected means the remaining steps ran and each was recorded in
|
||||
// the ledger.
|
||||
SyncActionProjected SyncAction = "projected"
|
||||
)
|
||||
|
||||
// Store is the persistence seam SyncCreation needs: find or upsert the identity
|
||||
// row, read the completed-step set, and mark a step completed. It is satisfied
|
||||
// by *storage.Store and by test fakes so the orchestrator stays unit-testable
|
||||
// without a database.
|
||||
type Store interface {
|
||||
GetRoadmapSyncIdentityByWorkItem(ctx context.Context, args db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error)
|
||||
UpsertRoadmapSyncIdentity(ctx context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error)
|
||||
CompletedSteps(ctx context.Context, identityID int64) (map[storage.RoadmapSyncStep]bool, error)
|
||||
MarkStepCompleted(ctx context.Context, identityID int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error)
|
||||
}
|
||||
|
||||
// Service drives the creation sync orchestration against a Store and the Plane
|
||||
// Todo provider facets.
|
||||
type Service struct {
|
||||
store Store
|
||||
provider roadmapsync.PlaneTodoProvider
|
||||
// selfActor identifies NomadCode's own provider account so a mutation it made
|
||||
// is not re-interpreted as a fresh user trigger.
|
||||
selfActor string
|
||||
}
|
||||
|
||||
// NewService builds the orchestrator. provider is the Plane adapter (or a fake)
|
||||
// that satisfies the comment/body/status facets; selfActor is the provider
|
||||
// account NomadCode mutates as.
|
||||
func NewService(store Store, provider roadmapsync.PlaneTodoProvider, selfActor string) *Service {
|
||||
return &Service{store: store, provider: provider, selfActor: selfActor}
|
||||
}
|
||||
|
||||
// SyncCreationInput carries everything one creation sync needs without reaching
|
||||
// into provider DTOs: the develop scan result and the expected work item
|
||||
// identity (the develop match inputs), the Plane work item ref, the original
|
||||
// Plane body to preserve, the Todo state id to move to, the develop Milestone
|
||||
// markdown to project, the actor that caused the trigger, the provider/roadmap
|
||||
// revisions the trigger observed, and the external source/id tagging the
|
||||
// preservation comment.
|
||||
type SyncCreationInput struct {
|
||||
Scan roadmapsync.ScanResult
|
||||
Expected roadmapsync.Identity
|
||||
|
||||
Ref workitem.Ref
|
||||
OriginalBody string
|
||||
TodoStateID string
|
||||
MilestoneMarkdown string
|
||||
|
||||
Actor string
|
||||
ProviderRevision string
|
||||
RoadmapRevision string
|
||||
|
||||
ExternalSource string
|
||||
ExternalID string
|
||||
}
|
||||
|
||||
// SyncCreationResult is the typed outcome a worker logs and branches on. Reason
|
||||
// is always populated so a non-projected result is never silent. NextStep is set
|
||||
// only for SyncActionProjected and names the last step the orchestrator
|
||||
// recorded.
|
||||
type SyncCreationResult struct {
|
||||
Action SyncAction
|
||||
Reason string
|
||||
NextStep roadmapsync.Step
|
||||
}
|
||||
|
||||
// SyncCreation runs one creation sync cycle: gate on the develop match, load or
|
||||
// upsert the identity row, reconcile against the persisted step ledger, and —
|
||||
// only when the reconcile says resume — run the remaining provider steps in
|
||||
// order, recording a ledger mark right after each provider call succeeds. A
|
||||
// not-ready/conflict/self-mutation/complete decision returns without any
|
||||
// provider side effect.
|
||||
func (s *Service) SyncCreation(ctx context.Context, in SyncCreationInput) (SyncCreationResult, error) {
|
||||
candidate := roadmapsync.MatchDevelopMilestone(roadmapsync.MatchInput{
|
||||
Scan: in.Scan,
|
||||
Expected: in.Expected,
|
||||
})
|
||||
if !candidate.Ready {
|
||||
return SyncCreationResult{Action: SyncActionNotReady, Reason: candidate.Reason}, nil
|
||||
}
|
||||
|
||||
projectionInput := roadmapsync.ProjectPlaneTodoInput{
|
||||
Candidate: candidate,
|
||||
Ref: in.Ref,
|
||||
OriginalBody: in.OriginalBody,
|
||||
TodoStateID: in.TodoStateID,
|
||||
MilestoneMarkdown: in.MilestoneMarkdown,
|
||||
ExternalSource: in.ExternalSource,
|
||||
ExternalID: in.ExternalID,
|
||||
}
|
||||
// Reject missing projection inputs before any identity write or provider call,
|
||||
// matching ProjectPlaneTodo's guard.
|
||||
if err := roadmapsync.ValidateProjectionInput(s.provider, projectionInput); err != nil {
|
||||
return SyncCreationResult{}, err
|
||||
}
|
||||
|
||||
identityRow, completed, err := s.loadOrUpsertIdentityAndSteps(ctx, in, candidate)
|
||||
if err != nil {
|
||||
return SyncCreationResult{}, err
|
||||
}
|
||||
|
||||
decision := roadmapsync.ReconcileCreationCycle(roadmapsync.ReconcileInput{
|
||||
Identity: candidate.Identity,
|
||||
ProviderRevision: in.ProviderRevision,
|
||||
RoadmapRevision: in.RoadmapRevision,
|
||||
Actor: in.Actor,
|
||||
SelfActor: s.selfActor,
|
||||
Existing: existingFromRow(identityRow),
|
||||
CompletedSteps: toReconcileSteps(completed),
|
||||
})
|
||||
|
||||
switch decision.Action {
|
||||
case roadmapsync.ActionComplete:
|
||||
return SyncCreationResult{Action: SyncActionComplete, Reason: decision.Reason}, nil
|
||||
case roadmapsync.ActionConflict:
|
||||
return SyncCreationResult{Action: SyncActionConflict, Reason: decision.Reason}, nil
|
||||
case roadmapsync.ActionSkipSelfMutation:
|
||||
return SyncCreationResult{Action: SyncActionSkipSelfMutation, Reason: decision.Reason}, nil
|
||||
case roadmapsync.ActionResume:
|
||||
// fall through to step execution below.
|
||||
default:
|
||||
return SyncCreationResult{}, errors.New("roadmapsyncpipeline: unexpected reconcile action " + string(decision.Action))
|
||||
}
|
||||
|
||||
lastStep, err := s.runRemainingSteps(ctx, identityRow.ID, decision.NextStep, completed, projectionInput)
|
||||
if err != nil {
|
||||
return SyncCreationResult{}, err
|
||||
}
|
||||
return SyncCreationResult{Action: SyncActionProjected, Reason: decision.Reason, NextStep: lastStep}, nil
|
||||
}
|
||||
|
||||
// loadOrUpsertIdentityAndSteps finds the identity row by provider work item and
|
||||
// upserts a fresh row when none exists, then reads its completed-step set. The
|
||||
// upsert reuses storage's atomic conflict handling so a concurrent re-process
|
||||
// cannot rebind one identity onto another.
|
||||
func (s *Service) loadOrUpsertIdentityAndSteps(ctx context.Context, in SyncCreationInput, candidate roadmapsync.ProjectionCandidate) (db.RoadmapSyncIdentity, map[storage.RoadmapSyncStep]bool, error) {
|
||||
identity := candidate.Identity
|
||||
|
||||
row, err := s.store.GetRoadmapSyncIdentityByWorkItem(ctx, db.GetRoadmapSyncIdentityByWorkItemParams{
|
||||
Provider: string(identity.Provider),
|
||||
Tenant: identity.Tenant,
|
||||
Project: identity.Project,
|
||||
WorkItemID: identity.WorkItemID,
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, storage.ErrRoadmapSyncIdentityNotFound) {
|
||||
return db.RoadmapSyncIdentity{}, nil, err
|
||||
}
|
||||
row, err = s.store.UpsertRoadmapSyncIdentity(ctx, upsertParams(identity, in))
|
||||
if err != nil {
|
||||
return db.RoadmapSyncIdentity{}, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
completed, err := s.store.CompletedSteps(ctx, row.ID)
|
||||
if err != nil {
|
||||
return db.RoadmapSyncIdentity{}, nil, err
|
||||
}
|
||||
return row, completed, nil
|
||||
}
|
||||
|
||||
// runRemainingSteps executes the creation-cycle steps from nextStep onward,
|
||||
// skipping any already in the completed set, and records a ledger mark right
|
||||
// after each provider call succeeds. A provider failure stops the sequence so a
|
||||
// later step never runs on an unpreserved/unupdated ticket, and the ledger mark
|
||||
// is never written for a step whose provider call did not succeed. It returns
|
||||
// the last step it recorded.
|
||||
func (s *Service) runRemainingSteps(ctx context.Context, identityID int64, nextStep roadmapsync.Step, completed map[storage.RoadmapSyncStep]bool, projectionInput roadmapsync.ProjectPlaneTodoInput) (roadmapsync.Step, error) {
|
||||
var last roadmapsync.Step
|
||||
resuming := false
|
||||
for _, step := range roadmapsync.OrderedSteps {
|
||||
if step == nextStep {
|
||||
resuming = true
|
||||
}
|
||||
if !resuming {
|
||||
continue
|
||||
}
|
||||
if completed[toStorageStep(step)] {
|
||||
continue
|
||||
}
|
||||
if err := s.runStep(ctx, step, projectionInput); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := s.store.MarkStepCompleted(ctx, identityID, toStorageStep(step)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
last = step
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
// runStep performs the provider side effect for a single creation-cycle step.
|
||||
// StepDevelopMatched has no provider side effect — it only records that the
|
||||
// develop match gate passed — so it is a no-op here and the caller still records
|
||||
// its ledger mark.
|
||||
func (s *Service) runStep(ctx context.Context, step roadmapsync.Step, in roadmapsync.ProjectPlaneTodoInput) error {
|
||||
switch step {
|
||||
case roadmapsync.StepDevelopMatched:
|
||||
return nil
|
||||
case roadmapsync.StepOriginalCommentPreserved:
|
||||
return roadmapsync.PreserveOriginalComment(ctx, s.provider, in)
|
||||
case roadmapsync.StepPlaneBodyUpdated:
|
||||
return roadmapsync.UpdatePlaneBody(ctx, s.provider, in)
|
||||
case roadmapsync.StepPlaneTodoMoved:
|
||||
return roadmapsync.MovePlaneTodo(ctx, s.provider, in)
|
||||
default:
|
||||
return errors.New("roadmapsyncpipeline: unknown creation cycle step " + string(step))
|
||||
}
|
||||
}
|
||||
|
||||
// upsertParams maps the matched identity and trigger revisions to the storage
|
||||
// upsert parameters. Empty revisions are stored as NULL so a first cycle that
|
||||
// has not observed a revision is not treated as a conflicting empty string.
|
||||
func upsertParams(identity roadmapsync.Identity, in SyncCreationInput) db.UpsertRoadmapSyncIdentityByWorkItemParams {
|
||||
return db.UpsertRoadmapSyncIdentityByWorkItemParams{
|
||||
Shape: string(identity.Shape),
|
||||
RoadmapMilestonePath: identity.RoadmapMilestonePath,
|
||||
RoadmapItemID: identity.RoadmapItemID,
|
||||
Provider: string(identity.Provider),
|
||||
Tenant: identity.Tenant,
|
||||
Project: identity.Project,
|
||||
WorkItemID: identity.WorkItemID,
|
||||
ParentWorkItemID: identity.ParentWorkItemID,
|
||||
ProviderRevision: nullableString(in.ProviderRevision),
|
||||
RoadmapRevision: nullableString(in.RoadmapRevision),
|
||||
}
|
||||
}
|
||||
|
||||
// existingFromRow maps a persisted identity row to the reconcile's
|
||||
// provider-neutral existing-identity view.
|
||||
func existingFromRow(row db.RoadmapSyncIdentity) roadmapsync.ExistingIdentity {
|
||||
return roadmapsync.ExistingIdentity{
|
||||
Found: row.ID != 0,
|
||||
Identity: roadmapsync.Identity{
|
||||
Shape: roadmapsync.Shape(row.Shape),
|
||||
RoadmapMilestonePath: row.RoadmapMilestonePath,
|
||||
RoadmapItemID: row.RoadmapItemID,
|
||||
Provider: workitem.ProviderID(row.Provider),
|
||||
Tenant: row.Tenant,
|
||||
Project: row.Project,
|
||||
WorkItemID: row.WorkItemID,
|
||||
ParentWorkItemID: row.ParentWorkItemID,
|
||||
},
|
||||
ProviderRevision: derefString(row.ProviderRevision),
|
||||
RoadmapRevision: derefString(row.RoadmapRevision),
|
||||
}
|
||||
}
|
||||
|
||||
// toReconcileSteps maps the storage step set to the reconcile step set.
|
||||
func toReconcileSteps(completed map[storage.RoadmapSyncStep]bool) map[roadmapsync.Step]bool {
|
||||
out := make(map[roadmapsync.Step]bool, len(completed))
|
||||
for step, done := range completed {
|
||||
if done {
|
||||
out[roadmapsync.Step(step)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toStorageStep maps a reconcile/domain step to the storage step. The two enums
|
||||
// share the same string values; the helper keeps the conversion explicit at the
|
||||
// package boundary.
|
||||
func toStorageStep(step roadmapsync.Step) storage.RoadmapSyncStep {
|
||||
return storage.RoadmapSyncStep(step)
|
||||
}
|
||||
|
||||
func nullableString(v string) *string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func derefString(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
308
services/core/internal/roadmapsyncpipeline/service_test.go
Normal file
308
services/core/internal/roadmapsyncpipeline/service_test.go
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
package roadmapsyncpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/nomadcode/nomadcode-core/internal/db"
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
||||
"github.com/nomadcode/nomadcode-core/internal/storage"
|
||||
"github.com/nomadcode/nomadcode-core/internal/workitem"
|
||||
)
|
||||
|
||||
const (
|
||||
testMilestonePath = "agent-roadmap/phase/p1/milestones/m1.md"
|
||||
testRevision = "rev-abc"
|
||||
)
|
||||
|
||||
// fakeProvider records the order of provider facet calls and can fail a chosen
|
||||
// step so a stop-before-later-step test is possible.
|
||||
type fakeProvider struct {
|
||||
calls []string
|
||||
failOn string
|
||||
failErr error
|
||||
}
|
||||
|
||||
func (f *fakeProvider) AppendComment(_ context.Context, _ workitem.CommentInput) error {
|
||||
f.calls = append(f.calls, "comment")
|
||||
if f.failOn == "comment" {
|
||||
return f.failErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeProvider) ProjectBody(_ context.Context, _ workitem.BodyProjection) error {
|
||||
f.calls = append(f.calls, "body")
|
||||
if f.failOn == "body" {
|
||||
return f.failErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeProvider) ProjectStatus(_ context.Context, _ workitem.StatusProjection) error {
|
||||
f.calls = append(f.calls, "status")
|
||||
if f.failOn == "status" {
|
||||
return f.failErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeStore is an in-memory Store. identity is the single persisted row (id 0
|
||||
// means "not found"); marked records the steps MarkStepCompleted was called
|
||||
// with, in order.
|
||||
type fakeStore struct {
|
||||
identity db.RoadmapSyncIdentity
|
||||
found bool
|
||||
completed map[storage.RoadmapSyncStep]bool
|
||||
marked []storage.RoadmapSyncStep
|
||||
upserted bool
|
||||
markErr error
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{completed: map[storage.RoadmapSyncStep]bool{}}
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetRoadmapSyncIdentityByWorkItem(_ context.Context, _ db.GetRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) {
|
||||
if !f.found {
|
||||
return db.RoadmapSyncIdentity{}, storage.ErrRoadmapSyncIdentityNotFound
|
||||
}
|
||||
return f.identity, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpsertRoadmapSyncIdentity(_ context.Context, args db.UpsertRoadmapSyncIdentityByWorkItemParams) (db.RoadmapSyncIdentity, error) {
|
||||
f.upserted = true
|
||||
f.identity = db.RoadmapSyncIdentity{
|
||||
ID: 1,
|
||||
Shape: args.Shape,
|
||||
RoadmapMilestonePath: args.RoadmapMilestonePath,
|
||||
Provider: args.Provider,
|
||||
Tenant: args.Tenant,
|
||||
Project: args.Project,
|
||||
WorkItemID: args.WorkItemID,
|
||||
ProviderRevision: args.ProviderRevision,
|
||||
RoadmapRevision: args.RoadmapRevision,
|
||||
}
|
||||
f.found = true
|
||||
return f.identity, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CompletedSteps(_ context.Context, _ int64) (map[storage.RoadmapSyncStep]bool, error) {
|
||||
out := make(map[storage.RoadmapSyncStep]bool, len(f.completed))
|
||||
for k, v := range f.completed {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) MarkStepCompleted(_ context.Context, _ int64, step storage.RoadmapSyncStep) (db.RoadmapSyncStep, error) {
|
||||
if f.markErr != nil {
|
||||
return db.RoadmapSyncStep{}, f.markErr
|
||||
}
|
||||
f.marked = append(f.marked, step)
|
||||
f.completed[step] = true
|
||||
return db.RoadmapSyncStep{Step: string(step)}, nil
|
||||
}
|
||||
|
||||
func readyInput() SyncCreationInput {
|
||||
identity := roadmapsync.Identity{
|
||||
Shape: roadmapsync.ShapeMilestone,
|
||||
RoadmapMilestonePath: testMilestonePath,
|
||||
Provider: "plane",
|
||||
Tenant: "general",
|
||||
Project: "nomad",
|
||||
WorkItemID: "NOMAD-1",
|
||||
}
|
||||
return SyncCreationInput{
|
||||
Scan: roadmapsync.ScanResult{
|
||||
Revision: roadmapsync.Revision{Branch: "develop", Revision: testRevision},
|
||||
ChangedFiles: []string{testMilestonePath},
|
||||
ScannedMilestones: []roadmapsync.ScannedMilestone{
|
||||
{Path: testMilestonePath, Identity: identity},
|
||||
},
|
||||
},
|
||||
Expected: identity,
|
||||
Ref: workitem.Ref{Provider: "plane", Tenant: "general", Project: "nomad", ID: "NOMAD-1"},
|
||||
OriginalBody: "<p>user request</p>",
|
||||
TodoStateID: "todo-state",
|
||||
MilestoneMarkdown: "# Milestone\n\nbody",
|
||||
ProviderRevision: testRevision,
|
||||
RoadmapRevision: testRevision,
|
||||
ExternalSource: "nomadcode",
|
||||
ExternalID: "sync-1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncCreationNotReadyDoesNotCallProvider(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
provider := &fakeProvider{}
|
||||
svc := NewService(store, provider, "")
|
||||
|
||||
in := readyInput()
|
||||
// Break the develop match: no changed milestone file.
|
||||
in.Scan.ChangedFiles = nil
|
||||
|
||||
res, err := svc.SyncCreation(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncCreation returned error: %v", err)
|
||||
}
|
||||
if res.Action != SyncActionNotReady {
|
||||
t.Fatalf("expected not_ready, got %s (%s)", res.Action, res.Reason)
|
||||
}
|
||||
if len(provider.calls) != 0 {
|
||||
t.Fatalf("expected no provider calls, got %v", provider.calls)
|
||||
}
|
||||
if len(store.marked) != 0 || store.upserted {
|
||||
t.Fatalf("expected no ledger marks or upsert, marked=%v upserted=%v", store.marked, store.upserted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncCreationReadyRunsStepsAndMarksLedger(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
provider := &fakeProvider{}
|
||||
svc := NewService(store, provider, "")
|
||||
|
||||
res, err := svc.SyncCreation(context.Background(), readyInput())
|
||||
if err != nil {
|
||||
t.Fatalf("SyncCreation returned error: %v", err)
|
||||
}
|
||||
if res.Action != SyncActionProjected {
|
||||
t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason)
|
||||
}
|
||||
|
||||
wantProviderOrder := []string{"comment", "body", "status"}
|
||||
if !equalStrings(provider.calls, wantProviderOrder) {
|
||||
t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder)
|
||||
}
|
||||
|
||||
wantLedger := []storage.RoadmapSyncStep{
|
||||
storage.StepDevelopMatched,
|
||||
storage.StepOriginalCommentPreserved,
|
||||
storage.StepPlaneBodyUpdated,
|
||||
storage.StepPlaneTodoMoved,
|
||||
}
|
||||
if !equalSteps(store.marked, wantLedger) {
|
||||
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
|
||||
}
|
||||
if res.NextStep != roadmapsync.StepPlaneTodoMoved {
|
||||
t.Fatalf("expected last step plane_todo_moved, got %s", res.NextStep)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncCreationRetrySkipsCompletedSteps(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
// Identity already exists with the first two steps recorded.
|
||||
store.found = true
|
||||
store.identity = db.RoadmapSyncIdentity{
|
||||
ID: 1,
|
||||
Shape: string(roadmapsync.ShapeMilestone),
|
||||
RoadmapMilestonePath: testMilestonePath,
|
||||
Provider: "plane",
|
||||
Tenant: "general",
|
||||
Project: "nomad",
|
||||
WorkItemID: "NOMAD-1",
|
||||
}
|
||||
store.completed[storage.StepDevelopMatched] = true
|
||||
store.completed[storage.StepOriginalCommentPreserved] = true
|
||||
|
||||
provider := &fakeProvider{}
|
||||
svc := NewService(store, provider, "")
|
||||
|
||||
res, err := svc.SyncCreation(context.Background(), readyInput())
|
||||
if err != nil {
|
||||
t.Fatalf("SyncCreation returned error: %v", err)
|
||||
}
|
||||
if res.Action != SyncActionProjected {
|
||||
t.Fatalf("expected projected, got %s (%s)", res.Action, res.Reason)
|
||||
}
|
||||
if store.upserted {
|
||||
t.Fatal("expected no upsert when identity already exists")
|
||||
}
|
||||
|
||||
// Comment was already preserved; only body+status should run again.
|
||||
wantProviderOrder := []string{"body", "status"}
|
||||
if !equalStrings(provider.calls, wantProviderOrder) {
|
||||
t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder)
|
||||
}
|
||||
wantLedger := []storage.RoadmapSyncStep{
|
||||
storage.StepPlaneBodyUpdated,
|
||||
storage.StepPlaneTodoMoved,
|
||||
}
|
||||
if !equalSteps(store.marked, wantLedger) {
|
||||
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncCreationStopsBeforeLaterStepOnProviderError(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
bodyErr := errors.New("plane body projection failed")
|
||||
provider := &fakeProvider{failOn: "body", failErr: bodyErr}
|
||||
svc := NewService(store, provider, "")
|
||||
|
||||
_, err := svc.SyncCreation(context.Background(), readyInput())
|
||||
if !errors.Is(err, bodyErr) {
|
||||
t.Fatalf("expected body error, got %v", err)
|
||||
}
|
||||
|
||||
// Comment ran and was marked; body ran but failed; status must not run.
|
||||
wantProviderOrder := []string{"comment", "body"}
|
||||
if !equalStrings(provider.calls, wantProviderOrder) {
|
||||
t.Fatalf("provider call order = %v, want %v", provider.calls, wantProviderOrder)
|
||||
}
|
||||
// develop_matched + original_comment_preserved recorded; body never marked.
|
||||
wantLedger := []storage.RoadmapSyncStep{
|
||||
storage.StepDevelopMatched,
|
||||
storage.StepOriginalCommentPreserved,
|
||||
}
|
||||
if !equalSteps(store.marked, wantLedger) {
|
||||
t.Fatalf("ledger marks = %v, want %v", store.marked, wantLedger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncCreationSkipsSelfMutation(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
provider := &fakeProvider{}
|
||||
svc := NewService(store, provider, "nomadcode-bot")
|
||||
|
||||
in := readyInput()
|
||||
in.Actor = "nomadcode-bot"
|
||||
|
||||
res, err := svc.SyncCreation(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncCreation returned error: %v", err)
|
||||
}
|
||||
if res.Action != SyncActionSkipSelfMutation {
|
||||
t.Fatalf("expected skip_self_mutation, got %s (%s)", res.Action, res.Reason)
|
||||
}
|
||||
if len(provider.calls) != 0 {
|
||||
t.Fatalf("expected no provider calls on self mutation, got %v", provider.calls)
|
||||
}
|
||||
if len(store.marked) != 0 {
|
||||
t.Fatalf("expected no ledger marks on self mutation, got %v", store.marked)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalSteps(a, b []storage.RoadmapSyncStep) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -23,7 +23,11 @@ type Client struct {
|
|||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, lifecycle TaskLifecycle, notifications *notification.Service, agentClient agent.Client, modelClient model.Client, runTimeout time.Duration, logger *slog.Logger) (*Client, error) {
|
||||
// New builds the scheduler client. creationSync is the optional Plane-origin
|
||||
// Milestone creation sync orchestrator: when non-nil its worker is registered so
|
||||
// EnqueueRoadmapCreationSync has a runtime worker; when nil only the task worker
|
||||
// is registered, preserving the default task scheduler behavior.
|
||||
func New(pool *pgxpool.Pool, lifecycle TaskLifecycle, notifications *notification.Service, agentClient agent.Client, modelClient model.Client, creationSync CreationSyncRunner, runTimeout time.Duration, logger *slog.Logger) (*Client, error) {
|
||||
workers := river.NewWorkers()
|
||||
river.AddWorker(workers, &TaskWorker{
|
||||
Lifecycle: lifecycle,
|
||||
|
|
@ -33,6 +37,12 @@ func New(pool *pgxpool.Pool, lifecycle TaskLifecycle, notifications *notificatio
|
|||
RunTimeout: runTimeout,
|
||||
Logger: logger,
|
||||
})
|
||||
if creationSync != nil {
|
||||
river.AddWorker(workers, &RoadmapCreationSyncWorker{
|
||||
Sync: creationSync,
|
||||
Logger: logger,
|
||||
})
|
||||
}
|
||||
|
||||
driver := riverpgxv5.New(pool)
|
||||
riverClient, err := river.NewClient[pgx.Tx](driver, &river.Config{
|
||||
|
|
@ -85,3 +95,12 @@ func (c *Client) EnqueueTask(ctx context.Context, taskID string) error {
|
|||
_, err := c.river.Insert(ctx, TaskJobArgs{TaskID: taskID}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnqueueRoadmapCreationSync enqueues a Plane-origin Milestone creation sync
|
||||
// job. It is the internal seam the develop-scan handoff calls to hand a matched
|
||||
// scan result to the orchestrator worker; it is intentionally separate from
|
||||
// EnqueueTask and is not exposed through a public HTTP route.
|
||||
func (c *Client) EnqueueRoadmapCreationSync(ctx context.Context, args RoadmapCreationSyncJobArgs) error {
|
||||
_, err := c.river.Insert(ctx, args, nil)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
120
services/core/internal/scheduler/river_test.go
Normal file
120
services/core/internal/scheduler/river_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/riverqueue/river"
|
||||
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline"
|
||||
"github.com/nomadcode/nomadcode-core/internal/workitem"
|
||||
)
|
||||
|
||||
// fakeCreationSyncRunner records the input it was driven with so a worker test
|
||||
// can assert the job args were mapped into the orchestrator input.
|
||||
type fakeCreationSyncRunner struct {
|
||||
called bool
|
||||
input roadmapsyncpipeline.SyncCreationInput
|
||||
result roadmapsyncpipeline.SyncCreationResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeCreationSyncRunner) SyncCreation(_ context.Context, in roadmapsyncpipeline.SyncCreationInput) (roadmapsyncpipeline.SyncCreationResult, error) {
|
||||
f.called = true
|
||||
f.input = in
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func sampleCreationSyncArgs() RoadmapCreationSyncJobArgs {
|
||||
identity := roadmapsync.Identity{
|
||||
Shape: roadmapsync.ShapeMilestone,
|
||||
RoadmapMilestonePath: "agent-roadmap/phase/p1/milestones/m1.md",
|
||||
Provider: "plane",
|
||||
Tenant: "general",
|
||||
Project: "nomad",
|
||||
WorkItemID: "NOMAD-1",
|
||||
}
|
||||
return RoadmapCreationSyncJobArgs{
|
||||
Expected: identity,
|
||||
Ref: workitem.Ref{Provider: "plane", ID: "NOMAD-1"},
|
||||
OriginalBody: "<p>body</p>",
|
||||
TodoStateID: "todo-state",
|
||||
MilestoneMarkdown: "# m",
|
||||
ProviderRevision: "rev-1",
|
||||
RoadmapRevision: "rev-1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoadmapCreationSyncWorkerCallsService(t *testing.T) {
|
||||
runner := &fakeCreationSyncRunner{
|
||||
result: roadmapsyncpipeline.SyncCreationResult{Action: roadmapsyncpipeline.SyncActionProjected},
|
||||
}
|
||||
worker := &RoadmapCreationSyncWorker{Sync: runner}
|
||||
|
||||
args := sampleCreationSyncArgs()
|
||||
err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: args})
|
||||
if err != nil {
|
||||
t.Fatalf("Work returned error: %v", err)
|
||||
}
|
||||
if !runner.called {
|
||||
t.Fatal("expected orchestrator to be called")
|
||||
}
|
||||
if runner.input.Expected.WorkItemID != "NOMAD-1" {
|
||||
t.Errorf("unexpected work item id: %q", runner.input.Expected.WorkItemID)
|
||||
}
|
||||
if runner.input.MilestoneMarkdown != "# m" || runner.input.TodoStateID != "todo-state" {
|
||||
t.Errorf("job args not mapped into orchestrator input: %#v", runner.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoadmapCreationSyncWorkerReturnsServiceError(t *testing.T) {
|
||||
wantErr := errors.New("provider failed")
|
||||
runner := &fakeCreationSyncRunner{err: wantErr}
|
||||
worker := &RoadmapCreationSyncWorker{Sync: runner}
|
||||
|
||||
err := worker.Work(context.Background(), &river.Job[RoadmapCreationSyncJobArgs]{Args: sampleCreationSyncArgs()})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected service error to propagate (so River retries), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// lazyPool builds a pgxpool without dialing. pgxpool.New is lazy and only
|
||||
// connects on first acquire, so it is safe for constructing the scheduler in a
|
||||
// unit test that never starts the client.
|
||||
func lazyPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
pool, err := pgxpool.New(context.Background(), "postgres://user:pass@127.0.0.1:1/db")
|
||||
if err != nil {
|
||||
t.Fatalf("build lazy pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return pool
|
||||
}
|
||||
|
||||
func TestSchedulerRegistersRoadmapSyncWorkerWhenConfigured(t *testing.T) {
|
||||
runner := &fakeCreationSyncRunner{}
|
||||
client, err := New(lazyPool(t), nil, nil, nil, nil, runner, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New with creation sync runner: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
}
|
||||
// The enqueue seam exists regardless of registration; the worker registration
|
||||
// is exercised by New succeeding with a non-nil runner (River rejects an
|
||||
// unknown job kind only at insert/work time, so construction succeeding plus
|
||||
// the worker unit test above cover the wiring without a live DB).
|
||||
}
|
||||
|
||||
func TestSchedulerWithoutRoadmapSyncKeepsTaskWorkerOnlyBehavior(t *testing.T) {
|
||||
client, err := New(lazyPool(t), nil, nil, nil, nil, nil, 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New without creation sync runner: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil client")
|
||||
}
|
||||
}
|
||||
88
services/core/internal/scheduler/roadmap_sync_jobs.go
Normal file
88
services/core/internal/scheduler/roadmap_sync_jobs.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/riverqueue/river"
|
||||
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsync"
|
||||
"github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline"
|
||||
"github.com/nomadcode/nomadcode-core/internal/workitem"
|
||||
)
|
||||
|
||||
// RoadmapCreationSyncJobArgs is the River job that drives one Plane-origin
|
||||
// Milestone creation sync cycle. It carries the develop scan result and the
|
||||
// expected work item identity (the develop match inputs) plus the projection
|
||||
// inputs, so the worker can build the orchestrator input without reaching into
|
||||
// provider DTOs. It is enqueued by the internal develop-scan handoff via
|
||||
// Client.EnqueueRoadmapCreationSync, not by a public API.
|
||||
type RoadmapCreationSyncJobArgs struct {
|
||||
Scan roadmapsync.ScanResult `json:"scan"`
|
||||
Expected roadmapsync.Identity `json:"expected"`
|
||||
|
||||
Ref workitem.Ref `json:"ref"`
|
||||
OriginalBody string `json:"original_body"`
|
||||
TodoStateID string `json:"todo_state_id"`
|
||||
MilestoneMarkdown string `json:"milestone_markdown"`
|
||||
|
||||
Actor string `json:"actor,omitempty"`
|
||||
ProviderRevision string `json:"provider_revision,omitempty"`
|
||||
RoadmapRevision string `json:"roadmap_revision,omitempty"`
|
||||
|
||||
ExternalSource string `json:"external_source,omitempty"`
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
}
|
||||
|
||||
func (RoadmapCreationSyncJobArgs) Kind() string {
|
||||
return "roadmap_creation_sync"
|
||||
}
|
||||
|
||||
// CreationSyncRunner is the orchestrator seam the worker drives. It is satisfied
|
||||
// by *roadmapsyncpipeline.Service and by test fakes, so the worker stays
|
||||
// testable without a Plane client or database.
|
||||
type CreationSyncRunner interface {
|
||||
SyncCreation(ctx context.Context, in roadmapsyncpipeline.SyncCreationInput) (roadmapsyncpipeline.SyncCreationResult, error)
|
||||
}
|
||||
|
||||
// RoadmapCreationSyncWorker runs a creation sync job by delegating to the
|
||||
// orchestrator. Not-ready/conflict/self-mutation/complete are normal terminal
|
||||
// outcomes the orchestrator already guards with no provider side effect, so the
|
||||
// worker logs them and returns nil rather than failing and triggering a River
|
||||
// retry. Only an actual error (provider/storage failure) is returned so River
|
||||
// retries it.
|
||||
type RoadmapCreationSyncWorker struct {
|
||||
river.WorkerDefaults[RoadmapCreationSyncJobArgs]
|
||||
|
||||
Sync CreationSyncRunner
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
func (w *RoadmapCreationSyncWorker) Work(ctx context.Context, job *river.Job[RoadmapCreationSyncJobArgs]) error {
|
||||
args := job.Args
|
||||
res, err := w.Sync.SyncCreation(ctx, roadmapsyncpipeline.SyncCreationInput{
|
||||
Scan: args.Scan,
|
||||
Expected: args.Expected,
|
||||
Ref: args.Ref,
|
||||
OriginalBody: args.OriginalBody,
|
||||
TodoStateID: args.TodoStateID,
|
||||
MilestoneMarkdown: args.MilestoneMarkdown,
|
||||
Actor: args.Actor,
|
||||
ProviderRevision: args.ProviderRevision,
|
||||
RoadmapRevision: args.RoadmapRevision,
|
||||
ExternalSource: args.ExternalSource,
|
||||
ExternalID: args.ExternalID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w.Logger != nil {
|
||||
w.Logger.Info("roadmap creation sync completed",
|
||||
"work_item_id", args.Expected.WorkItemID,
|
||||
"milestone_path", args.Expected.RoadmapMilestonePath,
|
||||
"action", string(res.Action),
|
||||
"reason", res.Reason,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Reference in a new issue