diff --git a/agent-roadmap/ROADMAP.md b/agent-roadmap/ROADMAP.md index efcc4c8..4fedf88 100644 --- a/agent-roadmap/ROADMAP.md +++ b/agent-roadmap/ROADMAP.md @@ -11,15 +11,15 @@ Gito는 GitHub, GitLab, Gitea 같은 플랫폼에 묶이지 않는 Git control p 검토중 또는 진행중 Phase는 계획 Phase보다 위에 두어, 아래로 갈수록 미래 작업에 가까워지게 정렬한다. 스케치 Phase는 아직 구현 가능한 계획이 아니므로 계획 Phase보다 아래에 둔다. +- [진행중] Provider Change Request Integration + - 경로: `agent-roadmap/phase/provider-change-request-integration/PHASE.md` + - 요약: NomadCode branch event wakeup을 위한 outbound HTTP webhook delivery를 최우선으로 두고, GitHub PR, GitLab MR, Gitea PR 같은 provider 기능과 webhook delivery를 ChangeRequest 추상화와 adapter 구조로 통합한다. - [진행중] Control Plane Foundation - 경로: `agent-roadmap/phase/control-plane-foundation/PHASE.md` - 요약: platformless Git control plane의 모듈 경계, repo/workspace/operation/event 원장, 외부 HTTP webhook/REST와 내부 proto-socket 경계를 build 가능한 baseline으로 닫는다. -- [진행중] Agent Shell IOP Execution +- [계획] Agent Shell IOP Execution - 경로: `agent-roadmap/phase/agent-shell-iop-execution/PHASE.md` - - 요약: `../agent-shell`은 Flutter agent UI/package로 사용하고, backend/runtime owner는 `../iop`로 지정해 Gito Git control plane과 연결한다. -- [계획] Provider Change Request Integration - - 경로: `agent-roadmap/phase/provider-change-request-integration/PHASE.md` - - 요약: GitHub PR, GitLab MR, Gitea PR 같은 provider 기능과 webhook delivery를 ChangeRequest 추상화와 adapter 구조로 통합한다. + - 요약: `../agent-shell`은 Flutter agent UI/package로 사용하고, backend/runtime owner는 `../iop`로 지정해 Gito Git control plane과 연결한다. NomadCode webhook 대응 뒤 IOP bridge 보완을 재개한다. - [계획] Control Surface Operations UX - 경로: `agent-roadmap/phase/control-surface-operations-ux/PHASE.md` - 요약: Flutter control surface와 운영 관찰 화면을 통해 repository, operation, runner, event 흐름을 다룰 수 있게 한다. diff --git a/agent-roadmap/phase/agent-shell-iop-execution/PHASE.md b/agent-roadmap/phase/agent-shell-iop-execution/PHASE.md index 8010e01..de2b5a7 100644 --- a/agent-roadmap/phase/agent-shell-iop-execution/PHASE.md +++ b/agent-roadmap/phase/agent-shell-iop-execution/PHASE.md @@ -2,7 +2,7 @@ ## 상태 -[진행중] +[계획] ## 목표 @@ -19,9 +19,9 @@ IOP 같은 sibling/external runtime 연결은 HTTP dispatch/callback 모델로 - 경로: `agent-roadmap/archive/phase/agent-shell-iop-execution/milestones/agent-shell-package-iop-backend-boundary.md` - 요약: `../agent-shell`은 Flutter agent UI/package로 사용하고, backend/runtime owner는 `../iop` 프로젝트로 지정한다. -- [진행중] IOP Agent Run Bridge +- [계획] IOP Agent Run Bridge - 경로: `agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md` - - 요약: IOP backend가 수행한 agent run 결과를 Gito의 Git operation과 event 원장으로 수렴시키는 production bridge를 마무리한다. + - 요약: IOP backend가 수행한 agent run 결과를 Gito의 Git operation과 event 원장으로 수렴시키는 production bridge를 NomadCode webhook 대응 이후 재개한다. - [계획] Command Policy and Cancellation - 경로: `agent-roadmap/phase/agent-shell-iop-execution/milestones/command-policy-and-cancellation.md` diff --git a/agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md b/agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md index 70e6c15..c892be6 100644 --- a/agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md +++ b/agent-roadmap/phase/agent-shell-iop-execution/milestones/iop-agent-run-bridge.md @@ -11,7 +11,7 @@ ## 상태 -[진행중] +[계획] ## 승격 조건 @@ -85,6 +85,7 @@ agent run 결과를 Git revision과 operation event로 수렴한다. - 표준선(선택): IOP run 결과는 Git 상태와 operation event로 검증한다. Gito는 IOP CLI/secret 주입 세부를 직접 소유하지 않고 HTTP dispatch/callback과 `credential_ref`/`secret_ref` 경계만 다룬다. - 선행 작업: Agent Shell Package and IOP Backend Boundary - 후속 작업: Command Policy and Cancellation -- 확인 필요: production worker/runtime 연결 보완 후 완료 리뷰와 archive 승인 +- 확인 필요: NomadCode webhook 대응 후 production worker/runtime 연결 보완, 완료 리뷰, archive 승인 +- 우선순위 조정: 2026-06-17 NomadCode webhook 요청 대응을 최우선 작업으로 올리면서 이 Milestone은 `[계획]`으로 되돌렸다. - 방향성 정리: 외부 연결 규약에서 proto-socket을 제거하고 GitHub webhook과 유사한 HTTP webhook/callback 모델로 정렬했다. - 완료 근거: `[input]`과 `[state]`는 `packages/contracts/notes/gito-control-plane.md`의 `IOP Agent Run Bridge` 섹션에서 transport-independent `AgentRunInput`과 run state mapping으로 정리했다. `[invoke]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/01_iop_invocation_contract/complete.log`의 PASS Roadmap Completion으로 확인했다. `[result]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/02+01_git_result_convergence/complete.log`의 PASS Roadmap Completion으로 확인했다. `[dirty-guard]`는 `services/core/internal/worker/runner.go`의 dirty workspace failure policy와 `TestRunnerRunOnceAgentRunFailsWhenGitEvidenceIsDirty` 통과로 확인했다. `[commit-push]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/03_commit_push_policy/complete.log`의 PASS Roadmap Completion으로 확인했다. `[revision-event]`는 `agent-task/archive/2026/06/m-iop-agent-run-bridge/04+03_revision_scan_event/complete.log`의 PASS Roadmap Completion과 `TestRuntimeScanAgentRunRevisionPublishFailureDoesNotAdvanceDurableState`, `TestScanOperationEventAllowsNullOperationID`, `cd services/core && go test ./...` 통과로 확인했다. diff --git a/agent-roadmap/phase/provider-change-request-integration/PHASE.md b/agent-roadmap/phase/provider-change-request-integration/PHASE.md index 41420d2..2bbf816 100644 --- a/agent-roadmap/phase/provider-change-request-integration/PHASE.md +++ b/agent-roadmap/phase/provider-change-request-integration/PHASE.md @@ -2,11 +2,11 @@ ## 상태 -[계획] +[진행중] ## 목표 -GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral ChangeRequest 인터페이스로 묶는다. 외부 연동은 GitHub webhook과 유사한 HTTP webhook/callback 모델로 맞추고, Webhook은 빠른 wakeup signal로 사용하되 최종 상태 판정은 Git revision/diff와 provider read API로 재검증한다. +GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral ChangeRequest 인터페이스로 묶는다. 외부 연동은 GitHub webhook과 유사한 HTTP webhook/callback 모델로 맞추고, Webhook은 빠른 wakeup signal로 사용하되 최종 상태 판정은 Git revision/diff와 provider read API로 재검증한다. 현재 최우선 slice는 NomadCode가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 받을 수 있게 하는 것이다. ## Milestone 흐름 @@ -14,6 +14,10 @@ GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral Change 완료, 검토중, 진행중, 계획, 스케치 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. 스케치 Milestone은 아직 구현 가능한 계획이 아니므로 계획 Milestone보다 아래에 둔다. +- [진행중] Webhook Revision Reconcile + - 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` + - 요약: NomadCode branch event wakeup 대응을 최우선으로 두고 provider webhook, outbound consumer webhook, polling, Git revision scan을 같은 변경 event로 정규화하고 재검증한다. + - [계획] Provider Adapter Foundation - 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/provider-adapter-foundation.md` - 요약: provider registry, credential ref, webhook intake, provider capability boundary를 만든다. @@ -22,10 +26,6 @@ GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral Change - 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/change-request-abstraction.md` - 요약: GitHub PR, GitLab MR, Gitea PR을 공통 ChangeRequest action과 event로 표현한다. -- [계획] Webhook Revision Reconcile - - 경로: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` - - 요약: provider webhook, outbound consumer webhook, polling, Git revision scan을 같은 변경 event로 정규화하고 재검증한다. - ## Phase 경계 - Git 자체 조작은 provider adapter가 아니라 platformless Git engine이 맡는다. @@ -33,3 +33,4 @@ GitHub PR, GitLab MR, Gitea PR 같은 provider 기능을 provider-neutral Change - provider별 credential 원문은 tracked 파일에 저장하지 않고 credential ref로만 연결한다. - provider webhook만으로 상태를 확정하지 않고 Git revision 또는 provider read API로 확인한다. - 외부 consumer event delivery는 HTTP webhook/callback으로 제공하며 proto-socket을 외부 연결 규약이나 보조 transport로 제공하지 않는다. +- NomadCode branch event webhook delivery는 ChangeRequest/PR/MR abstraction을 기다리지 않는 우선 slice로 처리한다. diff --git a/agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md b/agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md index a783347..4f0faf5 100644 --- a/agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md +++ b/agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md @@ -7,11 +7,11 @@ ## 목표 -provider webhook, outbound consumer webhook, provider polling, Git revision scan을 같은 변경 입력으로 정규화한다. Webhook은 빠른 wakeup signal이지만 최종 판정은 Git revision/diff 또는 provider read API로 재검증해 중복 적용과 누락을 줄인다. 외부 event delivery는 GitHub webhook과 유사한 HTTP callback 방식으로 제공하고, proto-socket은 외부 연결 규약에서 제외한다. +NomadCode가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 받을 수 있게 하는 대응을 최우선으로 진행한다. 이후 provider webhook, outbound consumer webhook, provider polling, Git revision scan을 같은 변경 입력으로 정규화한다. Webhook은 빠른 wakeup signal이지만 최종 판정은 Git revision/diff 또는 provider read API로 재검증해 중복 적용과 누락을 줄인다. 외부 event delivery는 GitHub webhook과 유사한 HTTP callback 방식으로 제공하고, proto-socket은 외부 연결 규약에서 제외한다. ## 상태 -[계획] +[진행중] ## 승격 조건 @@ -20,14 +20,25 @@ provider webhook, outbound consumer webhook, provider polling, Git revision scan ## 구현 잠금 - 상태: 해제 +- SDD: 필요 +- SDD 문서: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md` +- 잠금 해제 조건: + - [x] SDD 잠금이 해제되어 있다. + - [x] SDD 사용자 리뷰가 없거나 승인/해결되었다. + - [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다. + - [x] Evidence Map이 plan의 `Spec Targets`와 완료 시 `Spec Completion`으로 검증 가능하게 연결되어 있다. - 결정 필요: 없음 +- 해소 근거: + - SDD 상태는 `[승인됨]`이고 SDD 잠금은 해제되어 있다. + - `agent-contract/provided/gito-forgejo-branch-events-v1.md`의 NomadCode interop 확인 기준을 그대로 따른다. ## 범위 -- webhook received event -- branch updated event +- NomadCode `branch.updated` consumer webhook wakeup interop - consumer webhook subscription과 HTTP delivery - delivery idempotency, signature, retry/backoff +- branch updated event +- webhook received event - provider polling fallback - revision mismatch conflict - idempotency key와 expected revision @@ -35,6 +46,15 @@ provider webhook, outbound consumer webhook, provider polling, Git revision scan ## 기능 +### Epic: [delivery] Webhook delivery + +정규화된 event를 NomadCode 같은 외부 consumer가 제공한 HTTP endpoint로 전달한다. + +- [ ] [subscription] consumer endpoint, event type, repo/branch filter, `secret_ref`를 등록하는 형식을 정의한다. +- [ ] [deliver] `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다. 검증: fake consumer endpoint가 delivery id와 payload를 수신한다. +- [ ] [nomadcode-wakeup] NomadCode dev consumer가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 매핑할 수 있는 interop path를 검증한다. 검증: `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1` +- [ ] [retry] delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다. + ### Epic: [sources] Change sources 여러 입력을 하나의 change event model로 정규화한다. @@ -43,14 +63,6 @@ provider webhook, outbound consumer webhook, provider polling, Git revision scan - [ ] [poll-source] provider polling fallback shape를 정의한다. - [ ] [git-source] Git revision scan event와 provider event를 같은 identity로 연결한다. -### Epic: [delivery] Webhook delivery - -정규화된 event를 외부 consumer가 제공한 HTTP endpoint로 전달한다. - -- [ ] [subscription] consumer endpoint, event type, repo/branch filter, `secret_ref`를 등록하는 형식을 정의한다. -- [ ] [deliver] `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다. 검증: fake consumer endpoint가 delivery id와 payload를 수신한다. -- [ ] [retry] delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다. - ### Epic: [reconcile] Reconcile policy 변경 적용 전 재검증과 conflict 처리를 수행한다. @@ -79,8 +91,8 @@ provider webhook, outbound consumer webhook, provider polling, Git revision scan ## 작업 컨텍스트 -- 관련 경로: `services/core/internal/events/`, `services/core/internal/provider/`, `services/core/internal/gitengine/`, `services/core/internal/worker/` -- 표준선(선택): webhook은 빠른 신호이고 확정은 Git revision/diff 또는 provider read API로 수행한다. 외부 event delivery는 HTTP webhook/callback이며 proto-socket은 내부 control/runtime 규약으로만 남긴다. -- 선행 작업: Change Request Abstraction -- 후속 작업: Flutter Control Surface Baseline -- 확인 필요: 없음 +- 관련 경로: `services/core/internal/controlplane/`, `services/core/internal/events/`, `services/core/internal/provider/`, `services/core/internal/gitengine/`, `services/core/internal/worker/`, `agent-contract/provided/gito-forgejo-branch-events-v1.md` +- 표준선(선택): webhook은 빠른 신호이고 확정은 Git revision/diff 또는 provider read API로 수행한다. 외부 event delivery는 HTTP webhook/callback이며 proto-socket은 내부 control/runtime 규약으로만 남긴다. NomadCode 전용 필수 필드는 base payload에 넣지 않는다. +- 선행 작업: Forgejo Branch Event MVP와 `gito.forgejo-branch-events.v1` 계약 +- 후속 작업: Change Request Abstraction, Flutter Control Surface Baseline +- 확인 필요: SDD 작성/승인 후 구현 계획 생성 diff --git a/agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md b/agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md new file mode 100644 index 0000000..5d6068e --- /dev/null +++ b/agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md @@ -0,0 +1,129 @@ +# SDD: Webhook Revision Reconcile + +## 위치 + +- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` +- Phase: `agent-roadmap/phase/provider-change-request-integration/PHASE.md` + +## 상태 + +[승인됨] + +## SDD 잠금 + +- 상태: 해제 +- 사용자 리뷰: 없음 +- 잠금 항목: + - 없음 + +## 문제 / 비목표 + +- 문제: NomadCode가 Gito의 `branch.updated` event를 generic HTTP webhook wakeup으로 받을 수 있어야 한다. 동시에 webhook delivery는 최종 source of truth가 아니며, delivery idempotency, retry, signature, revision 재검증 경계를 구현 전에 고정해야 한다. +- 비목표: + - NomadCode 전용 필수 payload field를 base schema에 추가하지 않는다. + - provider별 모든 webhook payload를 이 Milestone에서 지원하지 않는다. + - webhook delivery 성공을 NomadCode 또는 다른 consumer의 상태 적용 완료 증거로 취급하지 않는다. + - proto-socket, gRPC, Redis Streams fanout을 external consumer delivery 경로로 추가하지 않는다. + - UI conflict resolution 화면은 이 Milestone에서 다루지 않는다. + +## Source of Truth + +| 영역 | 기준 | 메모 | +|------|------|------| +| Roadmap | `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` | Milestone 목표, 범위, 기능 Task 완료 기준 | +| Code | `services/core/internal/controlplane/`, `services/core/internal/events/`, `services/core/internal/worker/` | REST subscription/delivery surface, normalized event, durable delivery/outbox 구현 기준 | +| External Provider | Forgejo push webhook, Gito outbound HTTP webhook consumer | Forgejo push는 wakeup source, consumer delivery는 `branch.updated` wakeup signal | +| User Decision | 없음 | 기존 `gito.forgejo-branch-events.v1` 계약을 따른다 | + +## State Machine + +| 상태 | 진입 조건 | 다음 상태 | 근거 | +|------|-----------|-----------|------| +| webhook-received | Forgejo push callback이 유효 payload로 수신된다 | event-recorded 또는 ignored | `POST /callbacks/forgejo/push`, watch match 여부 | +| ignored | event가 unsupported 또는 unwatched branch에 해당한다 | 없음 | response `accepted=true`, `matched=false` | +| event-recorded | watched branch의 before/after revision이 normalized `branch.updated`로 기록된다 | delivery-pending | durable event/outbox record | +| delivery-pending | matching webhook subscription이 존재한다 | delivery-sending | subscription event/repo/branch filter | +| delivery-sending | worker가 pending delivery를 HTTP POST로 전송한다 | delivery-succeeded 또는 delivery-retryable 또는 delivery-failed | target HTTP response, timeout, signature config | +| delivery-succeeded | consumer endpoint가 2xx를 반환한다 | 없음 | delivery attempt record | +| delivery-retryable | consumer endpoint가 429 또는 5xx를 반환하거나 transient network failure가 발생한다 | delivery-pending 또는 delivery-failed | retry/backoff policy | +| delivery-failed | consumer endpoint가 non-retryable response를 반환하거나 retry budget이 소진된다 | 없음 | delivery attempt state | +| reconcile-conflict | expected revision이 현재 source of truth와 맞지 않는다 | dry-run-result 또는 없음 | Git revision scan 또는 provider read API | +| applied | idempotency key로 중복 적용이 차단되고 change apply가 성공한다 | 없음 | apply result and idempotency record | + +## Interface Contract + +- 계약 원문: `agent-contract/provided/gito-forgejo-branch-events-v1.md` +- 입력: + - Forgejo callback: `POST /callbacks/forgejo/push` + - branch watch: `POST /api/listeners/branches` + - webhook subscription: `POST /api/webhook-subscriptions` + - subscription filter: `events`, `repo_id`, `branch` + - signing reference: `secret_ref` + - revision identity: `repo_id`, `branch`, `before`, `after` +- 출력: + - normalized event: `branch.updated` + - webhook headers: `X-Gito-Event`, `X-Gito-Delivery`, optional `X-Gito-Signature` + - consumer payload: `id`, `type`, `provider`, `delivery_id`, `repo_id`, `branch`, `before`, `after`, `changed_files`, `observed_at`, `created_at` + - delivery attempt state: pending, sending, succeeded, retryable, failed +- 금지: + - raw secret, token, password, credential 값을 tracked docs, event payload, log에 남기지 않는다. + - NomadCode 전용 필수 field를 base `branch.updated` payload에 추가하지 않는다. + - provider callback이 NomadCode, Plane, roadmap source of truth를 직접 변경하지 않는다. + - consumer webhook delivery를 최종 상태 증거로 취급하지 않는다. + - external consumer delivery에 proto-socket을 요구하지 않는다. + +## Acceptance Scenarios + +| ID | Milestone Task | Given | When | Then | +|----|----------------|-------|------|------| +| S01 | `subscription` | consumer endpoint, event type, repo/branch filter, `secret_ref`가 입력된다 | webhook subscription을 등록한다 | subscription id와 filter가 저장되고 raw secret은 저장되지 않는다 | +| S02 | `deliver` | watched `nomadcode/develop` branch에서 `branch.updated` event가 기록되고 matching subscription이 있다 | delivery worker가 event를 처리한다 | fake consumer가 signed HTTP POST, delivery id, base payload를 수신한다 | +| S03 | `nomadcode-wakeup` | NomadCode dev consumer가 generic `branch.updated` endpoint를 제공한다 | Gito가 matching delivery를 전송한다 | consumer가 NomadCode 전용 필수 field 없이 wakeup으로 매핑할 수 있다 | +| S04 | `retry` | consumer가 429 또는 5xx를 반환한다 | delivery attempt가 기록된다 | retry/backoff 상태가 PostgreSQL 기준으로 남고 delivery idempotency가 유지된다 | +| S05 | `webhook-source` | Forgejo push callback이 들어온다 | payload가 watch와 match된다 | `provider.webhook.received` 또는 equivalent wakeup source event와 normalized branch event가 추적 가능하다 | +| S06 | `poll-source` | provider webhook이 누락되거나 지연된다 | polling fallback shape를 사용한다 | 같은 revision identity로 change source를 표현할 수 있다 | +| S07 | `git-source` | Git revision scan이 before/after revision을 알고 있다 | provider event와 scan result를 reconcile한다 | 동일 repo/branch/revision identity로 중복 이벤트를 피한다 | +| S08 | `expected-rev` | expected revision과 현재 branch revision이 다르다 | reconcile을 시도한다 | apply 없이 conflict로 멈춘다 | +| S09 | `dry-run` | event와 현재 Git/provider 상태가 주어진다 | dry-run을 실행한다 | drift, conflict, proposal이 관찰 가능한 결과로 반환된다 | +| S10 | `apply` | 같은 event 또는 idempotency key가 반복 처리된다 | apply를 두 번 요청한다 | side effect는 한 번만 실행되고 중복은 idempotent 결과로 수렴한다 | + +## Evidence Map + +| Scenario | Required Evidence | `agent-task` 연결 | `Spec Completion` 기대 | +|----------|-------------------|------------------|---------------------------| +| S01 | subscription create/list test, secret value grep | `agent-task/m-webhook-revision-reconcile/...` | `subscription` task가 contract filter와 `secret_ref` storage boundary를 만족했다 | +| S02 | `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1` | `agent-task/m-webhook-revision-reconcile/...` | `deliver` task가 fake consumer delivery id, headers, payload 수신을 증명했다 | +| S03 | NomadCode interop smoke 또는 fake NomadCode consumer test | `agent-task/m-webhook-revision-reconcile/...` | `nomadcode-wakeup` task가 generic `branch.updated`만으로 wakeup mapping을 증명했다 | +| S04 | delivery retry/backoff unit or integration test with PostgreSQL store | `agent-task/m-webhook-revision-reconcile/...` | `retry` task가 retryable/non-retryable status와 delivery idempotency를 증명했다 | +| S05 | Forgejo callback test and event inspection evidence | `agent-task/m-webhook-revision-reconcile/...` | `webhook-source` task가 provider callback을 wakeup source로 저장/추적함을 증명했다 | +| S06 | polling fallback shape contract or unit test | `agent-task/m-webhook-revision-reconcile/...` | `poll-source` task가 polling source를 동일 change model로 표현함을 증명했다 | +| S07 | revision identity dedupe/reconcile test | `agent-task/m-webhook-revision-reconcile/...` | `git-source` task가 Git revision scan과 provider event identity 연결을 증명했다 | +| S08 | expected revision mismatch test | `agent-task/m-webhook-revision-reconcile/...` | `expected-rev` task가 mismatch conflict stop을 증명했다 | +| S09 | reconcile dry-run test with drift/conflict/proposal output | `agent-task/m-webhook-revision-reconcile/...` | `dry-run` task가 apply 없이 observable proposal 결과를 증명했다 | +| S10 | idempotent apply test with repeated event/key | `agent-task/m-webhook-revision-reconcile/...` | `apply` task가 repeated processing side effect once를 증명했다 | + +## Cross-repo Dependencies + +- NomadCode는 external consumer로 `branch.updated` delivery를 수신한다. 현재 SDD는 `.agent-roadmap-sync/locks.yaml` entry를 요구하지 않는다. +- 계약 기준은 `agent-contract/provided/gito-forgejo-branch-events-v1.md`이다. + + + +## Drift Check + +- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다. +- [x] Evidence Map이 plan/code-review/complete.log에서 검증 가능하다. +- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다. +- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다. + +## 사용자 리뷰 이력 + +- 없음 + +## 작업 컨텍스트 + +- 표준선: webhook은 빠른 wakeup signal이며 최종 판정은 Git revision/diff 또는 provider read API로 수행한다. 외부 event delivery는 HTTP webhook/callback이고, proto-socket은 내부 control/runtime 규약으로만 남긴다. +- 후속 SDD: 없음 diff --git a/agent-task/m-webhook-revision-reconcile/01_http_delivery/CODE_REVIEW-local-G05.md b/agent-task/m-webhook-revision-reconcile/01_http_delivery/CODE_REVIEW-local-G05.md new file mode 100644 index 0000000..9739e90 --- /dev/null +++ b/agent-task/m-webhook-revision-reconcile/01_http_delivery/CODE_REVIEW-local-G05.md @@ -0,0 +1,158 @@ + + +# Code Review Reference - API + +> **[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 selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close 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 only SDD/Milestone lock decisions 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-17 +task=m-webhook-revision-reconcile/01_http_delivery, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` +- Task ids: + - `deliver`: `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다. + - `nomadcode-wakeup`: NomadCode dev consumer가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 매핑할 수 있는 interop path를 검증한다. +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md` +- Acceptance scenarios: + - `S02`: task=`deliver`; evidence=`cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1` + - `S03`: task=`nomadcode-wakeup`; evidence=`fake NomadCode consumer test receives generic branch.updated without NomadCode-only required fields` +- Completion mode: spec-check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-webhook-revision-reconcile/01_http_delivery/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] HTTP Branch Updated Delivery | [ ] | +| [API-2] NomadCode Wakeup Compatibility | [ ] | + +## 구현 체크리스트 + +- [ ] Runtime에 matching webhook subscription을 찾아 HTTP POST delivery를 수행하는 경로를 추가하고, `deliver` 검증인 `TestWebhookDeliveryBranchUpdated`를 작성한다. +- [ ] secret resolver 또는 package-private test hook으로 `secret_ref`를 raw secret 저장 없이 HMAC 서명에 연결하고, 응답/로그에 secret 값이 노출되지 않게 한다. +- [ ] fake NomadCode consumer가 base `branch.updated` payload만으로 wakeup mapping 가능한지 검증하고, NomadCode 전용 필수 field가 없음을 assertion한다. +- [ ] `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`를 실행한다. +- [ ] `cd services/core && go test ./...`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-webhook-revision-reconcile/01_http_delivery/`를 `agent-task/archive/YYYY/MM/m-webhook-revision-reconcile/01_http_delivery/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-webhook-revision-reconcile/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Matching subscription filter가 event/repo/branch를 정확히 적용하는지 확인한다. +- Signature가 raw `secret_ref` 저장이나 로그 노출 없이 resolved secret으로만 계산되는지 확인한다. +- Payload가 contract base schema이고 NomadCode 전용 필수 field를 추가하지 않았는지 확인한다. +- `TestWebhookDeliveryBranchUpdated` 출력이 실제 fake consumer delivery를 증명하는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### API-1 중간 검증 + +```bash +$ cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1 +(output) +``` + +### 최종 검증 + +```bash +$ cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1 +(output) + +$ cd services/core && go test ./... +(output) +``` + +--- + +> **[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. + +## Sections and Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute archive/finalization | +| Roadmap Targets | Fixed at stub creation | Code-review copies it into `complete.log` only on PASS | +| Spec Targets | Fixed at stub creation | Code-review copies it into `complete.log` only on PASS | +| 구현 항목별 완료 여부 | Implementing agent checks boxes only | Item names stay fixed | +| 구현 체크리스트 | Implementing agent checks boxes only | Text/order stay fixed | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected SDD/Milestone lock decision blocks | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Reviewer focus | +| 검증 결과 | Implementing agent | Fill command output only | diff --git a/agent-task/m-webhook-revision-reconcile/01_http_delivery/PLAN-local-G05.md b/agent-task/m-webhook-revision-reconcile/01_http_delivery/PLAN-local-G05.md new file mode 100644 index 0000000..bd2ae4f --- /dev/null +++ b/agent-task/m-webhook-revision-reconcile/01_http_delivery/PLAN-local-G05.md @@ -0,0 +1,252 @@ + + +# Plan - API + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수다. 구현 후 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막으면 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 근거, 실행 명령, 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 환경/secret/service 차단, 일반 범위 변경, 검증 공백은 사용자 리뷰가 아니라 검증 결과나 일반 follow-up으로 남긴다. + +## 배경 + +`subscription` 등록 표면은 현재 작업 패스에서 `/api/webhook-subscriptions`와 in-memory registry로 추가되었다. 남은 delivery slice는 watched `branch.updated` record가 matching subscription으로 HTTP POST되는 경로와 NomadCode-style generic wakeup 검증이다. 이 plan은 PostgreSQL retry/outbox 상태를 만들지 않고, deterministic fake consumer test로 S02/S03만 완료 대상으로 삼는다. + +## 사용자 리뷰 요청 흐름 + +구현 중 직접 사용자에게 묻지 않는다. 선택된 SDD 결정 또는 Milestone lock 결정만 active review stub의 `사용자 리뷰 요청` 섹션에 기록하고, code-review가 검증해 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` +- Task ids: + - `deliver`: `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다. + - `nomadcode-wakeup`: NomadCode dev consumer가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 매핑할 수 있는 interop path를 검증한다. +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md` +- Acceptance scenarios: + - `S02`: task=`deliver`; evidence=`cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1` + - `S03`: task=`nomadcode-wakeup`; evidence=`fake NomadCode consumer test receives generic branch.updated without NomadCode-only required fields` +- Completion mode: spec-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/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/provider-change-request-integration/PHASE.md` +- `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` +- `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md` +- `agent-contract/index.md` +- `agent-contract/provided/gito-forgejo-branch-events-v1.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` +- `agent-ops/rules/project/domain/core/rules.md` +- `agent-ops/rules/project/domain/contracts/rules.md` +- `services/core/go.mod` +- `services/core/cmd/server/main.go` +- `services/core/cmd/worker/main.go` +- `services/core/internal/core/model.go` +- `services/core/internal/controlplane/router.go` +- `services/core/internal/controlplane/router_test.go` +- `services/core/internal/controlplane/runtime.go` +- `services/core/internal/controlplane/runtime_test.go` +- `services/core/internal/controlplane/binary_interop_test.go` +- `services/core/internal/events/events.go` +- `services/core/internal/storage/storage.go` +- `services/core/internal/storage/storage_test.go` +- `services/core/internal/storage/postgres.go` +- `services/core/internal/storage/postgres_test.go` +- `services/core/internal/worker/http_invoker.go` +- `services/core/internal/worker/http_invoker_test.go` +- `services/core/internal/worker/runner.go` +- `services/core/migrations/00001_initial.sql` +- `.gitignore` + +### 테스트 환경 규칙 + +- test_env: `local`. +- 적용 규칙: `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다. +- 적용 명령: core behavior change는 `cd services/core && go test ./...`가 필수다. +- SDD S02 지정 검증은 `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`이다. +- `contracts-smoke`, `workspace-ops-smoke`도 읽었지만 이 plan의 구현 파일은 `services/core/**`와 `agent-task/**`다. 계약 문서 자체는 수정하지 않는다. + +### 테스트 커버리지 공백 + +- `subscription` create/list는 `TestWebhookSubscriptionRegistersFilterWithoutLeakingSecretRef`와 `TestWebhookSubscriptionValidatesTargetAndEvents`로 현재 커버된다. +- `branch.updated` event 생성은 `TestForgejoPushCreatesWatchedBranchEvent`와 runtime tests로 커버된다. +- HTTP consumer delivery, `X-Gito-Event`, `X-Gito-Delivery`, signature, NomadCode-neutral payload는 아직 테스트가 없다. 이 plan에서 `TestWebhookDeliveryBranchUpdated`를 추가한다. +- Retry/backoff/failed state는 이 plan에서 제외하고 `02+01_retry_delivery_store`가 다룬다. + +### 심볼 참조 + +- renamed/removed symbol: none. + +### 분할 판단 + +- split policy 평가 결과, delivery dispatch와 retry persistence는 상태 저장/스키마 위험이 달라 분리한다. +- shared task group: `agent-task/m-webhook-revision-reconcile`. +- `01_http_delivery`: no runtime predecessor. HTTP dispatch와 NomadCode-style fake consumer 검증. +- `02+01_retry_delivery_store`: depends on `01_http_delivery`; predecessor `01`은 아직 `complete.log`가 없으므로 이 plan 완료 전 구현하지 않는다. + +### 범위 결정 근거 + +- `subscription`은 현재 working tree의 small-task 구현으로 처리되었으므로 이 plan의 Roadmap Targets에 넣지 않는다. +- PostgreSQL delivery attempts, retry/backoff, failed state, worker scheduling은 `02+01_retry_delivery_store`로 제외한다. +- provider polling, reconcile dry-run/apply, Git source dedupe는 delivery epic 밖 작업이므로 제외한다. +- external proto-socket consumer delivery는 SDD 비목표이므로 추가하지 않는다. + +### 빌드 등급 + +- build/review: `local-G05`. Public REST/event behavior지만 변경 범위가 controlplane runtime/router/test로 좁고 `httptest`로 deterministic 검증 가능하다. + +## 구현 체크리스트 + +- [ ] Runtime에 matching webhook subscription을 찾아 HTTP POST delivery를 수행하는 경로를 추가하고, `deliver` 검증인 `TestWebhookDeliveryBranchUpdated`를 작성한다. +- [ ] secret resolver 또는 package-private test hook으로 `secret_ref`를 raw secret 저장 없이 HMAC 서명에 연결하고, 응답/로그에 secret 값이 노출되지 않게 한다. +- [ ] fake NomadCode consumer가 base `branch.updated` payload만으로 wakeup mapping 가능한지 검증하고, NomadCode 전용 필수 field가 없음을 assertion한다. +- [ ] `cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1`를 실행한다. +- [ ] `cd services/core && go test ./...`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [API-1] HTTP Branch Updated Delivery + +### 문제 + +`router.go:47`에 `/api/webhook-subscriptions` 등록 endpoint가 있고 `runtime.go:155`에 subscription registry가 있지만, `runtime.go:232`의 `HandleRevision`은 matching subscription으로 HTTP delivery를 보내지 않는다. + +Before: + +```go +// services/core/internal/controlplane/runtime.go:252 +r.mu.Lock() +matched := r.matchesLocked(provider, revision) +if matched { + r.records = append(r.records, record) +} +r.mu.Unlock() + +if !matched { + return record, false, nil +} +if r.broadcaster != nil { + if err := r.broadcaster.BroadcastEnvelope(ctx, branchUpdatedEnvelope(record)); err != nil { + return record, true, err + } +} +return record, true, nil +``` + +### 해결 방법 + +`Runtime`에 webhook delivery dependencies를 추가한다. 기본 HTTP client는 timeout이 있는 `http.Client`를 사용하고, 테스트는 package-private setter 또는 constructor option으로 fake secret resolver/client를 주입한다. Matching rule은 subscription `events`가 record type을 포함하고, `repo_id`/`branch`가 비어 있거나 record와 같을 때만 허용한다. Delivery body는 계약의 base payload만 사용하고 header는 `X-Gito-Event`, `X-Gito-Delivery`, `Content-Type: application/json`, resolved secret이 있을 때 `X-Gito-Signature`를 붙인다. + +After shape: + +```go +// services/core/internal/controlplane/runtime.go +type WebhookSecretResolver interface { + ResolveWebhookSecret(ctx context.Context, secretRef string) (string, bool, error) +} + +type Runtime struct { + // existing fields... + webhookClient *http.Client + secretResolver WebhookSecretResolver +} + +func (r *Runtime) deliverWebhooks(ctx context.Context, record EventRecord) error { + for _, subscription := range r.matchingWebhookSubscriptions(record) { + if err := r.postWebhook(ctx, subscription, record); err != nil { + return err + } + } + return nil +} +``` + +Call `deliverWebhooks` after successful branch watch match and before returning success. If delivery fails in this first slice, return an error so the Forgejo callback surfaces `500` and durable retry remains for the dependent plan. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/controlplane/runtime.go`: add HTTP client/secret resolver dependency, matching helper, payload builder, HMAC signer, synchronous delivery call. +- [ ] `services/core/internal/controlplane/router.go`: no route shape change unless a package-private test constructor is needed. +- [ ] `services/core/internal/controlplane/runtime_test.go`: add fake consumer/secret resolver test. +- [ ] `services/core/internal/controlplane/router_test.go`: update only if HTTP-route end-to-end coverage is easier here. + +### 테스트 작성 + +Write `TestWebhookDeliveryBranchUpdated` in `services/core/internal/controlplane/runtime_test.go` or `router_test.go`. Assertions: fake consumer receives one POST, headers include `X-Gito-Event=branch.updated`, non-empty `X-Gito-Delivery`, optional valid `X-Gito-Signature`, body contains `id`, `type`, `provider`, `delivery_id`, `repo_id`, `branch`, `before`, `after`, `changed_files`, `observed_at`, `created_at`. + +### 중간 검증 + +```bash +cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1 +``` + +Expected: test exits 0 and fake consumer assertions pass. + +## [API-2] NomadCode Wakeup Compatibility + +### 문제 + +SDD S03 requires a NomadCode dev consumer to map generic `branch.updated` without NomadCode-only required fields. Current delivery code has no fake NomadCode consumer test, and the payload builder does not yet exist. + +Before: + +```go +// services/core/internal/controlplane/runtime.go:594 +type outboxBroadcaster struct { + store storage.OperationEventStore +} +``` + +### 해결 방법 + +Keep the delivery payload consumer-neutral. In `TestWebhookDeliveryBranchUpdated`, implement the fake consumer as a NomadCode-style wakeup handler: it accepts only `type`, `repo_id`, `branch`, `before`, `after`, `changed_files`, timestamps, and delivery headers. Add a negative assertion that no `nomadcode_*`, `plane_*`, or required consumer-specific field appears in the top-level payload. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/controlplane/runtime.go`: payload builder must not add consumer-specific fields. +- [ ] `services/core/internal/controlplane/runtime_test.go`: fake consumer maps generic event to wakeup and asserts no consumer-specific required fields. + +### 테스트 작성 + +Use the same `TestWebhookDeliveryBranchUpdated` so S02 and S03 share the actual interop path. Do not write a separate mock that bypasses delivery. + +### 중간 검증 + +```bash +cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1 +``` + +Expected: test exits 0 and the fake consumer wakeup mapping is observed. + +## 수정 파일 요약 + +| 파일 | 항목 | +| --- | --- | +| `services/core/internal/controlplane/runtime.go` | API-1, API-2 | +| `services/core/internal/controlplane/runtime_test.go` | API-1, API-2 | +| `services/core/internal/controlplane/router.go` | API-1 if constructor wiring is needed | +| `services/core/internal/controlplane/router_test.go` | API-1 if route-level test is chosen | + +## 최종 검증 + +```bash +cd services/core && go test -run TestWebhookDeliveryBranchUpdated ./internal/controlplane/ -v -count=1 +cd services/core && go test ./... +``` + +Expected: both commands exit 0. Cached output is acceptable for the full package smoke; the SDD-specific command uses `-count=1`. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/CODE_REVIEW-cloud-G06.md b/agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/CODE_REVIEW-cloud-G06.md new file mode 100644 index 0000000..c6cf130 --- /dev/null +++ b/agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/CODE_REVIEW-cloud-G06.md @@ -0,0 +1,166 @@ + + +# Code Review Reference - API + +> **[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 selected SDD decision or selected Milestone `구현 잠금 > 결정 필요` item, fill `사용자 리뷰 요청` with linked evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Environment/secret/service blockers, generic scope changes, repeated failures, and evidence gaps that a follow-up agent can close 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 only SDD/Milestone lock decisions 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-17 +task=m-webhook-revision-reconcile/02+01_retry_delivery_store, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` +- Task ids: + - `retry`: delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다. +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md` +- Acceptance scenarios: + - `S04`: task=`retry`; evidence=`delivery retry/backoff unit or integration test with PostgreSQL store` +- Completion mode: spec-check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G06.md` → `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` → `plan_cloud_G06_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-webhook-revision-reconcile/02+01_retry_delivery_store/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Durable Webhook Delivery Store | [ ] | +| [API-2] Retry Classification And Backoff | [ ] | + +## 구현 체크리스트 + +- [ ] 선행 `01_http_delivery`의 `complete.log` 존재를 확인하고 경로를 `검증 결과`에 기록한다. +- [ ] outbound webhook delivery 상태 모델과 store interface를 추가한다. +- [ ] PostgreSQL migration과 `PgStore` 구현으로 pending/sending/succeeded/retryable/failed 상태, attempt count, next_attempt_at, last_error/status_code를 기록한다. +- [ ] delivery response classifier를 추가해 2xx는 succeeded, 429/5xx/transport는 retryable with backoff, 400/401/403/404/410은 failed로 기록한다. +- [ ] 같은 event/subscription 조합이 중복 enqueue되어도 delivery idempotency가 유지되는 테스트를 작성한다. +- [ ] `cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1`를 실행한다. +- [ ] `cd services/core && go test ./...`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/`를 `agent-task/archive/YYYY/MM/m-webhook-revision-reconcile/02+01_retry_delivery_store/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-webhook-revision-reconcile/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 새 결정이 필요해 보여도 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 이 섹션은 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 차단할 때만 채운다. 외부 환경/secret/서비스 준비, 검증 증거 공백, 반복 실패, 일반 범위 조정은 사용자 리뷰 요청이 아니며 `검증 결과`, `계획 대비 변경 사항`, 또는 code-review의 일반 follow-up plan으로 처리한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 연결 대상: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `provider_deliveries`와 outbound `webhook_deliveries` 책임이 섞이지 않았는지 확인한다. +- Retryable/non-retryable classification이 contract와 SDD 상태 머신을 따르는지 확인한다. +- Duplicate event/subscription enqueue가 idempotent인지 확인한다. +- PostgreSQL migration snippets와 integration test evidence가 실제 schema를 반영하는지 확인한다. +- `01_http_delivery` predecessor completion evidence가 기록되어 있는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### API-1 중간 검증 + +```bash +$ cd services/core && go test -run TestPostgresWebhookDeliveryStoreRecordsRetryState ./internal/storage -v -count=1 +(output) +``` + +### API-2 중간 검증 + +```bash +$ cd services/core && go test -run TestWebhookDeliveryRetryBackoffRecordsState ./internal/controlplane -v -count=1 +(output) +``` + +### 최종 검증 + +```bash +$ cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1 +(output) + +$ cd services/core && go test ./... +(output) +``` + +--- + +> **[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. + +## Sections and Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute archive/finalization | +| Roadmap Targets | Fixed at stub creation | Code-review copies it into `complete.log` only on PASS | +| Spec Targets | Fixed at stub creation | Code-review copies it into `complete.log` only on PASS | +| 구현 항목별 완료 여부 | Implementing agent checks boxes only | Item names stay fixed | +| 구현 체크리스트 | Implementing agent checks boxes only | Text/order stay fixed | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected SDD/Milestone lock decision blocks | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Reviewer focus | +| 검증 결과 | Implementing agent | Fill command output only | diff --git a/agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/PLAN-cloud-G06.md b/agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/PLAN-cloud-G06.md new file mode 100644 index 0000000..7c4e248 --- /dev/null +++ b/agent-task/m-webhook-revision-reconcile/02+01_retry_delivery_store/PLAN-cloud-G06.md @@ -0,0 +1,288 @@ + + +# Plan - API + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수다. 이 task directory 이름의 `+01`은 runtime dependency다. `agent-task/m-webhook-revision-reconcile/01_http_delivery/complete.log` 또는 matching archive `complete.log`가 없으면 구현을 시작하지 말고 review stub에 dependency blocker를 기록한다. 구현 후 검증을 실행하고 실제 stdout/stderr를 붙인 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review-skill 전용이다. 선택된 SDD 결정 또는 Milestone `구현 잠금 > 결정 필요` 항목이 실구현을 막으면 review stub의 `사용자 리뷰 요청` 섹션에 연결 대상, 근거, 실행 명령, 재개 조건을 기록하고 멈춘다. 구현 중 사용자에게 직접 묻거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. + +## 배경 + +`retry`는 단순 HTTP dispatch가 아니라 delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 남겨야 하는 storage-backed task다. 기존 `provider_deliveries`는 Forgejo/provider callback dedupe용이며 outbound consumer webhook attempt 상태를 표현하지 않는다. 이 plan은 `01_http_delivery`가 HTTP delivery path를 만든 뒤 그 경로를 durable queue/attempt state로 확장한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 직접 사용자에게 묻지 않는다. 선택된 SDD 결정 또는 Milestone lock 결정만 active review stub의 `사용자 리뷰 요청` 섹션에 기록하고, code-review가 검증해 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` +- Task ids: + - `retry`: delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다. +- Completion mode: check-on-pass + +## Spec Targets + +- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md` +- Acceptance scenarios: + - `S04`: task=`retry`; evidence=`delivery retry/backoff unit or integration test with PostgreSQL store` +- Completion mode: spec-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/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/provider-change-request-integration/PHASE.md` +- `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md` +- `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md` +- `agent-contract/index.md` +- `agent-contract/provided/gito-forgejo-branch-events-v1.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` +- `agent-ops/rules/project/domain/core/rules.md` +- `agent-ops/rules/project/domain/contracts/rules.md` +- `services/core/go.mod` +- `services/core/cmd/server/main.go` +- `services/core/cmd/worker/main.go` +- `services/core/internal/core/model.go` +- `services/core/internal/controlplane/router.go` +- `services/core/internal/controlplane/router_test.go` +- `services/core/internal/controlplane/runtime.go` +- `services/core/internal/controlplane/runtime_test.go` +- `services/core/internal/events/events.go` +- `services/core/internal/storage/storage.go` +- `services/core/internal/storage/storage_test.go` +- `services/core/internal/storage/postgres.go` +- `services/core/internal/storage/postgres_test.go` +- `services/core/internal/worker/runner.go` +- `services/core/migrations/00001_initial.sql` +- `.gitignore` + +### 테스트 환경 규칙 + +- test_env: `local`. +- 적용 규칙: `agent-test/local/rules.md`와 `agent-test/local/core-smoke.md`를 읽었다. +- 적용 명령: core behavior change는 `cd services/core && go test ./...`가 필수다. +- PostgreSQL-dependent tests may skip when `GITO_TEST_DATABASE_URL` is unset; skipped integration prerequisite must be reported with residual risk. +- 이 plan은 migration/storage 변경이 있으므로 targeted store tests와 full core smoke를 모두 둔다. + +### 테스트 커버리지 공백 + +- `ProviderDeliveryStore.RecordOnce`는 provider callback dedupe만 커버한다. +- Outbound webhook delivery attempt status, retryable/non-retryable classification, backoff time, retry budget, failed terminal state는 아직 모델/스토어/테스트가 없다. +- `operation_events` outbox pending/published는 generic event publish 용도이며 per-subscription delivery attempt 상태를 대체하지 않는다. + +### 심볼 참조 + +- renamed/removed symbol: none. 추가 interface가 생기면 `storage.Store`, `storage.Boundary`, `storage.PgStore`, test fakes 전체를 갱신해야 한다. + +### 분할 판단 + +- split policy 평가 결과, 이 작업은 storage/migration/attempt state를 다루므로 `01_http_delivery`와 분리했다. +- task group: `agent-task/m-webhook-revision-reconcile`. +- current subtask: `02+01_retry_delivery_store`. +- predecessor `01`: missing in active path now. Required completion file before implementation: `agent-task/m-webhook-revision-reconcile/01_http_delivery/complete.log`, or matching archived path `agent-task/archive/*/*/m-webhook-revision-reconcile/01_http_delivery/complete.log`. + +### 범위 결정 근거 + +- HTTP payload/header construction itself는 `01_http_delivery` 책임이다. 이 task는 successful/failed HTTP 결과를 durable delivery state로 남기고 retry scheduling이 가능한 store boundary를 만든다. +- Real background worker loop wiring은 최소 범위로만 포함한다. 별도 daemon scheduling이 필요해지면 follow-up으로 남기되, S04 증거는 store-level retry/backoff test로 닫는다. +- Provider polling/reconcile dry-run/apply는 delivery epic 밖이므로 제외한다. + +### 빌드 등급 + +- build/review: `cloud-G06`. PostgreSQL schema, storage interface, state machine, retry semantics가 얽혀 있고 review가 migration/test evidence를 함께 판단해야 한다. + +## 의존 관계 및 구현 순서 + +1. `01_http_delivery` PASS 후 `complete.log`가 존재하는지 확인한다. +2. Outbound delivery 상태 모델과 storage interface를 추가한다. +3. Migration/Postgres store를 구현한다. +4. Runtime delivery path를 store-backed enqueue/attempt recording으로 연결한다. +5. retry/backoff tests와 full smoke를 실행한다. + +## 구현 체크리스트 + +- [ ] 선행 `01_http_delivery`의 `complete.log` 존재를 확인하고 경로를 `검증 결과`에 기록한다. +- [ ] outbound webhook delivery 상태 모델과 store interface를 추가한다. +- [ ] PostgreSQL migration과 `PgStore` 구현으로 pending/sending/succeeded/retryable/failed 상태, attempt count, next_attempt_at, last_error/status_code를 기록한다. +- [ ] delivery response classifier를 추가해 2xx는 succeeded, 429/5xx/transport는 retryable with backoff, 400/401/403/404/410은 failed로 기록한다. +- [ ] 같은 event/subscription 조합이 중복 enqueue되어도 delivery idempotency가 유지되는 테스트를 작성한다. +- [ ] `cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1`를 실행한다. +- [ ] `cd services/core && go test ./...`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [API-1] Durable Webhook Delivery Store + +### 문제 + +`storage.go:13`의 `Store`에는 outbound webhook delivery store가 없다. `postgres.go:851`의 `ProviderDeliveryStore`는 provider callback dedupe만 기록하며 retry status, attempt count, next attempt time, response status를 저장하지 않는다. + +Before: + +```go +// services/core/internal/storage/storage.go:13 +type Store interface { + Ping(ctx context.Context) error + Repos() RepoStore + WorkspaceLeases() WorkspaceLeaseStore + Operations() OperationStore + OperationEvents() OperationEventStore + BranchWatches() BranchWatchStore + RevisionCursors() RevisionCursorStore + ProviderDeliveries() ProviderDeliveryStore + AgentRunInputs() AgentRunInputStore +} +``` + +```go +// services/core/internal/storage/postgres.go:851 +func (s *pgProviderDeliveryStore) RecordOnce(ctx context.Context, delivery core.ProviderDelivery) (DeliveryResult, error) { + const insert = ` + INSERT INTO provider_deliveries + (id, provider, delivery_id, dedupe_key, event_id, repo_id, branch, revision, created_at) +``` + +### 해결 방법 + +새 outbound 모델을 `core`에 추가하고 provider callback dedupe와 분리한다. + +After shape: + +```go +// services/core/internal/core/model.go +type WebhookDeliveryStatus string + +const ( + WebhookDeliveryPending WebhookDeliveryStatus = "pending" + WebhookDeliverySending WebhookDeliveryStatus = "sending" + WebhookDeliverySucceeded WebhookDeliveryStatus = "succeeded" + WebhookDeliveryRetryable WebhookDeliveryStatus = "retryable" + WebhookDeliveryFailed WebhookDeliveryStatus = "failed" +) + +type WebhookDelivery struct { + ID string + EventID string + SubscriptionID string + Status WebhookDeliveryStatus + AttemptCount int + NextAttemptAt time.Time + LastStatusCode int + LastError string + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +Add `WebhookDeliveryStore` to `storage.Store` with enqueue/pick/record methods. Update `Boundary`, `PgStore`, `storage_test.go` fakes, and `runtime_test.go` fake store. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/core/model.go`: add webhook delivery status/model and status validation helper. +- [ ] `services/core/internal/storage/storage.go`: add `WebhookDeliveryStore` interface and `WebhookDeliveries()` method. +- [ ] `services/core/internal/storage/postgres.go`: add `pgWebhookDeliveryStore`. +- [ ] `services/core/migrations/00001_initial.sql`: add `webhook_deliveries` table and indexes. +- [ ] `services/core/internal/storage/storage_test.go`: update compile fakes and migration snippets. +- [ ] `services/core/internal/storage/postgres_test.go`: add Postgres integration test; skip cleanly without `GITO_TEST_DATABASE_URL`. +- [ ] `services/core/internal/controlplane/runtime_test.go`: update `fakeStore`. + +### 테스트 작성 + +Write `TestPostgresWebhookDeliveryStoreRecordsRetryState` in `postgres_test.go`. Assertions: first enqueue creates pending, duplicate enqueue returns existing/no duplicate, pick moves to sending, retryable record increments attempt count and sets `next_attempt_at`, terminal failed and succeeded states are persisted. + +### 중간 검증 + +```bash +cd services/core && go test -run TestPostgresWebhookDeliveryStoreRecordsRetryState ./internal/storage -v -count=1 +``` + +Expected: exits 0 when `GITO_TEST_DATABASE_URL` is set; otherwise skips with the existing PostgreSQL prerequisite message. + +## [API-2] Retry Classification And Backoff + +### 문제 + +After `01_http_delivery`, HTTP delivery can fail, but S04 requires retry/backoff and failed state to be recorded. There is no classifier for 429/5xx vs non-retryable 4xx, and no retry budget. + +Before: + +```go +// services/core/internal/controlplane/runtime.go:310 +if r.broadcaster != nil { + if err := r.broadcaster.BroadcastEnvelope(ctx, branchUpdatedEnvelope(record)); err != nil { + // Rollback delivery record on broadcast failure + if r.store.ProviderDeliveries() != nil && dedupeKey != "" { +``` + +### 해결 방법 + +Keep provider event broadcast separate from outbound webhook delivery. Wrap the HTTP delivery result from `01_http_delivery` in a classifier: + +```go +func classifyWebhookDelivery(resp *http.Response, err error, attempt int, now time.Time) webhookDeliveryOutcome { + switch { + case err != nil: + return retryableOutcome(now, attempt) + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return succeededOutcome() + case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: + return retryableOutcome(now, attempt) + default: + return failedOutcome(resp.StatusCode) + } +} +``` + +Use bounded exponential backoff with deterministic unit tests. Do not log raw target URL, raw secret, or private response bodies. + +### 수정 파일 및 체크리스트 + +- [ ] `services/core/internal/controlplane/runtime.go`: record delivery outcome through `WebhookDeliveryStore`. +- [ ] `services/core/internal/controlplane/runtime_test.go`: add fake store tests for retryable, failed, succeeded outcomes. +- [ ] `services/core/internal/storage/storage.go`: expose status recording primitives needed by runtime. + +### 테스트 작성 + +Write `TestWebhookDeliveryRetryBackoffRecordsState` in `runtime_test.go`. Use `httptest.Server` returning 500 then 200 or just a deterministic fake transport. Assert attempt count, retryable state, `next_attempt_at` after now, and no duplicate delivery row for repeated event/subscription enqueue. + +### 중간 검증 + +```bash +cd services/core && go test -run TestWebhookDeliveryRetryBackoffRecordsState ./internal/controlplane -v -count=1 +``` + +Expected: exits 0 and records retryable state plus next attempt time. + +## 수정 파일 요약 + +| 파일 | 항목 | +| --- | --- | +| `services/core/internal/core/model.go` | API-1 | +| `services/core/internal/storage/storage.go` | API-1, API-2 | +| `services/core/internal/storage/postgres.go` | API-1 | +| `services/core/migrations/00001_initial.sql` | API-1 | +| `services/core/internal/storage/storage_test.go` | API-1 | +| `services/core/internal/storage/postgres_test.go` | API-1 | +| `services/core/internal/controlplane/runtime.go` | API-2 | +| `services/core/internal/controlplane/runtime_test.go` | API-2 | + +## 최종 검증 + +```bash +cd services/core && go test -run 'TestWebhookDeliveryRetry|TestPostgresWebhookDelivery' ./internal/... -v -count=1 +cd services/core && go test ./... +``` + +Expected: both commands exit 0. PostgreSQL-specific test may skip only when `GITO_TEST_DATABASE_URL` is unset; record the skip output and residual risk. Cached output is acceptable for the full package smoke, but targeted retry tests use `-count=1`. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/services/core/internal/controlplane/router.go b/services/core/internal/controlplane/router.go index 6d634c0..797eae0 100644 --- a/services/core/internal/controlplane/router.go +++ b/services/core/internal/controlplane/router.go @@ -44,6 +44,7 @@ func newRouterWithStore(cfg config.Config, logger *slog.Logger, store storage.St }) }) mux.HandleFunc("/api/listeners/branches", handleBranchListeners(runtime)) + mux.HandleFunc("/api/webhook-subscriptions", handleWebhookSubscriptions(runtime)) mux.HandleFunc("/api/events", handleEvents(runtime)) mux.HandleFunc("/callbacks/forgejo/push", handleForgejoPush(cfg, runtime)) mux.HandleFunc(cfg.ProtoSocketPath, func(w http.ResponseWriter, r *http.Request) { @@ -150,6 +151,45 @@ func handleBranchListeners(runtime *Runtime) http.HandlerFunc { } } +func handleWebhookSubscriptions(runtime *Runtime) http.HandlerFunc { + type request struct { + Name string `json:"name"` + TargetURL string `json:"target_url"` + Events []string `json:"events"` + RepoID string `json:"repo_id"` + Branch string `json:"branch"` + SecretRef string `json:"secret_ref"` + } + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"subscriptions": webhookSubscriptionsPayload(runtime.ListWebhookSubscriptions())}) + case http.MethodPost: + var input request + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid webhook subscription payload"}) + return + } + subscription, err := runtime.RegisterWebhookSubscription( + input.Name, + input.TargetURL, + input.Events, + input.RepoID, + input.Branch, + input.SecretRef, + ) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"subscription": webhookSubscriptionPayload(subscription)}) + default: + w.Header().Set("Allow", "GET, POST") + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } + } +} + func handleEvents(runtime *Runtime) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -340,6 +380,25 @@ func eventRecordsPayload(records []EventRecord) []any { return payload } +func webhookSubscriptionsPayload(subscriptions []WebhookSubscription) []any { + payload := make([]any, 0, len(subscriptions)) + for _, subscription := range subscriptions { + payload = append(payload, webhookSubscriptionPayload(subscription)) + } + return payload +} + +func webhookSubscriptionPayload(subscription WebhookSubscription) map[string]any { + return map[string]any{ + "id": subscription.ID, + "name": subscription.Name, + "events": subscription.Events, + "repo_id": subscription.RepoID, + "branch": subscription.Branch, + "created_at": subscription.CreatedAt.UTC().Format(time.RFC3339Nano), + } +} + func eventRecordPayload(record EventRecord) map[string]any { changedFiles := make([]any, 0, len(record.Revision.ChangedFiles)) for _, file := range record.Revision.ChangedFiles { diff --git a/services/core/internal/controlplane/router_test.go b/services/core/internal/controlplane/router_test.go index f021037..69879d0 100644 --- a/services/core/internal/controlplane/router_test.go +++ b/services/core/internal/controlplane/router_test.go @@ -201,6 +201,89 @@ func TestForgejoPushRequiresSignatureBeforeIgnoringUnsupportedEvent(t *testing.T } } +func TestWebhookSubscriptionRegistersFilterWithoutLeakingSecretRef(t *testing.T) { + router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default()) + + req := httptest.NewRequest(http.MethodPost, "/api/webhook-subscriptions", bytes.NewBufferString(`{ + "name": "nomadcode-develop-wakeup", + "target_url": "https://consumer.example.invalid/gito/events", + "events": ["branch.updated"], + "repo_id": "nomadcode", + "branch": "develop", + "secret_ref": "secret://gito/webhooks/nomadcode" + }`)) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + if bytes.Contains(rec.Body.Bytes(), []byte("secret://gito/webhooks/nomadcode")) || + bytes.Contains(rec.Body.Bytes(), []byte("secret_ref")) || + bytes.Contains(rec.Body.Bytes(), []byte("consumer.example.invalid")) { + t.Fatalf("subscription response leaked private subscription input: %s", rec.Body.String()) + } + var created map[string]map[string]any + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode subscription response: %v", err) + } + subscription := created["subscription"] + if subscription["id"] == "" { + t.Fatalf("missing subscription id: %#v", subscription) + } + if subscription["name"] != "nomadcode-develop-wakeup" || + subscription["repo_id"] != "nomadcode" || + subscription["branch"] != "develop" { + t.Fatalf("unexpected subscription payload: %#v", subscription) + } + events, ok := subscription["events"].([]any) + if !ok || len(events) != 1 || events[0] != "branch.updated" { + t.Fatalf("events: %#v", subscription["events"]) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/webhook-subscriptions", nil) + listRec := httptest.NewRecorder() + router.ServeHTTP(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list status: got %d body=%s", listRec.Code, listRec.Body.String()) + } + if bytes.Contains(listRec.Body.Bytes(), []byte("secret://gito/webhooks/nomadcode")) || + bytes.Contains(listRec.Body.Bytes(), []byte("secret_ref")) || + bytes.Contains(listRec.Body.Bytes(), []byte("consumer.example.invalid")) { + t.Fatalf("subscription list leaked private subscription input: %s", listRec.Body.String()) + } +} + +func TestWebhookSubscriptionValidatesTargetAndEvents(t *testing.T) { + router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default()) + + req := httptest.NewRequest(http.MethodPost, "/api/webhook-subscriptions", bytes.NewBufferString(`{ + "name": "invalid", + "target_url": "file:///tmp/event", + "events": ["branch.updated"] + }`)) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + + req = httptest.NewRequest(http.MethodPost, "/api/webhook-subscriptions", bytes.NewBufferString(`{ + "name": "missing-events", + "target_url": "https://consumer.example.invalid/gito/events" + }`)) + rec = httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("missing events status: got %d body=%s", rec.Code, rec.Body.String()) + } +} + func TestEventSubscribeRegistersConnectionFilter(t *testing.T) { dispatcher := protosocket.NewDispatcher() runtime := NewRuntime(nil) diff --git a/services/core/internal/controlplane/runtime.go b/services/core/internal/controlplane/runtime.go index 2a49f10..26edf71 100644 --- a/services/core/internal/controlplane/runtime.go +++ b/services/core/internal/controlplane/runtime.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net/url" "sort" "strings" "sync" @@ -24,11 +25,12 @@ type EventBroadcaster interface { } type Runtime struct { - mu sync.Mutex - watches map[string]BranchWatch - records []EventRecord - broadcaster EventBroadcaster - store storage.Store + mu sync.Mutex + watches map[string]BranchWatch + subscriptions map[string]WebhookSubscription + records []EventRecord + broadcaster EventBroadcaster + store storage.Store } type BranchWatch struct { @@ -49,10 +51,22 @@ type EventRecord struct { CreatedAt time.Time `json:"created_at"` } +type WebhookSubscription struct { + ID string + Name string + TargetURL string + Events []string + RepoID string + Branch string + SecretRef string + CreatedAt time.Time +} + func NewRuntime(broadcaster EventBroadcaster) *Runtime { return &Runtime{ - watches: make(map[string]BranchWatch), - broadcaster: broadcaster, + watches: make(map[string]BranchWatch), + subscriptions: make(map[string]WebhookSubscription), + broadcaster: broadcaster, } } @@ -60,9 +74,10 @@ func NewRuntime(broadcaster EventBroadcaster) *Runtime { // When store is nil the behavior is identical to NewRuntime. func NewRuntimeWithStore(broadcaster EventBroadcaster, store storage.Store) *Runtime { return &Runtime{ - watches: make(map[string]BranchWatch), - broadcaster: broadcaster, - store: store, + watches: make(map[string]BranchWatch), + subscriptions: make(map[string]WebhookSubscription), + broadcaster: broadcaster, + store: store, } } @@ -137,6 +152,73 @@ func (r *Runtime) ListBranchWatches() []BranchWatch { return watches } +func (r *Runtime) RegisterWebhookSubscription(name, targetURL string, events []string, repoID, branch, secretRef string) (WebhookSubscription, error) { + name = strings.TrimSpace(name) + targetURL = strings.TrimSpace(targetURL) + repoID = strings.TrimSpace(repoID) + branch = strings.TrimSpace(branch) + secretRef = strings.TrimSpace(secretRef) + events = normalizeEventNames(events) + if targetURL == "" { + return WebhookSubscription{}, fmt.Errorf("target_url is required") + } + parsedTarget, err := url.Parse(targetURL) + if err != nil || parsedTarget.Host == "" || (parsedTarget.Scheme != "http" && parsedTarget.Scheme != "https") { + return WebhookSubscription{}, fmt.Errorf("target_url must be an http or https URL") + } + if len(events) == 0 { + return WebhookSubscription{}, fmt.Errorf("events are required") + } + if name == "" { + name = "webhook-subscription" + } + + subscription := WebhookSubscription{ + ID: "webhook-sub-" + newID(), + Name: name, + TargetURL: targetURL, + Events: events, + RepoID: repoID, + Branch: branch, + SecretRef: secretRef, + CreatedAt: time.Now().UTC(), + } + r.mu.Lock() + defer r.mu.Unlock() + r.subscriptions[subscription.ID] = subscription + return subscription, nil +} + +func (r *Runtime) ListWebhookSubscriptions() []WebhookSubscription { + r.mu.Lock() + defer r.mu.Unlock() + subscriptions := make([]WebhookSubscription, 0, len(r.subscriptions)) + for _, subscription := range r.subscriptions { + subscriptions = append(subscriptions, subscription) + } + sort.Slice(subscriptions, func(i, j int) bool { + return subscriptions[i].ID < subscriptions[j].ID + }) + return subscriptions +} + +func normalizeEventNames(events []string) []string { + seen := make(map[string]struct{}, len(events)) + result := make([]string, 0, len(events)) + for _, event := range events { + event = strings.TrimSpace(event) + if event == "" { + continue + } + if _, ok := seen[event]; ok { + continue + } + seen[event] = struct{}{} + result = append(result, event) + } + return result +} + func (r *Runtime) ListEvents() []EventRecord { r.mu.Lock() defer r.mu.Unlock()