diff --git a/README.md b/README.md index c2fcb49..cefe619 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,11 @@ flutter run | 표면 | 현재 compatibility baseline | workspace 기준 | 비고 | |------|-----------------------------|----------------|------| -| Flutter web/code-server preview | local smoke에서 빈 포트를 고르고 `/proxy//` base href를 사용합니다. 기존 예시는 `8081`입니다. | 안정적인 workspace preview는 `13010`부터 사용하고 병렬 preview는 `13011`, `13012`처럼 증가시킵니다. | code-server가 proxy prefix를 유지하거나 제거해도 정적 리소스가 열려야 합니다. | +| Flutter web/code-server preview | local smoke에서 빈 포트를 고르고 `/proxy//` base href를 사용합니다. 기존 예시는 `8081`입니다. | remote local profile은 `13010`, remote dev profile은 `13011`입니다. 병렬 임시 preview는 이후 빈 포트를 고릅니다. | code-server가 proxy prefix를 유지하거나 제거해도 정적 리소스가 열려야 합니다. | | code-server workspace entry | client mock workspace URL은 `http://localhost:8080/?folder=...`를 사용합니다. | 유지 | 이 값은 code-server entry URL 호환성이고 Flutter preview 포트가 아닙니다. | -| Core HTTP/API | process/container 내부 기본값은 `HTTP_ADDR=:8080`, Docker `EXPOSE 8080`, local curl 예시는 `localhost:8080`입니다. | compose host publish 기본값은 `18010:8080`입니다. | 기존 `8080` local default는 migration compatibility로 유지하고, compose에서 필요하면 `NOMADCODE_CORE_HOST_PORT=8080`으로 되돌립니다. | -| PostgreSQL | compose와 local script는 `code-server-postgres:5432` service DNS를 사용합니다. | host 노출이 꼭 필요하면 `15410:5432`를 후보로 둡니다. | 기본은 service DNS이며 host publish를 추가하지 않습니다. | -| Redis | compose와 local script는 `code-server-redis:6379` service DNS를 사용합니다. | host 노출이 꼭 필요하면 `16310:6379`를 후보로 둡니다. | 기본은 service DNS이며 host publish를 추가하지 않습니다. | +| Core HTTP/API | process/container 내부 기본값은 `HTTP_ADDR=:8080`, Docker `EXPOSE 8080`, local curl 예시는 `localhost:8080`입니다. | remote local profile은 `18010:8080`, remote dev profile은 `18011:8080`입니다. | 기존 `8080` local default는 migration compatibility로 유지하고, compose에서 필요하면 `NOMADCODE_CORE_HOST_PORT=8080`으로 되돌립니다. | +| PostgreSQL | compose와 local script는 `code-server-postgres:5432` service DNS를 사용합니다. | host 노출이 꼭 필요하면 local `15410:5432`, dev `15411:5432`를 후보로 둡니다. | 기본은 service DNS이며 host publish를 추가하지 않습니다. | +| Redis | compose와 local script는 `code-server-redis:6379` service DNS를 사용합니다. | host 노출이 꼭 필요하면 local `16310:6379`, dev `16311:6379`를 후보로 둡니다. | 기본은 service DNS이며 host publish를 추가하지 않습니다. | | IOP Edge / provider endpoints | `MODEL_*`, `A2A_*`, `PROTO_SOCKET_*`, `PLANE_*`, `JIRA_*`, `MATTERMOST_*` env로 분리합니다. | public port를 예약하지 않고 각 provider endpoint를 env로 주입합니다. | token, password, API key 원문은 tracked 문서에 쓰지 않고 ignored local env/secret 파일에서만 관리합니다. | ## 구조 @@ -138,14 +138,14 @@ IOP 경계가 걸린 작업은 같은 workspace에 sibling IOP repository가 있 | Core | `DATABASE_URL` | 스크립트 사용 시 선택 | Core script는 local 기본값을 제공합니다. 다른 PostgreSQL을 쓰거나 binary를 직접 실행할 때 설정합니다. | | Core | `REDIS_URL`, `REDIS_KEY_PREFIX` | 스크립트 사용 시 선택 | worker/queue 상태에 사용할 Redis 주소와 key prefix입니다. | | Core | `AUTH_USERNAME`, `AUTH_PASSWORD` | 선택 | `AUTH_PASSWORD`를 설정하면 `/readyz`와 `/api/*`에 HTTP Basic Auth가 적용됩니다. | -| Core | `NOMADCODE_CORE_HOST_PORT` | 선택 | Docker Compose host publish 포트입니다. 기본값은 remote/local test workspace 기준 `18010`이고 container 내부는 `8080`입니다. | +| Core | `NOMADCODE_CORE_HOST_PORT` | 선택 | Docker Compose host publish 포트입니다. remote local 기본값은 `18010`, remote dev 기준은 `18011`이고 container 내부는 `8080`입니다. | | Core | `MODEL_BASE_URL`, `MODEL_API_KEY`, `MODEL_NAME`, `MODEL_CONTEXT_SIZE`, `MODEL_TIMEOUT_SEC` | 선택 | worker execution에서 사용할 IOP Edge OpenAI-compatible Responses endpoint 설정입니다. IOP/NomadCode 전용 실행 문맥은 요청 `metadata`로 전달하는 방향을 기준으로 합니다. | | Core | `A2A_EDGE_URL`, `A2A_AGENT_URL`, `A2A_TOKEN`, `A2A_TIMEOUT_SEC` | 선택 | 향후 A2A-compatible agent delegation 경로 설정입니다. 현재 기본 실행 경로는 아닙니다. | | Core/Client | `PROTO_SOCKET_PATH`, `PROTO_SOCKET_HEARTBEAT_INTERVAL_SEC`, `PROTO_SOCKET_HEARTBEAT_WAIT_SEC`, `PROTO_SOCKET_HOST`, `PROTO_SOCKET_PORT`, `PROTO_SOCKET_SECURE`, `PROTO_SOCKET_ENABLED`, `PROTO_SOCKET_HEARTBEAT_INTERVAL_SECONDS`, `PROTO_SOCKET_HEARTBEAT_WAIT_SECONDS` | 선택 | Core proto-socket endpoint와 Flutter client connector 설정입니다. Client는 host가 없으면 연결을 비활성화합니다. | | Core | `WORKFLOW_TASK_TIMEOUT_SEC` | 선택 | workflow task timeout 기본값을 조정합니다. | | Core | `MATTERMOST_BASE_URL`, `MATTERMOST_TOKEN`, `MATTERMOST_CHANNEL_ID` | 선택 | Mattermost task completion message 발송용 설정입니다. token 값은 문서에 기록하지 않습니다. | | Core | `PLANE_BASE_URL`, `PLANE_TOKEN` | 선택 | Plane work item 조회, comment, state update 연동용 설정입니다. token 값은 문서에 기록하지 않습니다. | -| Core | `GITO_WEBHOOK_SECRET`, `GITO_REPO_ID`, `GITO_DEVELOP_REPO_PATH`, `GITO_BRANCH`, `GITO_REMOTE_NAME`, `ROADMAP_CREATION_TODO_STATE_ID`, `PLANE_TODO_STATE_ID`, `GITO_PROTO_SOCKET_URL` | 선택 | Gito signed HTTP webhook `branch.updated` consumer 설정입니다. signing secret, repo id, local develop checkout, Todo state id가 모두 있을 때 HTTP consumer가 켜집니다. `GITO_PROTO_SOCKET_URL`은 이전 proto-socket 호환 경로가 필요할 때만 설정합니다. `ROADMAP_CREATION_TODO_STATE_ID`가 `PLANE_TODO_STATE_ID`보다 우선합니다. | +| Core | `GITO_WEBHOOK_SECRET`, `GITO_REPO_ID`, `GITO_DEVELOP_REPO_PATH`, `GITO_BRANCH`, `GITO_REMOTE_NAME`, `ROADMAP_CREATION_TODO_STATE_ID`, `PLANE_TODO_STATE_ID`, `GITO_PROTO_SOCKET_URL` | 선택 | Gito signed HTTP webhook `branch.updated` consumer 설정입니다. signing secret, repo id, local develop checkout, Todo state id가 모두 있을 때 HTTP consumer가 켜집니다. Gito local `18060`은 NomadCode local `18010`으로, Gito dev `18061`은 NomadCode dev `18011`로 delivery합니다. `GITO_PROTO_SOCKET_URL`은 이전 proto-socket 호환 경로가 필요할 때만 설정합니다. `ROADMAP_CREATION_TODO_STATE_ID`가 `PLANE_TODO_STATE_ID`보다 우선합니다. | | Core | `JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN` | 선택 | Jira issue 조회, comment, status transition 연동용 설정입니다. Jira Cloud Basic auth(`Email:APIToken`) 형식을 사용합니다. | ## 참고 문서 diff --git a/agent-contract/index.md b/agent-contract/index.md new file mode 100644 index 0000000..5e28415 --- /dev/null +++ b/agent-contract/index.md @@ -0,0 +1,17 @@ +# Agent Contract Index + +## 읽기 규칙 + +외부 API, 런타임 호출, 프로젝트 간 연동, 요청/응답 스키마 계약을 확인해야 하는 작업에서만 이 index를 읽는다. 매칭되는 제공/소비 계약만 추가로 읽고, 계약 원문을 README, docs, rules에 복제하지 않는다. + +## 제공 계약 + +| id | 읽는 조건 | path | +| --- | --- | --- | +| 없음 | 없음 | 없음 | + +## 소비 계약 + +| id | 읽는 조건 | source | +| --- | --- | --- | +| `nomadcode.gito-branch-webhook-consumer.v1` | Gito `branch.updated` HTTP webhook 소비, `POST /api/integrations/gito/webhook`, Gito/NomadCode local/dev port profiles `18060/18061` and `18010/18011`, `GITO_WEBHOOK_SECRET`, `GITO_REPO_ID`, `GITO_DEVELOP_REPO_PATH` | `agent-contract/outer/gito-branch-webhook-consumer-v1.md` | diff --git a/agent-contract/outer/gito-branch-webhook-consumer-v1.md b/agent-contract/outer/gito-branch-webhook-consumer-v1.md new file mode 100644 index 0000000..385e58c --- /dev/null +++ b/agent-contract/outer/gito-branch-webhook-consumer-v1.md @@ -0,0 +1,109 @@ +# Contract: nomadcode.gito-branch-webhook-consumer.v1 + +## 범위 + +이 계약은 NomadCode Core가 Gito의 signed HTTP `branch.updated` webhook delivery를 수신해 develop checkout fetch/scan 및 roadmap creation sync wakeup으로 넘기는 외부 소비 경계를 정의한다. + +원문 제공 계약은 Gito `agent-contract/provided/gito-forgejo-branch-events-v1.md`의 `gito.forgejo-branch-events.v1`이다. 이 문서는 NomadCode가 소비자로서 강제하는 endpoint, headers, enablement, response 의미만 기록한다. + +## 비범위 + +- Forgejo/Git provider webhook을 직접 수신하거나 정규화하는 책임은 Gito에 있다. +- Gito event 생성, branch watch, webhook subscription 저장/전송 책임은 Gito에 있다. +- IOP model execution port와 Responses API 동작은 NomadCode task execution dependency이며 이 webhook 소비 계약의 schema가 아니다. + +## Endpoint + +### `POST /api/integrations/gito/webhook` + +Gito가 delivery하는 `branch.updated` JSON payload를 수신한다. Basic Auth를 우회하는 public callback 경계이며, request body HMAC 서명으로 검증한다. + +Headers: + +| Field | Required | Meaning | +| --- | --- | --- | +| `X-Gito-Event` | 필수 | 고정값 `branch.updated`. 다른 값은 `400`으로 거부한다. | +| `X-Gito-Delivery` | 필수 | stable delivery id. HTTP layer idempotency key로 사용한다. | +| `X-Gito-Signature` | 필수 | request body에 대한 HMAC-SHA256 hex digest. `sha256=` prefix와 raw hex를 모두 허용한다. | + +Request body minimum shape: + +```json +{ + "type": "branch.updated", + "repo_id": "nomadcode", + "branch": "develop", + "before": "old-sha", + "after": "new-sha", + "changed_files": [ + { + "path": "README.md", + "change_type": "modified" + } + ] +} +``` + +Accepted payload rules: + +| Field | Required | Meaning | +| --- | --- | --- | +| `type` | 선택 | 있으면 `branch.updated`여야 한다. | +| `repo_id` | 필수 | `GITO_REPO_ID`와 같을 때만 처리 대상이다. | +| `branch` | 필수 | `GITO_BRANCH` 또는 기본값 `develop`과 같을 때만 처리 대상이다. | +| `before` | 선택 | 이전 revision SHA. 내부 bridge로 전달한다. | +| `after` | 선택 | 새 revision SHA. 있으면 revision idempotency key에 포함한다. | +| `changed_files` | 선택 | 있으면 object list여야 한다. wakeup 힌트이며 source-of-truth로 단정하지 않는다. | + +Response handling: + +| Status | Body | Meaning | +| --- | --- | --- | +| `202` | `{"status":"accepted"}` | 대상 repo/branch event를 처리했다. | +| `202` | `{"status":"ignored"}` | repo 또는 branch가 target과 달라 side effect 없이 ack했다. | +| `202` | `{"status":"duplicate"}` | 같은 delivery id 또는 같은 `repo_id:branch:after` revision을 이미 처리했다. | +| `400` | error object | event type, delivery id, JSON, payload shape가 잘못됐다. | +| `401` | error object | signature가 없거나 검증에 실패했다. | +| `413` | error object | body가 1 MiB 제한을 넘었다. | +| `500` | error object | 내부 handler가 실패했다. 재시도 가능하다. | +| `503` | error object | consumer enablement 입력이 준비되지 않았다. | + +## Enablement + +NomadCode Core는 아래 값이 모두 있을 때 Gito HTTP webhook consumer를 wiring한다. + +| Env | Required | Meaning | +| --- | --- | --- | +| `GITO_WEBHOOK_SECRET` | 필수 | `X-Gito-Signature` 검증용 signing secret. raw value는 tracked docs에 쓰지 않는다. | +| `GITO_REPO_ID` | 필수 | target repo id. | +| `GITO_DEVELOP_REPO_PATH` | 필수 | fetch/scan할 develop checkout path. Core container에서 접근 가능해야 한다. | +| `ROADMAP_CREATION_TODO_STATE_ID` 또는 `PLANE_TODO_STATE_ID` | 필수 | Plane-origin roadmap creation sync projection 대상 Todo state id. | +| `GITO_BRANCH` | 선택 | 기본값 `develop`. | +| `GITO_REMOTE_NAME` | 선택 | 기본값 `origin`. | + +## Environment Port Profiles + +포트 profile은 같은 remote host에서 local용 환경과 dev용 환경을 동시에 분리 운영하기 위한 기본값이다. Endpoint path, headers, body schema는 profile과 무관하게 동일하다. + +| Profile | NomadCode Core URL | Gito source URL | Preview | DB/cache 후보 | Subscription target | +| --- | --- | --- | ---: | ---: | --- | +| remote local | `http://127.0.0.1:18010` | `http://127.0.0.1:18060` | `13010` | `15410/16310` | `http://127.0.0.1:18010/api/integrations/gito/webhook` | +| remote dev | `http://127.0.0.1:18011` | `http://127.0.0.1:18061` | `13011` | `15411/16311` | `http://127.0.0.1:18011/api/integrations/gito/webhook` | + +NomadCode Core container 내부 listen은 `8080`을 유지하고 host publish만 `18010` 또는 `18011`로 분리한다. IOP dev model endpoint `http://toki-labs.com:18083/v1`은 NomadCode 실행 dependency이며 이 계약이 소유하는 port가 아니다. + +## Consumer Responsibilities + +- NomadCode는 `branch.updated`를 wakeup signal로만 받아들인다. +- NomadCode는 Plane, agent-roadmap, 기타 source-of-truth mutation 전에 target checkout을 fetch/scan해 실제 revision 상태를 재확인한다. +- `changed_files`는 relevance 판단 힌트이며 단독 증거가 아니다. +- raw webhook secret, token, provider credential, private endpoint 원문은 tracked 문서나 로그에 기록하지 않는다. + +## Evidence + +- `services/core/internal/http/gito_webhook.go` +- `services/core/internal/config/config.go` +- `services/core/internal/http/router.go` +- `services/core/docker-compose.yml` +- `services/core/README.md` +- `../gito/agent-contract/provided/gito-forgejo-branch-events-v1.md` diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md index 1c12e40..5715d73 100644 --- a/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md @@ -6,7 +6,7 @@ ## 목표 -로드맵 기반 agent-ops 운영 루프와 Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인을 Core action 경계로 정리한다. 외부 agent 제어 표면은 현재 확정하지 않고 후속 스케치로 분리한다. 현재는 Roadmap Driven Agent-Ops Automation까지 완료/archive했고, 다음 후보는 Milestone Execution Lifecycle Sync다. +로드맵 기반 agent-ops 운영 루프와 Plane/Jira 같은 work item provider와 Milestone item의 양방향 동기화 도메인을 Core action 경계로 정리한다. 외부 agent 제어 표면은 현재 확정하지 않고 후속 스케치로 분리한다. 현재는 Roadmap Driven Agent-Ops Automation까지 완료/archive했고, 다음 후보는 agent-origin roadmap push를 Plane 생성/identity backfill로 수렴시키는 Agent-Origin Milestone Creation Sync다. ## Milestone 흐름 @@ -46,9 +46,13 @@ - 경로: `agent-roadmap/archive/phase/agent-ops-mcp-control-plane/milestones/roadmap-driven-agent-ops-automation.md` - 요약: roadmap/action core와 Plane/Jira 기반 Milestone item 동기화의 상위 방향과 계약 후보를 정리했고, 코드 레벨 종료 검토 후 archive했다. +- [계획] Agent-Origin Milestone Creation Sync + - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md` + - 요약: agent 또는 사람이 active Milestone을 직접 수정해 `develop`에 push한 경우 Gito revision 검증 후 Plane work item 생성/갱신, provider identity ledger 기록, identity backfill commit/push, self-loop guard까지 닫는다. + - [계획] Milestone Execution Lifecycle Sync - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-execution-lifecycle-sync.md` - - 요약: Todo 이후 In Progress 실행, 하위 티켓, plan/code-review, 완료/폐기 흐름은 사용자가 명시적으로 해제할 때까지 구현 잠금 상태로 둔다. + - 요약: Todo 이후 In Progress 실행, 하위 티켓, plan/code-review, 완료/폐기 흐름은 Agent-Origin Milestone Creation Sync 이후 사용자가 명시적으로 해제할 때까지 구현 잠금 상태로 둔다. - [스케치] MCP Control Surface Sketch - 경로: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/mcp-control-surface-sketch.md` diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md new file mode 100644 index 0000000..598126b --- /dev/null +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md @@ -0,0 +1,80 @@ +# Milestone: Agent-Origin Milestone Creation Sync + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md` + +## 목표 + +agent 또는 사람이 `agent-roadmap` active Milestone을 직접 수정해 `develop`에 push한 경우에도 NomadCode가 Gito branch revision을 검증한 뒤 Plane work item을 생성하거나 갱신하고, 생성된 provider identity를 다시 roadmap 파일에 backfill commit으로 남긴다. Plane-origin 흐름과 같은 identity ledger, revision guard, retry/idempotency 기준을 사용해 양방향 생성 동기화의 빈 경로를 닫는다. + +## 상태 + +[계획] + +## 구현 잠금 + +- 상태: 해제 +- SDD: 필요 +- SDD 문서: `agent-roadmap/sdd/agent-ops-mcp-control-plane/agent-origin-milestone-creation-sync/SDD.md` +- SDD 사유: Gito webhook, Plane provider 쓰기, git identity backfill push, revision/idempotency/identity map을 함께 다루는 sync state machine이다. +- 잠금 해제 조건: + - [x] SDD 잠금이 해제되어 있다 + - [x] SDD 사용자 리뷰가 없거나 승인/해결되었다 + - [x] Acceptance Scenario가 Milestone 기능 Task와 연결되어 있다 + - [x] Evidence Map이 완료 시 Roadmap Completion과 최종 검증 evidence로 검증 가능하게 연결되어 있다 +- 결정 필요: 없음 + +## 범위 + +- Gito `branch.updated` webhook을 wakeup으로 받아 `develop` branch의 target revision을 fetch/scan하는 agent-origin sync 경로 +- active Milestone 파일 중 provider identity가 없는 신규/변경 Milestone을 Plane work item 생성 대상으로 판별하는 기준 +- Plane work item 생성 후 `roadmap_sync_identities` ledger에 canonical filename slug와 provider work item id를 저장하는 흐름 +- 생성된 provider identity block을 원래 Milestone markdown에 backfill하고 별도 commit/push로 `develop`에 반영하는 흐름 +- Core가 만든 identity-only backfill commit이 다시 들어왔을 때 self-loop와 중복 provider mutation을 막는 idempotency guard +- 기존 provider identity가 있는 Milestone 변경은 Plane 본문/제목 갱신으로 수렴하고, identity 충돌/rename/reuse는 conflict로 멈추는 정책 + +## 기능 + +### Epic: [agent-origin-sync] Agent-origin Milestone creation sync + +agent-origin roadmap push를 Plane work item 생성/갱신과 provider identity backfill까지 수렴시킨다. + +- [ ] [scan-diff] Gito webhook의 `before..after` 변경 목록을 wakeup 힌트로 사용하되, `after` SHA를 fetch/검증한 뒤 active Milestone diff와 전체 active index를 함께 스캔한다. 검증: `changed_files`만 신뢰하지 않고 `origin/develop`의 `after` SHA가 일치할 때만 sync job이 enqueue된다. +- [ ] [missing-create] provider identity가 없는 active Milestone을 agent-origin 생성 후보로 판별하고 Plane work item을 생성한다. 검증: 같은 milestone slug와 revision을 재처리해도 중복 Plane work item을 만들지 않는다. +- [ ] [identity-ledger] 생성된 Plane work item id를 `roadmap_sync_identities`에 filename slug 기준으로 upsert하고 revision/idempotency conflict를 구분한다. 검증: 기존 slug가 다른 provider work item에 묶여 있으면 provider mutation 없이 conflict로 멈춘다. +- [ ] [identity-backfill] Plane 생성 직후 Milestone markdown에 `Provider identity` block을 주입하고 Core-authored backfill commit을 `develop`에 push한다. 검증: backfill commit에는 identity block 외의 unrelated roadmap 변경이 포함되지 않는다. +- [ ] [self-loop] Core-authored identity backfill webhook은 provider 생성/갱신을 반복하지 않고 ledger와 markdown identity 일치 여부만 확인한다. 검증: backfill commit 재전달과 Core 재시작 후 재전달 모두 no-op 또는 complete로 수렴한다. +- [ ] [identity-update] provider identity가 이미 있는 Milestone의 agent-origin 본문/제목 변경은 Plane 본문/제목 갱신으로 수렴한다. 검증: `develop` revision guard와 completed-step ledger로 부분 실패를 재시도할 수 있다. +- [ ] [conflict-policy] rename, filename slug reuse, provider identity mismatch, stale Plane work item, duplicate active slug를 안전하게 분류한다. 검증: destructive overwrite 없이 conflict/not-ready/skip 결과와 로그가 구분된다. +- [ ] [field-smoke] dev 환경에서 agent가 active Milestone을 직접 추가해 push한 뒤 Plane ticket 생성, identity backfill commit, 재전달 no-op까지 확인한다. 검증: secret을 남기지 않은 DB/log/Plane/git evidence가 `complete.log`에 기록된다. + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 없음 +- 검토 항목: + - [ ] 모든 기능 Task와 검증이 `Roadmap Completion` evidence로 충족되었다 + - [ ] SDD Evidence Map이 최종 검증 evidence와 일치한다 + - [ ] 사용자가 완료 결과를 확인했다 + - [ ] archive 이동을 승인했다 +- agent-ui 상태 반영: 해당 없음 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- Plane `Todo -> In Progress` 이후 실행 lifecycle과 하위 티켓 생성 +- agent-task plan/code-review 결과를 Plane 하위 티켓 상태로 동기화하는 흐름 +- MCP control surface와 외부 agent tool permission 정책 +- 사용자 승인 없는 자동 archive 이동 +- provider native API schema 일반화 또는 Jira 구현 + +## 작업 컨텍스트 + +- 관련 경로: `services/core/internal/gitosync/`, `services/core/internal/roadmapsync/`, `services/core/internal/roadmapsyncpipeline/`, `services/core/internal/authoring/`, `services/core/internal/workitem/`, `services/core/internal/scheduler/`, `agent-contract/outer/gito-branch-webhook-consumer-v1.md`, `agent-roadmap/` +- 표준선: Gito webhook은 wakeup signal이며, Plane/roadmap mutation 전에는 반드시 `origin/develop`의 target SHA를 fetch/scan해 검증한다. filename slug가 canonical milestone id이고, provider identity block은 repo portable anchor로 유지한다. +- 선행 작업: Plane-Origin Authoring Roundtrip Sync, Roadmap Driven Agent-Ops Automation +- 후속 작업: Milestone Execution Lifecycle Sync +- 확인 필요: 없음 diff --git a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-execution-lifecycle-sync.md b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-execution-lifecycle-sync.md index 366efb9..747ff33 100644 --- a/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-execution-lifecycle-sync.md +++ b/agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/milestone-execution-lifecycle-sync.md @@ -61,8 +61,8 @@ Todo 이후 실행 lifecycle을 Plane 하위 티켓, Milestone Task, agent-task ## 작업 컨텍스트 - 관련 경로: `services/core/internal/workitem/`, `services/core/internal/scheduler/`, `services/core/internal/workflow/`, `agent-task/`, `packages/contracts/`, `agent-roadmap/` -- 선행 작업: `Roadmap Driven Agent-Ops Automation` +- 선행 작업: `Roadmap Driven Agent-Ops Automation`, `Agent-Origin Milestone Creation Sync` - 잠금 해제 조건: 사용자가 명시적으로 이 마일스톤의 잠금 해제를 요청한다. -- 현재 지점: 사용자 확인과 명시적 잠금 해제 전까지 실행하지 않는 후속 마일스톤이다. +- 현재 지점: agent-origin 생성 동기화가 닫히고 사용자가 명시적으로 잠금 해제를 요청하기 전까지 실행하지 않는 후속 마일스톤이다. - 확인 필요: - [ ] 잠금 해제 후 `Todo -> In Progress` 이후 범위를 다시 확인한다. diff --git a/agent-roadmap/sdd/agent-ops-mcp-control-plane/agent-origin-milestone-creation-sync/SDD.md b/agent-roadmap/sdd/agent-ops-mcp-control-plane/agent-origin-milestone-creation-sync/SDD.md new file mode 100644 index 0000000..f616cc2 --- /dev/null +++ b/agent-roadmap/sdd/agent-ops-mcp-control-plane/agent-origin-milestone-creation-sync/SDD.md @@ -0,0 +1,115 @@ +# SDD: Agent-Origin Milestone Creation Sync + +## 위치 + +- Milestone: `agent-roadmap/phase/agent-ops-mcp-control-plane/milestones/agent-origin-milestone-creation-sync.md` +- Phase: `agent-roadmap/phase/agent-ops-mcp-control-plane/PHASE.md` + +## 상태 + +[승인됨] + +## SDD 잠금 + +- 상태: 해제 +- 사용자 리뷰: 없음 +- 잠금 항목: + - 없음 + +## 문제 / 비목표 + +- 문제: Plane-origin 흐름은 Plane 요청에서 시작해 pushed develop SHA를 확인한 뒤 Plane Todo projection까지 수렴하지만, agent 또는 사람이 `agent-roadmap` active Milestone을 직접 수정해 push한 경우 provider identity가 없는 Milestone을 Plane work item과 안정적으로 연결하고 그 identity를 repo에 되돌려 쓰는 경로가 부족하다. +- 비목표: + - Todo 이후 실행 lifecycle과 Plane 하위 티켓 동기화 + - MCP control surface 또는 외부 agent tool permission 정책 + - provider native API schema 일반화 또는 Jira 구현 + - 사용자 승인 없는 archive 이동 + +## Source of Truth + +| 영역 | 기준 | 메모 | +|------|------|------| +| Roadmap | `agent-roadmap/phase/**/milestones/*.md` on `develop` | active Milestone markdown이 Plane projection의 본문/제목 기준이며 filename slug가 canonical milestone id다. | +| Code | `services/core/internal/gitosync/`, `services/core/internal/roadmapsyncpipeline/`, `services/core/internal/roadmapsync/` | Gito revision scan, identity matching, provider mutation, ledger/retry orchestration을 소유한다. | +| External Provider | Plane | Plane work item id와 state는 provider side effect 결과이며 raw token/secret은 tracked 문서에 기록하지 않는다. | +| User Decision | 없음 | 사용자는 agent-origin identity backfill 방향을 승인했고, 남은 항목은 기존 sync/domain rule로 구현 가능하다. | + +## State Machine + +| 상태 | 진입 조건 | 다음 상태 | 근거 | +|------|-----------|-----------|------| +| `branch_event_received` | Gito `branch.updated` webhook이 target repo/branch로 들어온다. | `revision_verified` 또는 `not_ready` | `agent-contract/outer/gito-branch-webhook-consumer-v1.md` | +| `revision_verified` | local develop checkout fetch 후 `origin/develop`이 event `after` SHA와 일치한다. | `milestone_scanned` | `git fetch`, `git rev-parse`, `git ls-tree`, `git show` | +| `milestone_scanned` | active Milestone diff와 active index를 읽고 identity 유무를 분류한다. | `missing_identity_candidate`, `identity_matched`, `conflict` | roadmap parser, duplicate slug guard | +| `missing_identity_candidate` | provider identity가 없는 active Milestone이 생성 후보로 판별된다. | `plane_created` 또는 `conflict` | filename slug, project sync config, ledger lookup | +| `plane_created` | Plane work item 생성과 DB identity upsert가 성공한다. | `identity_backfilled` | Plane adapter, `roadmap_sync_identities` | +| `identity_backfilled` | Milestone markdown에 `Provider identity` block을 주입한 commit이 `develop`에 push된다. | `self_loop_checked` | Core-authored git commit/push | +| `identity_matched` | provider identity가 있는 Milestone 변경이다. | `plane_projected`, `complete`, `conflict` | roadmap revision guard, completed-step ledger | +| `self_loop_checked` | identity-only backfill commit webhook이 재수신된다. | `complete` 또는 `conflict` | actor/commit marker, markdown identity, ledger | +| `conflict` | slug reuse, identity mismatch, duplicate active slug, destructive overwrite 위험이 감지된다. | `conflict` | no provider mutation, operator/user review evidence | + +## Interface Contract + +- 계약 원문: `agent-contract/outer/gito-branch-webhook-consumer-v1.md` +- 입력: + - `repo_id`: target repository id. `GITO_REPO_ID`와 일치해야 한다. + - `branch`: target branch. 기본값은 `develop`이다. + - `before`: 이전 git SHA. diff 후보 산정에 사용하되 source-of-truth로 단정하지 않는다. + - `after`: 새 git SHA. fetch/scan 및 idempotency 기준이다. + - `changed_files`: wakeup relevance 힌트. 실제 sync는 target revision scan으로 확인한다. +- 출력: + - Plane work item 생성/갱신 결과 + - `roadmap_sync_identities` identity row와 completed step ledger + - provider identity block backfill commit SHA + - conflict/not-ready/complete/projected 로그와 job result +- 금지: + - `changed_files`만 믿고 Plane 또는 roadmap 파일을 mutation하지 않는다. + - provider identity가 충돌하는 Milestone을 덮어쓰지 않는다. + - Core-authored backfill commit 재전달로 Plane work item을 중복 생성하지 않는다. + - raw secret, token, provider credential을 tracked evidence나 로그에 남기지 않는다. + +## Acceptance Scenarios + +| ID | Milestone Task | Given | When | Then | +|----|----------------|-------|------|------| +| S01 | `scan-diff` | Gito event가 `before`, `after`, `changed_files`를 포함한다. | Core가 fetch 후 `origin/develop`과 `after` SHA를 비교한다. | SHA가 일치할 때만 active Milestone scan이 진행되고, 불일치하면 not-ready로 남는다. | +| S02 | `missing-create` | active Milestone 파일이 추가됐고 provider identity가 없다. | `after` revision scan에서 생성 후보로 분류된다. | Plane work item이 1개만 생성되고 같은 revision 재처리는 중복 생성하지 않는다. | +| S03 | `identity-ledger` | 생성된 Plane work item id와 milestone slug가 있다. | identity row를 upsert한다. | 같은 slug/provider key는 재시도 가능하고, 다른 provider work item과 충돌하면 conflict로 멈춘다. | +| S04 | `identity-backfill` | Plane 생성과 identity ledger 기록이 완료됐다. | Core가 Milestone markdown에 provider identity block을 추가한다. | identity-only backfill commit이 `develop`에 push되고 unrelated 변경은 포함되지 않는다. | +| S05 | `self-loop` | backfill commit으로 다시 Gito webhook이 들어온다. | Core가 actor/commit marker, markdown identity, ledger를 비교한다. | Plane 생성/갱신이 반복되지 않고 complete/no-op으로 수렴한다. | +| S06 | `identity-update` | provider identity가 있는 Milestone markdown이 agent-origin으로 수정됐다. | Core가 target revision을 scan한다. | Plane 제목/본문 갱신이 ledger step으로 재시도 가능하게 수행된다. | +| S07 | `conflict-policy` | rename, slug reuse, stale provider item, duplicate active slug 중 하나가 발생한다. | Core가 sync를 시도한다. | destructive overwrite 없이 conflict/not-ready/skip 중 하나로 분류하고 provider mutation을 제한한다. | +| S08 | `field-smoke` | dev Plane/Gito/NomadCode/IOP 환경이 준비되어 있다. | agent가 identity 없는 active Milestone을 직접 push한다. | Plane ticket 생성, identity backfill commit, 재전달 no-op, secret-redacted evidence가 확인된다. | + +## Evidence Map + +| Scenario | Required Evidence | `agent-task` 연결 | 완료 Evidence 기대 | +|----------|-------------------|------------------|---------------------------| +| S01 | gitosync scanner/bridge unit test and webhook handler test | `agent-task/m-agent-origin-milestone-creation-sync/...` | `scan-diff` Roadmap Completion, test output, revision mismatch/not-ready evidence | +| S02 | roadmapsyncpipeline missing-create idempotency test | `agent-task/m-agent-origin-milestone-creation-sync/...` | `missing-create` Roadmap Completion, duplicate retry evidence | +| S03 | storage identity conflict/upsert test | `agent-task/m-agent-origin-milestone-creation-sync/...` | `identity-ledger` Roadmap Completion, conflict evidence | +| S04 | identity backfill writer test and git commit scope check | `agent-task/m-agent-origin-milestone-creation-sync/...` | `identity-backfill` Roadmap Completion, commit diff evidence | +| S05 | self-loop/redelivery test across process restart or durable ledger | `agent-task/m-agent-origin-milestone-creation-sync/...` | `self-loop` Roadmap Completion, no-op/complete evidence | +| S06 | provider identity update projection test | `agent-task/m-agent-origin-milestone-creation-sync/...` | `identity-update` Roadmap Completion, Plane body/title projection evidence | +| S07 | conflict classification tests | `agent-task/m-agent-origin-milestone-creation-sync/...` | `conflict-policy` Roadmap Completion, no destructive provider mutation evidence | +| S08 | dev field smoke with redacted logs/DB/Plane/git evidence | `agent-task/m-agent-origin-milestone-creation-sync/...` | `field-smoke` Roadmap Completion, final dev cycle evidence without secrets | + +## Cross-repo Dependencies + +- Gito provides `gito.forgejo-branch-events.v1`; NomadCode consumes it through `agent-contract/outer/gito-branch-webhook-consumer-v1.md`. + +## Drift Check + +- [x] Milestone 기능 Task와 Acceptance Scenario가 일치한다. +- [x] Evidence Map이 code-review/complete.log에서 검증 가능하다. +- [x] agent-contract를 쓰는 경우 SDD에 계약 원문을 복제하지 않았다. +- [x] 사용자 리뷰가 필요한 항목은 `USER_REVIEW.md`에만 남겼다. + +## 사용자 리뷰 이력 + +- 없음 + +## 작업 컨텍스트 + +- 표준선: `develop` branch의 active Milestone markdown이 roadmap source of truth이며, Gito webhook은 wakeup으로만 사용한다. Provider identity block은 repo portable anchor이고, DB ledger는 runtime idempotency/retry state다. +- 후속 SDD: Milestone Execution Lifecycle Sync에서 하위 티켓/agent-task lifecycle을 다룰 때 별도 SDD 필요 여부를 다시 판정한다. diff --git a/agent-test/dev/rules.md b/agent-test/dev/rules.md new file mode 100644 index 0000000..781993e --- /dev/null +++ b/agent-test/dev/rules.md @@ -0,0 +1,66 @@ +--- +test_env: dev +last_rule_updated_at: 2026-07-01 +--- + +# dev 테스트 규칙 + +**현재 문서를 반드시 끝까지 정독하고 작업한다. 다 읽지 않고 즉각 작업은 금지한다.** +**dev 환경은 remote local profile과 같은 host에서 분리 운영하는 NomadCode/Gito 연동 검증용 profile이다.** + +## 공통 규칙 + +- dev 테스트/검증은 이 파일을 기준으로 판단한다. +- 원격 host에서 local profile 포트와 dev profile 포트를 동시에 띄울 수 있어야 한다. +- 환경값, secret, private endpoint 원문은 tracked docs/roadmap/task log/최종 응답에 기록하지 않는다. +- raw token, password, webhook secret, provider credential, private key는 ignored local profile 또는 remote secret store에서만 주입한다. +- 컨테이너 내부 listen 포트는 제품 기본값을 우선 유지하고, workspace 충돌 관리는 host publish/direct smoke 포트로 처리한다. +- 테스트용 Docker subnet을 새로 만들 때는 `192.168.0.0/24` 대역을 사용하지 않는다. + +## 기본 환경 + +- host: standard remote runner `toki@toki-labs.com`. +- checkout: dev profile은 `$HOME/agent-work/nomadcode-dev`를 기준으로 둔다. 아직 없으면 현재 검증 대상 branch/commit으로 생성 또는 동기화한 뒤 사용한다. +- runtime: repo root helper(`bin/test`, `bin/lint`, `bin/build`)와 변경 범위별 Go, Flutter/Dart toolchain. +- package manager: Go modules, Flutter pub. +- docker: Compose는 Core 내부 listen `8080`을 유지하고 host publish를 `NOMADCODE_CORE_HOST_PORT=18011`로 둔다. 별도 DB/cache host publish가 필요할 때만 `15411/16311` 후보를 사용한다. +- external service: Mattermost, FCM, Plane, Gito 등 도메인/검증 시나리오별 문서가 명시한 서비스만 사용한다. +- model endpoint: 기본 IOP dev dependency는 `http://toki-labs.com:18083/v1`이다. 이 포트는 NomadCode-owned slot이 아니며 IOP dev 환경이 소유한다. +- credential: ignored local file only. + +## 포트 매핑 + +| 용도 | local profile | dev profile | +| --- | ---: | ---: | +| Flutter/code-server preview | `13010` | `13011` | +| NomadCode Core host publish | `18010:8080` | `18011:8080` | +| PostgreSQL host publish 후보 | `15410` | `15411` | +| Redis host publish 후보 | `16310` | `16311` | +| Gito delivery source | `18060` | `18061` | + +dev NomadCode의 Gito webhook target은 `http://127.0.0.1:18011/api/integrations/gito/webhook`이다. local profile target은 `http://127.0.0.1:18010/api/integrations/gito/webhook`으로 유지한다. + +## Gito Webhook Consumer 기준 + +- consumer endpoint는 `POST /api/integrations/gito/webhook`이다. +- dev Gito base URL은 `http://127.0.0.1:18061`이고 subscription target은 `http://127.0.0.1:18011/api/integrations/gito/webhook`이다. +- consumer enablement에는 `GITO_WEBHOOK_SECRET`, `GITO_REPO_ID`, `GITO_DEVELOP_REPO_PATH`, `ROADMAP_CREATION_TODO_STATE_ID` 또는 `PLANE_TODO_STATE_ID`가 필요하다. +- `GITO_BRANCH` 기본값은 `develop`, `GITO_REMOTE_NAME` 기본값은 `origin`이다. +- `GITO_DEVELOP_REPO_PATH`는 Core container에서 접근 가능한 checkout path여야 한다. +- 계약 원문은 `agent-contract/outer/gito-branch-webhook-consumer-v1.md`와 Gito 제공 계약 `../gito/agent-contract/provided/gito-forgejo-branch-events-v1.md`를 함께 따른다. + +## 라우팅 + +- core / smoke / services/core backend baseline: `agent-test/local/core-smoke.md` +- mobile / smoke / apps/client Flutter baseline: `agent-test/local/mobile-smoke.md` +- mobile / smoke / Android SDK remote runner and debug APK validation: `agent-test/local/mobile-android-sdk-smoke.md` +- mobile / smoke / Mattermost server-generated signed push, Nexo parity, Android remote push evidence: `agent-test/local/mattermost-server-generated-push-smoke.md` +- contracts / smoke / packages/contracts contract docs/schema baseline: `agent-test/local/contracts-smoke.md` +- workspace-ops / smoke / root helpers, docs, agent rules, roadmap baseline: `agent-test/local/workspace-ops-smoke.md` + +## 라우팅 규칙 + +- 여러 항목이 맞으면 모두 읽는다. +- 도메인 매핑이나 domain rule이 있으면 각 도메인의 `-smoke` 문서를 기본 baseline으로 둔다. +- 도메인이 아직 없을 때만 `project-smoke`를 fallback baseline으로 둔다. +- 도메인/검증 시나리오별 문서는 다른 테스트 문서로 라우팅하지 않는다. diff --git a/packages/contracts/notes/flutter-core-api-candidates.md b/packages/contracts/notes/flutter-core-api-candidates.md index 97c175d..5d861d4 100644 --- a/packages/contracts/notes/flutter-core-api-candidates.md +++ b/packages/contracts/notes/flutter-core-api-candidates.md @@ -93,11 +93,11 @@ | 표면 | 후보 기준 | compatibility note | |------|-----------|--------------------| -| Flutter web preview | code-server `/proxy//` 아래 `13010+` host port 대역 | 기존 local smoke의 ad hoc `8081` 계열 포트는 계속 허용한다. | +| Flutter web preview | code-server `/proxy//` 아래 remote local `13010`, remote dev `13011` host port | 기존 local smoke의 ad hoc `8081` 계열 포트는 계속 허용한다. | | code-server workspace entry | `http://localhost:8080/?folder=...` mock URL 유지 | 이 URL은 code-server entry 호환성이고 Flutter web preview 포트가 아니다. | -| Core HTTP/API | process/container 내부 `8080`, workspace host publish 기본값 `18010:8080` | `HTTP_ADDR=:8080`, Docker `EXPOSE 8080`, local curl `localhost:8080`은 migration compatibility로 유지하고, compose는 `NOMADCODE_CORE_HOST_PORT`로 host port를 override한다. | -| PostgreSQL | compose/service DNS `code-server-postgres:5432`; host 필요 시 `15410:5432` 후보 | 기본 내부 통신은 service DNS를 우선하고 host publish는 추가하지 않는다. | -| Redis | compose/service DNS `code-server-redis:6379`; host 필요 시 `16310:6379` 후보 | 기본 내부 통신은 service DNS를 우선하고 host publish는 추가하지 않는다. | +| Core HTTP/API | process/container 내부 `8080`, remote local host publish `18010:8080`, remote dev host publish `18011:8080` | `HTTP_ADDR=:8080`, Docker `EXPOSE 8080`, local curl `localhost:8080`은 migration compatibility로 유지하고, compose는 `NOMADCODE_CORE_HOST_PORT`로 host port를 override한다. | +| PostgreSQL | compose/service DNS `code-server-postgres:5432`; host 필요 시 local `15410:5432`, dev `15411:5432` 후보 | 기본 내부 통신은 service DNS를 우선하고 host publish는 추가하지 않는다. | +| Redis | compose/service DNS `code-server-redis:6379`; host 필요 시 local `16310:6379`, dev `16311:6379` 후보 | 기본 내부 통신은 service DNS를 우선하고 host publish는 추가하지 않는다. | | proto-socket client/core endpoint | client는 `PROTO_SOCKET_*`, core는 `/proto-socket`과 heartbeat env를 사용 | client `PROTO_SOCKET_HOST`가 없으면 connector는 비활성화된다. | | IOP Edge/provider endpoints | `MODEL_*`, `A2A_*`, `PLANE_*`, `JIRA_*`, `MATTERMOST_*` env group | raw token, password, API key 값은 tracked contract note에 기록하지 않는다. | @@ -162,17 +162,19 @@ - 검증은 최소한 channel/action dispatch, auth boundary, error envelope, event broadcast 또는 reconnect 영향 중 해당되는 항목을 테스트로 고정한다. ### 1.8 외부 Gito Branch Event Consumer -- **설명**: NomadCode는 Gito의 branch event를 proto-socket을 통해 수신하는 소비자입니다. 소비 표면은 Flutter client wakeup과 Core backend bridge 두 책임으로 나뉩니다. -- **원문 계약**: Gito `agent-contract/provided/gito-forgejo-branch-events-v1.md` (계약 ID: `gito.forgejo-branch-events.v1`) -- **소비 액션**: `event.subscribe` (구독 요청) 및 `branch.updated` (브로드캐스트 이벤트) -- **공통 책임 경계**: NomadCode는 `branch.updated` 이벤트를 단순 wakeup signal로만 해석하고 자체적인 Plane/roadmap mutation은 실행하지 않으며, 이벤트 수신 후 target branch revision에 대한 재검증 및 후속 동기화 여부를 직접 확인해야 합니다. 계약 본문 전체는 Gito 원문 계약을 따릅니다. +- **설명**: NomadCode는 Gito의 branch event를 HTTP webhook으로 수신하는 소비자입니다. 소비 표면은 Core backend bridge가 canonical이며, Flutter client wakeup은 후속 UI projection 책임으로 분리합니다. +- **NomadCode 소비 계약**: `agent-contract/outer/gito-branch-webhook-consumer-v1.md` (계약 ID: `nomadcode.gito-branch-webhook-consumer.v1`) +- **Gito 원문 제공 계약**: Gito `agent-contract/provided/gito-forgejo-branch-events-v1.md` (계약 ID: `gito.forgejo-branch-events.v1`) +- **소비 표면**: `POST /api/integrations/gito/webhook` 및 `branch.updated` payload. +- **포트 profile**: remote local은 Gito `18060` -> NomadCode `18010`, remote dev는 Gito `18061` -> NomadCode `18011`을 기준으로 한다. +- **공통 책임 경계**: NomadCode는 `branch.updated` 이벤트를 단순 wakeup signal로만 해석하고, Plane/roadmap mutation 전 target branch revision에 대한 재검증 및 후속 동기화 여부를 직접 확인해야 합니다. `GITO_PROTO_SOCKET_URL`은 이전 wire consumer 호환 경로가 필요할 때만 사용합니다. #### 1.8.1 Flutter client wakeup - **소비자**: `apps/client` Flutter 앱. -- **용도**: branch event를 UI wakeup 힌트로만 사용합니다. 화면 갱신/알림 트리거에 쓰며, 자체적으로 source-of-truth 상태를 변경하지 않습니다. +- **용도**: Core가 처리한 branch event projection을 UI wakeup 힌트로만 사용합니다. 화면 갱신/알림 트리거에 쓰며, 자체적으로 source-of-truth 상태를 변경하지 않습니다. #### 1.8.2 Core backend bridge -- **소비자**: `services/core` 내부 Gito branch event client (`services/core/internal/gitoevents`). +- **소비자**: `services/core` HTTP Gito webhook consumer (`POST /api/integrations/gito/webhook`) 및 내부 Gito branch event bridge. - **용도**: Core는 Gito 제공 계약 `gito.forgejo-branch-events.v1`을 소비해 `branch.updated`를 backend sync enqueue wakeup으로만 사용합니다. - **`changed_files` 취급**: `changed_files`는 roadmap Milestone 관련성 판단을 위한 wakeup 힌트이며, 그 자체를 신뢰해 동기화하지 않습니다. 실제 target branch revision 확인/스캔은 backend bridge(후속 revision-scan 단계)의 책임입니다. - **구독 필터**: `repo_id`, `branch`로 구독하며, 다른 repo/branch event는 enqueue 후보로 넘기지 않습니다. diff --git a/services/core/README.md b/services/core/README.md index 4abe0df..f03b4c5 100644 --- a/services/core/README.md +++ b/services/core/README.md @@ -102,7 +102,7 @@ Jira 연동은 Jira Cloud Basic auth(`Email:APIToken`) 형식을 사용합니다 Mattermost task completion notification은 `MATTERMOST_BASE_URL`, `MATTERMOST_TOKEN`, `MATTERMOST_CHANNEL_ID` 환경 변수를 사용해 `POST /api/v4/posts`로 발송합니다. 설정이 누락되거나 Mattermost가 non-2xx를 반환하면 core는 notification projection 오류를 기록하되 canonical task completion 상태를 롤백하지 않습니다. -Gito HTTP webhook consumer는 `POST /api/integrations/gito/webhook`에서 signed `branch.updated` wakeup을 받고, local develop checkout을 fetch/scan한 뒤 Plane-origin roadmap creation sync job으로 넘깁니다. `GITO_WEBHOOK_SECRET`, `GITO_REPO_ID`, `GITO_DEVELOP_REPO_PATH`, `ROADMAP_CREATION_TODO_STATE_ID` 또는 `PLANE_TODO_STATE_ID`가 모두 있어야 켜지며, `GITO_BRANCH` 기본값은 `develop`, `GITO_REMOTE_NAME` 기본값은 `origin`입니다. `ROADMAP_CREATION_TODO_STATE_ID`를 설정하면 `PLANE_TODO_STATE_ID`보다 우선합니다. Gito 쪽 bootstrap은 watched branch 등록과 webhook subscription 등록을 사용하고, subscription의 `target_url`은 이 Core callback endpoint를 가리키며 signing secret 원문은 tracked 문서에 기록하지 않습니다. `GITO_PROTO_SOCKET_URL`은 이전 wire consumer 호환 경로가 필요할 때만 별도로 설정합니다. +Gito HTTP webhook consumer는 `POST /api/integrations/gito/webhook`에서 signed `branch.updated` wakeup을 받고, local develop checkout을 fetch/scan한 뒤 Plane-origin roadmap creation sync job으로 넘깁니다. `GITO_WEBHOOK_SECRET`, `GITO_REPO_ID`, `GITO_DEVELOP_REPO_PATH`, `ROADMAP_CREATION_TODO_STATE_ID` 또는 `PLANE_TODO_STATE_ID`가 모두 있어야 켜지며, `GITO_BRANCH` 기본값은 `develop`, `GITO_REMOTE_NAME` 기본값은 `origin`입니다. `ROADMAP_CREATION_TODO_STATE_ID`를 설정하면 `PLANE_TODO_STATE_ID`보다 우선합니다. Gito 쪽 bootstrap은 watched branch 등록과 webhook subscription 등록을 사용하고, subscription의 `target_url`은 이 Core callback endpoint를 가리키며 signing secret 원문은 tracked 문서에 기록하지 않습니다. remote local target은 `http://127.0.0.1:18010/api/integrations/gito/webhook`, remote dev target은 `http://127.0.0.1:18011/api/integrations/gito/webhook`입니다. 계약 원문은 `agent-contract/outer/gito-branch-webhook-consumer-v1.md`와 Gito 제공 계약 `../gito/agent-contract/provided/gito-forgejo-branch-events-v1.md`를 함께 봅니다. `GITO_PROTO_SOCKET_URL`은 이전 wire consumer 호환 경로가 필요할 때만 별도로 설정합니다. ```bash GITO_WEBHOOK_SECRET="" \ @@ -186,13 +186,19 @@ DB 접근 코드는 `migrations/`의 스키마와 `queries/`의 SQL을 기준으 Docker Compose 실행은 dev 배포용입니다. compose는 외부 Docker 네트워크 `net_nginx`에 붙고, 같은 네트워크의 `code-server-postgres`에 `nomad-core-dev` DB로, `code-server-redis`의 Redis DB 4번과 `nomadcode-core:dev` key prefix로 접속합니다. dev Redis 주소나 prefix를 바꿀 때는 local 실행용 `REDIS_URL`, `REDIS_KEY_PREFIX`와 분리된 `DEV_REDIS_URL`, `DEV_REDIS_KEY_PREFIX`를 사용합니다. 외부 노출 테스트에서는 `AUTH_PASSWORD`를 함께 지정합니다. -Compose도 Gito HTTP webhook consumer env를 빈 기본값으로 전달하므로, signing secret, repo id, local checkout volume, Todo state id가 준비된 경우에만 consumer가 활성화됩니다. `GITO_DEVELOP_REPO_PATH`는 container에서 접근 가능한 경로여야 합니다. +Compose도 Gito HTTP webhook consumer env를 빈 기본값으로 전달하므로, signing secret, repo id, local checkout volume, Todo state id가 준비된 경우에만 consumer가 활성화됩니다. `GITO_DEVELOP_REPO_PATH`는 container에서 접근 가능한 경로여야 합니다. remote local profile은 `NOMADCODE_CORE_HOST_PORT=18010`, remote dev profile은 `NOMADCODE_CORE_HOST_PORT=18011`을 사용하고 Core container 내부 listen은 `8080`으로 유지합니다. ```bash AUTH_PASSWORD="change-me" ./bin/docker-up ``` -Compose의 기본 host publish는 remote/local test workspace 기준 `18010:8080`입니다. 기존 host `8080`이 필요한 호환성 확인에서는 아래처럼 명시합니다. +Compose의 기본 host publish는 remote/local test workspace 기준 `18010:8080`입니다. dev profile은 같은 remote host에서 local profile과 분리하기 위해 아래처럼 `18011:8080`을 명시합니다. + +```bash +NOMADCODE_CORE_HOST_PORT=18011 AUTH_PASSWORD="change-me" ./bin/docker-up +``` + +기존 host `8080`이 필요한 호환성 확인에서는 아래처럼 명시합니다. ```bash NOMADCODE_CORE_HOST_PORT=8080 AUTH_PASSWORD="change-me" ./bin/docker-up diff --git a/services/core/internal/adapters/openai/client.go b/services/core/internal/adapters/openai/client.go index 0a5f870..9da69d0 100644 --- a/services/core/internal/adapters/openai/client.go +++ b/services/core/internal/adapters/openai/client.go @@ -427,6 +427,10 @@ func buildRequestMetadata(input model.GenerateInput) map[string]any { } merged := make(map[string]any, len(input.Metadata)+1) for k, v := range input.Metadata { + // IOP Responses contract rejects ambiguous call-origin metadata. + if k == "source" { + continue + } merged[k] = v } if input.WorkspaceMetadata != nil { @@ -434,6 +438,9 @@ func buildRequestMetadata(input model.GenerateInput) map[string]any { merged["workspace"] = path } } + if len(merged) == 0 { + return nil + } return merged } diff --git a/services/core/internal/adapters/openai/client_test.go b/services/core/internal/adapters/openai/client_test.go index ff71a63..f01723f 100644 --- a/services/core/internal/adapters/openai/client_test.go +++ b/services/core/internal/adapters/openai/client_test.go @@ -76,9 +76,12 @@ func TestGenerateCallsResponsesAPI(t *testing.T) { if !ok { t.Fatalf("expected metadata object, got %#v", gotBody["metadata"]) } - if metadata["task_id"] != "task-123" || metadata["source"] != "plane" { + if metadata["task_id"] != "task-123" { t.Fatalf("unexpected metadata: %#v", metadata) } + if _, ok := metadata["source"]; ok { + t.Fatalf("metadata.source must not be sent to IOP-compatible responses: %#v", metadata) + } if _, ok := gotBody["context_size"]; ok { t.Fatal("Responses API request must not include unsupported context_size") } @@ -131,9 +134,12 @@ func TestGenerateWorkspaceMetadataUsesFlatWorkspacePath(t *testing.T) { if !ok { t.Fatalf("expected metadata object, got %T: %#v", gotBody["metadata"], gotBody["metadata"]) } - if metadata["task_id"] != "task-abc" || metadata["source"] != "plane" { + if metadata["task_id"] != "task-abc" { t.Fatalf("flat metadata not preserved: %#v", metadata) } + if _, ok := metadata["source"]; ok { + t.Fatalf("metadata.source must not be sent to IOP-compatible responses: %#v", metadata) + } wsPath, ok := metadata["workspace"].(string) if !ok { t.Fatalf("expected metadata.workspace string, got %T: %#v", metadata["workspace"], metadata["workspace"]) diff --git a/services/core/internal/adapters/plane/client.go b/services/core/internal/adapters/plane/client.go index b5bdc80..675620d 100644 --- a/services/core/internal/adapters/plane/client.go +++ b/services/core/internal/adapters/plane/client.go @@ -395,6 +395,9 @@ func (c *Client) do(ctx context.Context, method, path string, input any, output return err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("%w: plane API request failed: status %d: %s", workitem.ErrNotFound, resp.StatusCode, strings.TrimSpace(string(raw))) + } return fmt.Errorf("plane API request failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } if output == nil || len(raw) == 0 { diff --git a/services/core/internal/adapters/plane/client_test.go b/services/core/internal/adapters/plane/client_test.go index 883653f..b27005a 100644 --- a/services/core/internal/adapters/plane/client_test.go +++ b/services/core/internal/adapters/plane/client_test.go @@ -3,6 +3,7 @@ package plane import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -132,6 +133,25 @@ func TestClientReturnsResponseBodyOnError(t *testing.T) { } } +func TestGetWorkItemWrapsNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"missing"}`)) + })) + defer server.Close() + + client := NewClient(Config{BaseURL: server.URL, Token: "test-token"}, nil) + _, err := client.GetWorkItem(context.Background(), WorkItemRef{ + WorkspaceSlug: "general", + ProjectID: "project-1", + WorkItemID: "missing", + }) + if !errors.Is(err, workitem.ErrNotFound) { + t.Fatalf("GetWorkItem error: got %v, want workitem.ErrNotFound", err) + } +} + func TestClientImplementsWorkItemFacets(t *testing.T) { client := NewClient(Config{BaseURL: "https://example.com", Token: "test-token"}, nil) if client.ProviderID() != workitem.ProviderID("plane") { @@ -548,8 +568,8 @@ func TestFetchWorkItemMapsStateAndAssignees(t *testing.T) { // Test case 3: assignees as nested objects (e.g. assignee detail) server3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ - "id": "work-3", - "name": "Test 3", + "id": "work-3", + "name": "Test 3", "state_id": "state-direct", "assignees": []map[string]any{ {"assignee": map[string]string{"id": "user-nested-1"}}, diff --git a/services/core/internal/authoring/request.go b/services/core/internal/authoring/request.go index b288a70..5152a3a 100644 --- a/services/core/internal/authoring/request.go +++ b/services/core/internal/authoring/request.go @@ -37,7 +37,7 @@ var ErrProviderIdentityMissing = errors.New("authoring provider identity missing // isAuthoringTask returns true when the task is a Plane-origin authoring task // that should be routed through the IOP workspace authoring bridge. The criteria -// are: source=="plane" and task metadata contains a "checkout" key with a +// are: task source=="plane" and task metadata contains a "checkout" key with a // non-empty slot path. func isAuthoringTask(task storage.Task) (workitempipeline.CheckoutMetadata, bool) { if task.Source != "plane" { @@ -180,7 +180,6 @@ func BuildAuthoringPushInputStrict(task storage.Task) (model.GenerateInput, bool Input: PushOnlyCommand, Metadata: map[string]string{ "task_id": task.ID, - "source": task.Source, }, WorkspaceMetadata: &model.WorkspaceMetadata{ Path: checkout.SlotPath, @@ -246,7 +245,6 @@ func BuildAuthoringGenerateInputStrict(task storage.Task) (model.GenerateInput, Instructions: instructions, Metadata: map[string]string{ "task_id": task.ID, - "source": task.Source, }, WorkspaceMetadata: &model.WorkspaceMetadata{ Path: checkout.SlotPath, diff --git a/services/core/internal/authoring/request_test.go b/services/core/internal/authoring/request_test.go index a98e583..852227b 100644 --- a/services/core/internal/authoring/request_test.go +++ b/services/core/internal/authoring/request_test.go @@ -56,9 +56,12 @@ func TestBuildAuthoringGenerateInputPlaneOrigin(t *testing.T) { if input.WorkspaceMetadata.SourceBranch != "develop" { t.Errorf("unexpected source branch: %q", input.WorkspaceMetadata.SourceBranch) } - if input.Metadata["task_id"] != "task-1" || input.Metadata["source"] != "plane" { + if input.Metadata["task_id"] != "task-1" { t.Errorf("unexpected metadata: %#v", input.Metadata) } + if _, ok := input.Metadata["source"]; ok { + t.Errorf("metadata.source must not be sent to IOP-compatible responses: %#v", input.Metadata) + } } func TestBuildAuthoringGenerateInputPipelinePayload(t *testing.T) { @@ -298,9 +301,12 @@ func TestBuildAuthoringPushInputPreservesWorkspaceMetadata(t *testing.T) { if input.WorkspaceMetadata.Tenant != "acme" { t.Errorf("unexpected tenant: %q", input.WorkspaceMetadata.Tenant) } - if input.Metadata["task_id"] != "task-push-2" || input.Metadata["source"] != "plane" { + if input.Metadata["task_id"] != "task-push-2" { t.Errorf("unexpected metadata: %#v", input.Metadata) } + if _, ok := input.Metadata["source"]; ok { + t.Errorf("metadata.source must not be sent to IOP-compatible responses: %#v", input.Metadata) + } } func TestBuildAuthoringPushInputNonPlaneSource(t *testing.T) { diff --git a/services/core/internal/gitosync/bridge.go b/services/core/internal/gitosync/bridge.go index 4130c70..75b5204 100644 --- a/services/core/internal/gitosync/bridge.go +++ b/services/core/internal/gitosync/bridge.go @@ -130,9 +130,9 @@ func (b *Bridge) Handle(ctx context.Context, ev gitoevents.BranchUpdatedEvent) e } // enqueueDoc fetches the originating Plane work item, builds the job args, and -// enqueues one creation sync job for a single scanned Milestone. A scanned -// identity whose provider/work item id does not resolve to a fetchable work -// item is a hard error so a misconfigured identity is surfaced, not swallowed. +// enqueues one creation sync job for a single scanned Milestone. Missing work +// items can be stale roadmap identities, so they are skipped while other fetch +// failures still surface. func (b *Bridge) enqueueDoc(ctx context.Context, scan roadmapsync.ScanResult, doc ScannedMilestoneDoc) error { if !doc.HasIdentity { providerID := workitem.ProviderID("plane") @@ -184,6 +184,14 @@ func (b *Bridge) enqueueDoc(ctx context.Context, scan roadmapsync.ScanResult, do item, err := b.reader.FetchWorkItem(ctx, ref) if err != nil { + if errors.Is(err, workitem.ErrNotFound) { + b.logInfo("gito scanned milestone work item missing, skipped", + "work_item_id", identity.WorkItemID, + "milestone_path", identity.RoadmapMilestonePath, + "revision", scan.Revision.Revision, + ) + return nil + } return err } diff --git a/services/core/internal/gitosync/bridge_test.go b/services/core/internal/gitosync/bridge_test.go index b6cdb61..a7f0544 100644 --- a/services/core/internal/gitosync/bridge_test.go +++ b/services/core/internal/gitosync/bridge_test.go @@ -21,13 +21,25 @@ func (f fakeScanner) Scan(context.Context, gitoevents.BranchUpdatedEvent) (ScanO } type fakeReader struct { - item workitem.WorkItem - err error - refs []workitem.Ref + item workitem.WorkItem + err error + refs []workitem.Ref + items map[string]workitem.WorkItem + errs map[string]error } func (f *fakeReader) FetchWorkItem(_ context.Context, ref workitem.Ref) (workitem.WorkItem, error) { f.refs = append(f.refs, ref) + if f.errs != nil { + if err, ok := f.errs[ref.ID]; ok { + return workitem.WorkItem{}, err + } + } + if f.items != nil { + if item, ok := f.items[ref.ID]; ok { + return item, nil + } + } if f.err != nil { return workitem.WorkItem{}, f.err } @@ -265,6 +277,73 @@ func TestBridgeFetchErrorIsSurfaced(t *testing.T) { } } +func TestBridgeSkipsMissingWorkItemAndContinues(t *testing.T) { + stale := matchedIdentity() + stale.WorkItemID = "missing-wi" + stale.RoadmapMilestonePath = "agent-roadmap/phase/p1/milestones/stale.md" + + current := matchedIdentity() + current.WorkItemID = "current-wi" + current.RoadmapMilestonePath = "agent-roadmap/phase/p1/milestones/current.md" + + out := ScanOutput{ + Result: roadmapsync.ScanResult{ + Revision: roadmapsync.Revision{Branch: "develop", Revision: "bbbb"}, + ChangedFiles: []string{ + stale.RoadmapMilestonePath, + current.RoadmapMilestonePath, + }, + ScannedMilestones: []roadmapsync.ScannedMilestone{ + {Path: stale.RoadmapMilestonePath, Identity: stale}, + {Path: current.RoadmapMilestonePath, Identity: current}, + }, + }, + Docs: []ScannedMilestoneDoc{ + { + Path: stale.RoadmapMilestonePath, + MilestoneID: stale.MilestoneID(), + Markdown: milestoneMarkdownWithIdentity, + HasIdentity: true, + Identity: stale, + }, + { + Path: current.RoadmapMilestonePath, + MilestoneID: current.MilestoneID(), + Markdown: milestoneMarkdownWithIdentity, + HasIdentity: true, + Identity: current, + }, + }, + } + + reader := &fakeReader{ + errs: map[string]error{ + "missing-wi": workitem.ErrNotFound, + }, + items: map[string]workitem.WorkItem{ + "current-wi": { + Title: "Current", + DescriptionHTML: "

