feat: webhook-revision-reconcile milestone implementation and roadmap update

- Add SDD documentation for webhook-revision-reconcile phase
- Add task breakdown for webhook-revision-reconcile
- Update ROADMAP.md with latest phase progress
- Update PHASE.md files for agent-shell-iop-execution and provider-change-request-integration
- Update milestone details for iop-agent-run-bridge and webhook-revision-reconcile
- Implement controlplane router and runtime changes
- Add router tests
This commit is contained in:
toki 2026-06-17 21:13:29 +09:00
parent 06f1c658fb
commit 64dd8a6664
13 changed files with 1274 additions and 43 deletions

View file

@ -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 흐름을 다룰 수 있게 한다.

View file

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

View file

@ -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 ./...` 통과로 확인했다.

View file

@ -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로 처리한다.

View file

@ -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 작성/승인 후 구현 계획 생성

View file

@ -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`이다.
<!-- 필요한 경우:
- `<project>:agent-roadmap/phase/<phase>/milestones/<milestone>.md`
- `.agent-roadmap-sync/locks.yaml` entry: `<id>`
-->
## 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: 없음

View file

@ -0,0 +1,158 @@
<!-- task=m-webhook-revision-reconcile/01_http_delivery plan=0 tag=API -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 |

View file

@ -0,0 +1,252 @@
<!-- task=m-webhook-revision-reconcile/01_http_delivery plan=0 tag=API -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,166 @@
<!-- task=m-webhook-revision-reconcile/02+01_retry_delivery_store plan=0 tag=API -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 |

View file

@ -0,0 +1,288 @@
<!-- task=m-webhook-revision-reconcile/02+01_retry_delivery_store plan=0 tag=API -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

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

View file

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

View file

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