feat(controlplane): provider poll revision과 reconcile plan을 반영한다

- provider polling 입력을 RevisionEvent로 정규화하는 HandleProviderPollRevision을 추가한다
- ProviderPollRevision 구조체로 before/after/changed_files 관찰 값을 받는다
- 기존 HandleRevision과 같은 identity dedupe와 branch.watch 매칭을 사용한다
- provider poll source duplicate 검증 테스트를 추가한다
- webhook-revision-reconcile 마일스톤 완료 상태를 동기화한다
- reconcile policy PLAN과 코드 리뷰를 추가한다
This commit is contained in:
toki 2026-06-18 16:48:41 +09:00
parent 7612e369c9
commit 918a608eb1
5 changed files with 559 additions and 8 deletions

View file

@ -50,18 +50,18 @@ NomadCode가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 받
정규화된 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 기준으로 기록한다.
- [x] [subscription] consumer endpoint, event type, repo/branch filter, `secret_ref`를 등록하는 형식을 정의한다.
- [x] [deliver] `branch.updated` 같은 normalized event를 signed HTTP POST로 전달한다. 검증: fake consumer endpoint가 delivery id와 payload를 수신한다.
- [x] [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`
- [x] [retry] delivery idempotency, retry/backoff, failed delivery 상태를 PostgreSQL 기준으로 기록한다.
### Epic: [sources] Change sources
여러 입력을 하나의 change event model로 정규화한다.
- [ ] [webhook-source] webhook payload를 wakeup signal event로 저장한다.
- [ ] [poll-source] provider polling fallback shape를 정의한다.
- [ ] [git-source] Git revision scan event와 provider event를 같은 identity로 연결한다.
- [x] [webhook-source] webhook payload를 wakeup signal event로 저장한다.
- [x] [poll-source] provider polling fallback shape를 정의한다.
- [x] [git-source] Git revision scan event와 provider event를 같은 identity로 연결한다.
### Epic: [reconcile] Reconcile policy
@ -95,4 +95,7 @@ NomadCode가 generic `branch.updated` HTTP webhook delivery를 wakeup으로 받
- 표준선(선택): 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 작성/승인 후 구현 계획 생성
- 작업현황 동기화(2026-06-18): `subscription`, `deliver`, `nomadcode-wakeup`, `retry`, `webhook-source`, `git-source`는 코드/테스트 근거로 완료 처리했다.
- 작업현황 동기화(2026-06-18): `poll-source`는 provider polling 입력 shape와 revision identity dedupe 테스트 근거로 완료 처리했다.
- 계획 작성(2026-06-18): `expected-rev`, `dry-run`, `apply``agent-task/m-webhook-revision-reconcile/PLAN-local-G05.md`에서 reconcile policy 작업으로 묶었다.
- 남은 기능 Task: `expected-rev`, `dry-run`, `apply`.

View file

@ -0,0 +1,159 @@
<!-- task=m-webhook-revision-reconcile 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-18
task=m-webhook-revision-reconcile, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/provider-change-request-integration/milestones/webhook-revision-reconcile.md`
- Task ids:
- `expected-rev`: expected revision이 맞지 않으면 conflict로 멈춘다.
- `dry-run`: reconcile dry-run 결과로 drift/conflict/proposal을 반환한다.
- `apply`: idempotency key로 중복 적용 없이 change를 반영한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
- Acceptance scenarios:
- `S08`: task=`expected-rev`; evidence=`expected revision mismatch test`
- `S09`: task=`dry-run`; evidence=`reconcile dry-run test with drift/conflict/proposal output`
- `S10`: task=`apply`; evidence=`idempotent apply test with repeated event/key`
- 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/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 연결된 SDD/Milestone 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Reconcile 모델과 expected revision conflict | [ ] |
| [API-2] Dry-run drift/conflict/proposal 결과 | [ ] |
| [API-3] Apply idempotency key와 side effect 1회 보장 | [ ] |
## 구현 체크리스트
- [ ] Reconcile request/result 모델과 expected revision conflict 경로를 구현하고 `TestRuntimeReconcileExpectedRevisionConflict`를 통과시킨다.
- [ ] Dry-run 결과가 drift/conflict/proposal을 반환하도록 구현하고 `TestRuntimeDryRunReconcileReportsDriftConflictProposal`를 통과시킨다.
- [ ] Apply 경로가 idempotency key로 side effect를 1회만 실행하도록 구현하고 `TestRuntimeApplyReconcileIsIdempotent`를 통과시킨다.
- [ ] 최종 검증 `cd services/core && go test ./...``git diff --check`를 실행한다.
- [ ] 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/``agent-task/archive/YYYY/MM/m-webhook-revision-reconcile/`로 이동하고 최종 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으로 처리한다._
- 상태: 없음
- 사유 유형: 없음
- 연결 대상: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- expected/current mismatch에서 apply side effect가 전혀 발생하지 않는지 확인한다.
- dry-run이 event append, webhook delivery, broadcast를 하지 않는지 확인한다.
- apply duplicate가 기존 `ProviderDeliveries` dedupe를 재사용해 event/envelope count를 늘리지 않는지 확인한다.
- `Roadmap Targets``Spec Targets`의 Task/Scenario id가 complete log에 복사 가능한 상태인지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```text
$ cd services/core && go test ./internal/controlplane -run TestRuntimeReconcileExpectedRevisionConflict -count=1
(output)
```
### API-2 중간 검증
```text
$ cd services/core && go test ./internal/controlplane -run TestRuntimeDryRunReconcileReportsDriftConflictProposal -count=1
(output)
```
### API-3 중간 검증
```text
$ cd services/core && go test ./internal/controlplane -run TestRuntimeApplyReconcileIsIdempotent -count=1
(output)
```
### 최종 검증
```text
$ cd services/core && go test ./internal/controlplane -run 'TestRuntimeReconcileExpectedRevisionConflict|TestRuntimeDryRunReconcileReportsDriftConflictProposal|TestRuntimeApplyReconcileIsIdempotent' -count=1
(output)
$ cd services/core && go test ./...
(output)
$ git diff --check
(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.

View file

@ -0,0 +1,272 @@
<!-- task=m-webhook-revision-reconcile plan=0 tag=API -->
# Plan - API
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이 필수 마지막 단계다. 구현 후 active 파일은 그대로 두고 리뷰 준비 상태를 보고한다. 선택된 SDD 결정 또는 선택된 Milestone `구현 잠금 > 결정 필요` 항목이 막으면 review stub의 `사용자 리뷰 요청`에 정확한 근거를 남기고 멈춘다. 직접 사용자에게 묻거나, 선택지를 채팅으로 제시하거나, `USER_REVIEW.md`, archive log, `complete.log`를 만들지 않는다. 환경/secret/서비스 차단과 검증 증거 공백은 사용자 리뷰 요청이 아니며 `검증 결과`나 후속 plan으로 남긴다.
## 배경
`poll-source`는 provider polling 입력 shape로 닫혔지만, reconcile 정책 Task 세 개가 아직 남아 있다. 현재 runtime은 `branch.updated` 이벤트와 revision identity dedupe는 갖고 있으나 expected revision conflict, dry-run result, apply idempotency를 하나의 실행 표면으로 제공하지 않는다. 이 계획은 기존 `ProviderDeliveries` dedupe와 `HandleRevision` side effect를 재사용해 작은 API 표면으로 S08-S10을 닫는다.
## 사용자 리뷰 요청 흐름
사용자 리뷰 요청은 선택된 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:
- `expected-rev`: expected revision이 맞지 않으면 conflict로 멈춘다.
- `dry-run`: reconcile dry-run 결과로 drift/conflict/proposal을 반환한다.
- `apply`: idempotency key로 중복 적용 없이 change를 반영한다.
- Completion mode: check-on-pass
## Spec Targets
- SDD: `agent-roadmap/sdd/provider-change-request-integration/webhook-revision-reconcile/SDD.md`
- Acceptance scenarios:
- `S08`: task=`expected-rev`; evidence=`expected revision mismatch test`
- `S09`: task=`dry-run`; evidence=`reconcile dry-run test with drift/conflict/proposal output`
- `S10`: task=`apply`; evidence=`idempotent apply test with repeated event/key`
- Completion mode: spec-check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-test/local/rules.md`
- `agent-test/local/core-smoke.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`
- `services/core/internal/controlplane/runtime.go`
- `services/core/internal/controlplane/runtime_test.go`
- `services/core/internal/worker/runner.go`
- `services/core/internal/worker/runner_test.go`
- `services/core/internal/core/model.go`
- `.gitignore`
### 테스트 환경 규칙
- test_env: local.
- `agent-test/local/rules.md`는 존재하며 읽었다.
- 매칭 profile: `agent-test/local/core-smoke.md`.
- 적용 명령: core Go 변경은 `cd services/core && go test ./...`.
- focused 검증은 새 controlplane 테스트에 대해 `cd services/core && go test ./internal/controlplane -run 'TestRuntimeReconcileExpectedRevisionConflict|TestRuntimeDryRunReconcileReportsDriftConflictProposal|TestRuntimeApplyReconcileIsIdempotent' -count=1`.
- 구조상 빈 규칙이나 `<확인 필요>` 값은 없다.
### 테스트 커버리지 공백
- expected revision mismatch: 현재 coverage 없음. 새 `TestRuntimeReconcileExpectedRevisionConflict` 필요.
- dry-run drift/conflict/proposal: 현재 coverage 없음. 새 `TestRuntimeDryRunReconcileReportsDriftConflictProposal` 필요.
- idempotent apply: `ProviderDelivery` duplicate coverage는 있으나 reconcile idempotency key apply coverage는 없음. 새 `TestRuntimeApplyReconcileIsIdempotent` 필요.
### 심볼 참조
- 이름 변경/삭제 없음.
### 분할 판단
- split decision policy를 먼저 평가했다.
- 대상은 모두 `services/core/internal/controlplane/runtime.go`의 단일 runtime API와 `runtime_test.go` 테스트에 묶인다.
- `apply``dry-run` 결과를 재사용하지만 별도 선행 `complete.log`가 필요한 ownership/API rollout 경계는 아니다.
- DB schema, provider adapter, worker rollout은 제외하므로 multi-plan split보다 단일 plan이 리뷰와 completion target 정합성에 더 안전하다.
### 범위 결정 근거
- `services/core/internal/worker/**`는 호출자 rollout을 하지 않는다. 기존 worker revision scan은 이미 source Task에 해당하며 reconcile action 후보와 분리한다.
- `services/core/internal/storage/**`와 migration은 수정하지 않는다. `ProviderDeliveries` store의 기존 idempotency를 재사용한다.
- 외부 REST/proto-socket endpoint는 추가하지 않는다. 이 Milestone Task는 action 후보와 runtime API 검증으로 닫는다.
- provider-specific API 호출은 추가하지 않는다.
### 빌드 등급
- local-G05: 단일 core runtime API와 targeted unit tests로 검증 가능하지만 idempotent side effect와 SDD completion anchor가 있어 중간 위험이다.
## 구현 체크리스트
- [ ] Reconcile request/result 모델과 expected revision conflict 경로를 구현하고 `TestRuntimeReconcileExpectedRevisionConflict`를 통과시킨다.
- [ ] Dry-run 결과가 drift/conflict/proposal을 반환하도록 구현하고 `TestRuntimeDryRunReconcileReportsDriftConflictProposal`를 통과시킨다.
- [ ] Apply 경로가 idempotency key로 side effect를 1회만 실행하도록 구현하고 `TestRuntimeApplyReconcileIsIdempotent`를 통과시킨다.
- [ ] 최종 검증 `cd services/core && go test ./...``git diff --check`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] Reconcile 모델과 expected revision conflict
#### 문제
[runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:55)는 event record와 poll shape만 제공하고 reconcile request/result가 없다. expected revision mismatch를 apply 없이 conflict로 멈추는 API와 테스트가 없다.
Before:
```go
55 type EventRecord struct {
56 ID string `json:"id"`
57 Type string `json:"type"`
58 Provider string `json:"provider"`
59 DeliveryID string `json:"delivery_id,omitempty"`
60 Duplicate bool `json:"duplicate,omitempty"`
61 Revision core.RevisionEvent `json:"revision"`
62 CreatedAt time.Time `json:"created_at"`
63 }
65 type ProviderPollRevision struct {
```
#### 해결 방법
`ProviderPollRevision` 뒤에 `ReconcileStatus`, `ReconcileRequest`, `ReconcileProposal`, `ReconcileResult`를 추가한다. `DryRunReconcile(ctx, req)`에서 `repo_id`, `branch`, `expected_revision`, `current_revision`, `target_revision`을 trim/검증하고 `ExpectedRevision != CurrentRevision`이면 `Status=conflict`, `Conflict=true`, `Applied=false`로 반환한다.
After:
```go
type ReconcileStatus string
const (
ReconcileStatusNoop ReconcileStatus = "noop"
ReconcileStatusConflict ReconcileStatus = "conflict"
ReconcileStatusProposal ReconcileStatus = "proposal"
ReconcileStatusApplied ReconcileStatus = "applied"
ReconcileStatusDuplicate ReconcileStatus = "duplicate"
)
type ReconcileRequest struct {
Provider string
RepoID string
Branch string
ExpectedRevision string
CurrentRevision string
TargetRevision string
ChangedFiles []core.ChangedFile
IdempotencyKey string
ObservedAt time.Time
}
```
#### 수정 파일 및 체크리스트
- [ ] `services/core/internal/controlplane/runtime.go`: reconcile status/request/result 타입 추가.
- [ ] `services/core/internal/controlplane/runtime.go`: validation helper 또는 `DryRunReconcile` 내부 검증 추가.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntimeReconcileExpectedRevisionConflict` 추가.
#### 테스트 작성
- 작성: `services/core/internal/controlplane/runtime_test.go`.
- 테스트명: `TestRuntimeReconcileExpectedRevisionConflict`.
- assertion: expected/current mismatch는 conflict result를 반환하고, `r.ListEvents()`와 fake broadcaster envelope count가 증가하지 않는다.
#### 중간 검증
```bash
cd services/core && go test ./internal/controlplane -run TestRuntimeReconcileExpectedRevisionConflict -count=1
```
### [API-2] Dry-run drift/conflict/proposal 결과
#### 문제
[runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:607)의 poll handler는 revision event를 바로 처리한다. [runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:816)의 scan path도 변경을 바로 event로 전환한다. apply 없이 drift/conflict/proposal을 관찰하는 dry-run API가 없다.
Before:
```go
607 func (r *Runtime) HandleProviderPollRevision(ctx context.Context, poll ProviderPollRevision) (EventRecord, bool, error) {
...
631 return r.HandleRevision(ctx, provider, "", revision)
632 }
816 func (r *Runtime) ScanBranchRevision(ctx context.Context, opts ScanRevisionOptions) (EventRecord, bool, error) {
```
#### 해결 방법
`DryRunReconcile`이 conflict가 아니면 `TargetRevision == CurrentRevision`일 때 `noop`, 다르면 `Status=proposal`, `Drift=true`, `Proposal.Before=CurrentRevision`, `Proposal.After=TargetRevision`, `ChangedFiles`를 반환한다. 이벤트 append, delivery, broadcast를 하지 않는다.
#### 수정 파일 및 체크리스트
- [ ] `services/core/internal/controlplane/runtime.go`: `DryRunReconcile` 구현.
- [ ] `services/core/internal/controlplane/runtime.go`: result에 `Drift`, `Conflict`, `Proposal`, `Reason` 필드 포함.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntimeDryRunReconcileReportsDriftConflictProposal` 추가.
#### 테스트 작성
- 작성: `services/core/internal/controlplane/runtime_test.go`.
- 테스트명: `TestRuntimeDryRunReconcileReportsDriftConflictProposal`.
- assertion: conflict case, noop case, proposal/drift case를 모두 검증하고 dry-run이 side effect를 만들지 않음을 확인한다.
#### 중간 검증
```bash
cd services/core && go test ./internal/controlplane -run TestRuntimeDryRunReconcileReportsDriftConflictProposal -count=1
```
### [API-3] Apply idempotency key와 side effect 1회 보장
#### 문제
[runtime.go](/config/workspace/gito/services/core/internal/controlplane/runtime.go:634)의 durable path는 provider delivery id 또는 revision identity로 dedupe한다. reconcile apply가 idempotency key를 필수로 받아 같은 apply 재처리 시 side effect를 한 번만 실행하는 API는 없다.
Before:
```go
643 var dedupeKey string
644 if r.store.ProviderDeliveries() != nil {
645 dedupeKey = dedupeKeyFor(deliveryID, revision)
646 delivery := core.ProviderDelivery{
...
661 if !result.First {
662 // Return idempotent response using original delivery's event id and timestamp.
663 record.ID = result.ExistingEventID
664 record.CreatedAt = result.ExistingCreatedAt
665 record.Duplicate = true
666 return record, true, nil
667 }
```
#### 해결 방법
`ApplyReconcile(ctx, req)`을 추가한다. 먼저 `DryRunReconcile`을 호출해 conflict면 바로 반환한다. apply는 `IdempotencyKey`와 durable `ProviderDeliveries` store를 요구한다. 적용 시 `HandleRevision(ctx, provider, "reconcile:"+IdempotencyKey, revision)`을 호출해 기존 dedupe path를 재사용한다. duplicate record는 `Status=duplicate`, `Duplicate=true`, `Applied=false`로 반환하고 webhook/broadcast side effect가 늘지 않아야 한다.
#### 수정 파일 및 체크리스트
- [ ] `services/core/internal/controlplane/runtime.go`: `ApplyReconcile` 구현.
- [ ] `services/core/internal/controlplane/runtime.go`: missing idempotency key 또는 missing durable dedupe store error 처리.
- [ ] `services/core/internal/controlplane/runtime_test.go`: `TestRuntimeApplyReconcileIsIdempotent` 추가.
#### 테스트 작성
- 작성: `services/core/internal/controlplane/runtime_test.go`.
- 테스트명: `TestRuntimeApplyReconcileIsIdempotent`.
- assertion: 첫 apply는 event와 broadcaster envelope를 1개 만들고 cursor를 target revision으로 갱신한다. 같은 idempotency key 재호출은 duplicate result를 반환하고 event/envelope count를 늘리지 않는다. expected/current mismatch apply는 side effect 없이 conflict로 끝난다.
#### 중간 검증
```bash
cd services/core && go test ./internal/controlplane -run TestRuntimeApplyReconcileIsIdempotent -count=1
```
## 의존 관계 및 구현 순서
1. `API-1` 타입과 conflict 결과를 먼저 구현한다.
2. `API-2` dry-run이 `API-1` validation/result를 재사용한다.
3. `API-3` apply가 `API-2` dry-run 결과와 기존 `HandleRevision` durable dedupe를 재사용한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `services/core/internal/controlplane/runtime.go` | API-1, API-2, API-3 |
| `services/core/internal/controlplane/runtime_test.go` | API-1, API-2, API-3 |
## 최종 검증
```bash
cd services/core && go test ./internal/controlplane -run 'TestRuntimeReconcileExpectedRevisionConflict|TestRuntimeDryRunReconcileReportsDriftConflictProposal|TestRuntimeApplyReconcileIsIdempotent' -count=1
cd services/core && go test ./...
git diff --check
```
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -62,6 +62,16 @@ type EventRecord struct {
CreatedAt time.Time `json:"created_at"`
}
type ProviderPollRevision struct {
Provider string
RepoID string
Branch string
Before string
After string
ChangedFiles []core.ChangedFile
ObservedAt time.Time
}
type WebhookSubscription struct {
ID string
Name string
@ -594,6 +604,33 @@ func (r *Runtime) HandleRevision(ctx context.Context, provider, deliveryID strin
return record, true, nil
}
func (r *Runtime) HandleProviderPollRevision(ctx context.Context, poll ProviderPollRevision) (EventRecord, bool, error) {
provider := strings.TrimSpace(poll.Provider)
if provider == "" {
provider = "forgejo"
}
repoID := strings.TrimSpace(poll.RepoID)
branch := strings.TrimSpace(poll.Branch)
before := strings.TrimSpace(poll.Before)
after := strings.TrimSpace(poll.After)
if repoID == "" || branch == "" || before == "" || after == "" {
return EventRecord{}, false, fmt.Errorf("repo_id, branch, before, and after are required")
}
observedAt := poll.ObservedAt
if observedAt.IsZero() {
observedAt = time.Now().UTC()
}
revision := core.RevisionEvent{
RepoID: repoID,
Branch: branch,
Before: before,
After: after,
ChangedFiles: append([]core.ChangedFile(nil), poll.ChangedFiles...),
ObservedAt: observedAt,
}
return r.HandleRevision(ctx, provider, "", revision)
}
func (r *Runtime) handleRevisionWithStore(ctx context.Context, provider, deliveryID string, revision core.RevisionEvent, record EventRecord) (EventRecord, bool, error) {
_, matched, err := r.store.BranchWatches().FindBranchWatch(ctx, provider, revision.RepoID, revision.Branch)
if err != nil {

View file

@ -694,6 +694,86 @@ func TestRuntimeScanBranchRevisionBuildsRevisionEvent(t *testing.T) {
}
}
func TestRuntimeHandleProviderPollRevisionUsesRevisionIdentity(t *testing.T) {
store := newFakeStore()
broadcaster := &fakeBroadcaster{}
r := NewRuntimeWithStore(broadcaster, store)
_, err := r.RegisterBranchWatch("nomadcode", "develop", "forgejo")
if err != nil {
t.Fatalf("register watch: %v", err)
}
observedAt := time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)
poll := ProviderPollRevision{
Provider: "forgejo",
RepoID: "nomadcode",
Branch: "develop",
Before: "rev-before",
After: "rev-after",
ChangedFiles: []core.ChangedFile{
{Path: "README.md", ChangeType: "modified"},
},
ObservedAt: observedAt,
}
record, matched, err := r.HandleProviderPollRevision(context.Background(), poll)
if err != nil {
t.Fatalf("handle provider poll revision: %v", err)
}
if !matched {
t.Fatal("expected provider poll revision to match branch watch")
}
if record.Type != events.BranchUpdated {
t.Fatalf("event type: got %q want %q", record.Type, events.BranchUpdated)
}
if record.Provider != "forgejo" {
t.Fatalf("provider: got %q want forgejo", record.Provider)
}
if record.DeliveryID != "" {
t.Fatalf("provider poll source must not require provider delivery id, got %q", record.DeliveryID)
}
if record.Revision.RepoID != "nomadcode" ||
record.Revision.Branch != "develop" ||
record.Revision.Before != "rev-before" ||
record.Revision.After != "rev-after" {
t.Fatalf("revision identity mismatch: %+v", record.Revision)
}
if !record.Revision.ObservedAt.Equal(observedAt) {
t.Fatalf("observed_at: got %s want %s", record.Revision.ObservedAt, observedAt)
}
dedupeKey := "revision:nomadcode:develop:rev-before:rev-after"
if got := store.deliveries.records[dedupeKey]; got.EventID != record.ID {
t.Fatalf("poll source dedupe key mismatch: got %+v want event_id %q", got, record.ID)
}
if len(broadcaster.envelopes) != 1 {
t.Fatalf("expected one branch.updated envelope, got %d", len(broadcaster.envelopes))
}
payload := broadcaster.envelopes[0].Payload
if payload["repo_id"] != "nomadcode" || payload["branch"] != "develop" ||
payload["before"] != "rev-before" || payload["after"] != "rev-after" {
t.Fatalf("poll source payload identity mismatch: %#v", payload)
}
duplicate, matched, err := r.HandleProviderPollRevision(context.Background(), poll)
if err != nil {
t.Fatalf("handle duplicate provider poll revision: %v", err)
}
if !matched {
t.Fatal("expected duplicate provider poll revision to still match branch watch")
}
if !duplicate.Duplicate {
t.Fatal("expected duplicate provider poll revision to be marked duplicate")
}
if duplicate.ID != record.ID {
t.Fatalf("duplicate event id: got %q want %q", duplicate.ID, record.ID)
}
if len(r.ListEvents()) != 1 {
t.Fatalf("duplicate poll source must not append another event, got %d", len(r.ListEvents()))
}
}
type fakeBroadcaster struct {
envelopes []protosocket.Envelope
err error