current body

", + }, + }, + } + enq := &fakeEnqueuer{} + bridge := newTestBridge(t, fakeScanner{out: out}, reader, enq) + + if err := bridge.Handle(context.Background(), newScanEvent()); err != nil { + t.Fatalf("Handle: %v", err) + } + if len(enq.jobs) != 1 { + t.Fatalf("jobs: got %d, want 1", len(enq.jobs)) + } + if enq.jobs[0].Expected.WorkItemID != "current-wi" { + t.Fatalf("enqueued work item: got %q, want current-wi", enq.jobs[0].Expected.WorkItemID) + } + if len(reader.refs) != 2 { + t.Fatalf("FetchWorkItem calls: got %d, want 2", len(reader.refs)) + } +} + func TestNewBridgeRequiresTodoStateID(t *testing.T) { _, err := NewBridge(fakeScanner{}, &fakeReader{}, &fakeEnqueuer{}, nil, BridgeConfig{ RepoID: "nomadcode", diff --git a/services/core/internal/scheduler/jobs.go b/services/core/internal/scheduler/jobs.go index 4bf741e..71a8d21 100644 --- a/services/core/internal/scheduler/jobs.go +++ b/services/core/internal/scheduler/jobs.go @@ -388,7 +388,6 @@ func buildGenerateInput(task storage.Task) model.GenerateInput { Instructions: instructions, Metadata: map[string]string{ "task_id": task.ID, - "source": task.Source, }, } } diff --git a/services/core/internal/scheduler/jobs_test.go b/services/core/internal/scheduler/jobs_test.go index dcd42a6..a46f796 100644 --- a/services/core/internal/scheduler/jobs_test.go +++ b/services/core/internal/scheduler/jobs_test.go @@ -119,9 +119,12 @@ func TestBuildGenerateInputIncludesTaskMetadata(t *testing.T) { if input.Instructions != "be concise" { t.Fatalf("unexpected instructions: %q", input.Instructions) } - if input.Metadata["task_id"] != "task-1" || input.Metadata["source"] != "plane" { + if input.Metadata["task_id"] != "task-1" { t.Fatalf("unexpected metadata: %#v", input.Metadata) } + if _, ok := input.Metadata["source"]; ok { + t.Fatalf("metadata.source must not be sent to IOP-compatible responses: %#v", input.Metadata) + } } type fakeAgentClient struct { diff --git a/services/core/internal/workitem/provider.go b/services/core/internal/workitem/provider.go index f27c5e0..6d7d785 100644 --- a/services/core/internal/workitem/provider.go +++ b/services/core/internal/workitem/provider.go @@ -9,7 +9,10 @@ import ( "github.com/nomadcode/nomadcode-core/internal/workflow" ) -var ErrInvalidRef = errors.New("invalid workitem ref") +var ( + ErrInvalidRef = errors.New("invalid workitem ref") + ErrNotFound = errors.New("workitem not found") +) type ProviderID string