From 63cddb8a1ea133fb07ad3db6f4430d64312a28a9 Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 3 Jun 2026 22:06:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(fleet):=20Edge=20native=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=20=EC=A4=91=EA=B3=84=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control Plane이 Edge-owned capability와 domain agent 상태를 관찰하고 Edge 명령 이벤트를 중계할 수 있도록 native wire 계약을 확장한다. --- .../milestones/multi-edge-operations.md | 6 +- .../code_review_cloud_G06_0.log | 89 +++ .../01_native_contract/complete.log | 47 ++ .../01_native_contract/plan_cloud_G06_0.log} | 0 .../code_review_cloud_G05_0.log | 63 +++ .../code_review_cloud_G06_1.log | 207 +++++++ .../code_review_cloud_G06_2.log | 185 ++++++ .../code_review_cloud_G06_3.log | 184 ++++++ .../02+01_edge_fleet_ops/complete.log | 41 ++ .../plan_cloud_G05_0.log} | 0 .../02+01_edge_fleet_ops/plan_cloud_G06_1.log | 265 +++++++++ .../02+01_edge_fleet_ops/plan_cloud_G06_2.log | 165 ++++++ .../02+01_edge_fleet_ops/plan_cloud_G06_3.log | 138 +++++ .../CODE_REVIEW-cloud-G06.md | 39 -- .../CODE_REVIEW-cloud-G05.md | 30 - apps/client/lib/gen/proto/iop/control.pb.dart | 524 +++++++++++++++++ .../lib/gen/proto/iop/control.pbjson.dart | 135 ++++- apps/control-plane/internal/wire/edge.go | 12 + apps/control-plane/internal/wire/edge_test.go | 71 +++ apps/edge/internal/controlplane/connector.go | 65 ++- .../internal/controlplane/connector_test.go | 168 +++++- apps/edge/internal/service/service.go | 261 +++++++++ apps/edge/internal/service/service_test.go | 360 ++++++++++++ proto/gen/iop/control.pb.go | 525 ++++++++++++++++-- proto/iop/control.proto | 42 ++ 25 files changed, 3504 insertions(+), 118 deletions(-) create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/code_review_cloud_G06_0.log create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/complete.log rename agent-task/{m-multi-edge-operations/01_native_contract/PLAN-cloud-G06.md => archive/2026/06/m-multi-edge-operations/01_native_contract/plan_cloud_G06_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G05_0.log create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_1.log create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_2.log create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_3.log create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/complete.log rename agent-task/{m-multi-edge-operations/02+01_edge_fleet_ops/PLAN-cloud-G05.md => archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G05_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_1.log create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_2.log create mode 100644 agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_3.log delete mode 100644 agent-task/m-multi-edge-operations/01_native_contract/CODE_REVIEW-cloud-G06.md delete mode 100644 agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/CODE_REVIEW-cloud-G05.md diff --git a/agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md b/agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md index 509dac2..9922529 100644 --- a/agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md +++ b/agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md @@ -35,12 +35,12 @@ Control Plane은 Edge를 제어하기 쉽게 연결하는 레이어이며, Edge ### Epic: [fleet-ops-boundary] Fleet Operations Boundary -- [ ] [native-fleet] Multi-edge 운영 명령과 이벤트 relay는 IOP native protocol을 기준으로 설계한다. +- [x] [native-fleet] Multi-edge 운영 명령과 이벤트 relay는 IOP native protocol을 기준으로 설계한다. - [ ] [edge-status-view] Control Plane은 여러 Edge 상태를 구분해 조회하고 표시할 수 있다. 검증: 두 개 이상의 Edge 상태가 구분되는 조회/표시 경로를 확인한다. - [ ] [history-agent-state] Edge별 실행/명령 결과와 domain agent capability/status/command summary가 운영 화면에서 구분된다. - [x] [openai-routing] OpenAI-compatible 표면은 특정 Edge/adapter로 라우팅되는 inference 호환 경로로 제한한다. - [x] [a2a-routing] A2A 표면은 특정 Edge/adapter로 위임되는 agent task 경로로 제한한다. -- [ ] [edge-ownership] Edge는 설정, Node registry, 로컬 런타임 상태의 원본 소유권을 유지하고, Control Plane은 이동 가능한 제어 attachment로만 동작한다. +- [x] [edge-ownership] Edge는 설정, Node registry, 로컬 런타임 상태의 원본 소유권을 유지하고, Control Plane은 이동 가능한 제어 attachment로만 동작한다. ## 완료 리뷰 @@ -71,3 +71,5 @@ Control Plane은 Edge를 제어하기 쉽게 연결하는 레이어이며, Edge - 결정됨: OTO/build-deploy domain agent는 1차 fleet 운영 capability에 포함한다. Control Plane은 Edge-owned capability/status/command summary만 다루고 artifact/log/state 원본은 소유하지 않는다. - 완료 근거: [openai-routing] `apps/edge/internal/openai/server.go`는 Edge-local `SubmitRun`/`OllamaAPI`로만 `adapter + target` inference 요청을 변환하며 Control Plane 운영 제어 표면으로 쓰지 않는다. 검증: `go test ./apps/edge/internal/openai` 통과. - 완료 근거: [a2a-routing] `apps/edge/internal/input/a2a/server.go`는 Edge-local `SubmitRun`/`CancelRun`을 통해 특정 `adapter + target` agent task 위임만 처리한다. 검증: `go test ./apps/edge/internal/input/a2a` 통과. +- 완료 근거: [native-fleet] `proto/iop/control.proto`에 native command 요청/응답/이벤트 relay와 capability/status 요약 메시지를 추가하고, Control Plane과 Edge의 parser map을 갱신했다. 검증: `make proto proto-dart` 및 `go test ./apps/control-plane/internal/wire` 통과. +- 완료 근거: [edge-ownership] Control Plane native contract/registry에 Edge config, Node registry, agent lifecycle detail 원본이 노출되지 않도록 descriptor 및 reflect 기반 소유권 가드 테스트(`TestProtoOwnershipGuard`, `TestControlPlaneRegistryOwnershipGuard`)를 추가했다. 검증: `go test ./apps/control-plane/internal/wire` 통과. diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/code_review_cloud_G06_0.log b/agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/code_review_cloud_G06_0.log new file mode 100644 index 0000000..56de8dd --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/code_review_cloud_G06_0.log @@ -0,0 +1,89 @@ + +# Code Review - Native Fleet Contract + +## Review Scope + +- `proto/iop/control.proto` +- proto generated files +- `apps/control-plane/internal/wire/edge.go` +- `apps/edge/internal/controlplane/connector.go` +- ownership guard tests + +## Roadmap Targets + +- `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md` `[native-fleet]` +- `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md` `[edge-ownership]` + +## Reviewer Checklist + +- [x] Control Plane이 Edge 설정, Node registry, runtime/automation state 원본을 소유하는 필드나 저장소가 생기지 않았다. +- [x] OTO/build-deploy는 capability/status/command summary로만 표현된다. +- [x] command 요청/응답/event relay 메시지가 request/result 추적에 충분하다. +- [x] parser map이 양쪽에서 대칭적으로 갱신되었다. +- [x] proto 생성물이 누락되지 않았다. +- [x] 실패/unknown enum 처리 테스트가 있다. + +## Verification Result + +- [x] `make proto` +- [x] `make proto-dart` +- [x] `go test ./apps/control-plane/internal/wire ./apps/edge/internal/controlplane` +- [x] 필요한 경우 `cd apps/client && flutter test` + +검증 상세: + +```text +$ make proto +protoc ... proto/iop/control.proto ... + +$ make proto-dart +protoc --plugin=protoc-gen-dart=/config/.local/bin/protoc-gen-dart ... proto/iop/control.proto ... + +$ go test -count=1 ./apps/control-plane/internal/wire ./apps/edge/internal/controlplane +ok iop/apps/control-plane/internal/wire 1.310s +ok iop/apps/edge/internal/controlplane 4.436s + +$ make test-control-plane-edge-wire +[cp-edge-wire] Control Plane-Edge wire smoke PASSED. + +$ go test ./... +ok iop/proto/gen/iop [no test files] + +$ cd apps/client && flutter test +00:02 +15: All tests passed! +``` + +## User Review + +검토 기본값: 이 리뷰는 사용자가 명시적으로 보류하지 않으면 구현자가 자체 검증 후 완료 처리한다. + +## Notes + +- 리뷰 중 발견한 계약 변경은 후속 plan의 구현 전제에 영향을 주므로 milestone 작업 맥락에 함께 반영한다. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: + - Nit: `proto/iop/control.proto:139`에 EOF 빈 줄이 하나 남아 `git diff --check`가 `new blank line at EOF`를 보고한다. 기능/계약에는 영향이 없지만 후속 정리 시 마지막 빈 줄 하나를 제거하면 된다. +- 다음 단계: PASS이므로 active plan/review를 log로 아카이브하고 `complete.log`를 작성한 뒤 task directory를 `agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/`로 이동한다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/complete.log b/agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/complete.log new file mode 100644 index 0000000..9e1f0df --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/complete.log @@ -0,0 +1,47 @@ +# Complete - m-multi-edge-operations/01_native_contract + +## 완료 일시 + +2026-06-03 + +## 요약 + +Native fleet contract 구현을 1회 리뷰 루프로 검토했고 최종 판정은 PASS다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G06_0.log` | `code_review_cloud_G06_0.log` | PASS | Control Plane-Edge native command/status/capability 계약, Go/Dart 생성물, parser map, ownership guard 테스트가 계획 범위를 충족했다. | + +## 구현/정리 내용 + +- `proto/iop/control.proto`에 Edge capability summary, domain agent summary, command request/response/event 메시지를 추가하고 `EdgeStatusResponse`에 summary 필드를 연결했다. +- Go/Dart protobuf 생성물을 갱신했다. +- Control Plane과 Edge connector parser map에 새 command 메시지를 추가했다. +- Control Plane wire 테스트에 proto/registry ownership guard를 추가했다. + +## 최종 검증 + +- `make proto` - PASS; Go protobuf 생성이 성공했다. +- `make proto-dart` - PASS; Dart protobuf 생성이 성공했다. +- `go test -count=1 ./apps/control-plane/internal/wire ./apps/edge/internal/controlplane` - PASS; `iop/apps/control-plane/internal/wire` 1.310s, `iop/apps/edge/internal/controlplane` 4.436s. +- `make test-control-plane-edge-wire` - PASS; `Control Plane-Edge wire smoke PASSED.` +- `go test ./...` - PASS; 전체 Go 테스트가 성공했다. +- `cd apps/client && flutter test` - PASS; `00:02 +15: All tests passed!` + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md` +- Completed task ids: + - `native-fleet`: PASS; evidence=`agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/plan_cloud_G06_0.log`, `agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/code_review_cloud_G06_0.log`; verification=`make proto`, `make proto-dart`, `go test -count=1 ./apps/control-plane/internal/wire ./apps/edge/internal/controlplane`, `make test-control-plane-edge-wire` + - `edge-ownership`: PASS; evidence=`agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/plan_cloud_G06_0.log`, `agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/code_review_cloud_G06_0.log`; verification=`go test -count=1 ./apps/control-plane/internal/wire ./apps/edge/internal/controlplane` +- Not completed task ids: 없음 + +## 잔여 Nit + +- `proto/iop/control.proto:139`에 EOF 빈 줄이 하나 남아 `git diff --check`가 `new blank line at EOF`를 보고한다. + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-multi-edge-operations/01_native_contract/PLAN-cloud-G06.md b/agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/plan_cloud_G06_0.log similarity index 100% rename from agent-task/m-multi-edge-operations/01_native_contract/PLAN-cloud-G06.md rename to agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/plan_cloud_G06_0.log diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G05_0.log b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G05_0.log new file mode 100644 index 0000000..4a8aaa0 --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G05_0.log @@ -0,0 +1,63 @@ + +# Code Review - Edge Fleet Operations + +## Review Scope + +- `apps/edge/internal/controlplane/connector.go` +- `apps/edge/internal/service/service.go` +- Edge connector/service tests + +## Dependency Check + +- [x] `agent-task/m-multi-edge-operations/01_native_contract/complete.log` 확인 + +## Reviewer Checklist + +- [ ] command 처리가 Edge-local service 책임으로 남아 있다. +- [ ] Control Plane 연결/재연결이 Edge 설정 원본을 변경하지 않는다. +- [ ] OTO/build-deploy 정보는 summary 수준이다. +- [ ] unsupported/busy/error command 응답이 명확하다. +- [ ] event relay가 연결 실패/재연결 상황에서 안전하다. +- [ ] secret/config detail이 status response로 새지 않는다. + +## Verification Result + +- [x] `go test ./apps/edge/internal/service ./apps/edge/internal/controlplane` +- [x] 필요한 경우 `go test ./apps/edge/...` + +## User Review + +검토 기본값: 이 리뷰는 사용자가 명시적으로 보류하지 않으면 구현자가 자체 검증 후 완료 처리한다. + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Fail - `agent.command`가 실제 node command/run/cancel 경로를 호출하지 않고 synthetic success를 반환한다. + - completeness: Fail - 계획의 command 처리, busy/error 상태, summary-only status 요구가 모두 충족되지 않았다. + - test coverage: Fail - 현재 테스트는 node transport를 사용하지 않는 fake 성공 경로를 통과시켜 핵심 회귀를 잡지 못한다. + - API contract: Fail - status response가 `NodeConfigPayload`를 그대로 포함해 native summary 계약과 secret/config 비노출 체크포인트와 충돌한다. + - code quality: Warn - `git diff --check`가 trailing whitespace와 EOF blank line을 보고한다. + - plan deviation: Fail - plan은 Edge-owned command result/progress relay를 요구했지만 구현은 command 수행을 시뮬레이션한다. + - verification trust: Warn - `go test -count=1 ./apps/edge/...`는 통과했지만 Required 동작을 검증하지 않는 테스트라 충분한 신뢰 증거가 아니다. +- 발견된 문제: + - Required: `apps/edge/internal/service/service.go:654`의 `agent.command` 분기는 `ResolveNode` 뒤에 `entry.Client.Send`, `toki.SendRequestTyped`, `SubmitRun`, `CancelRun`, `TerminateSession`, `Capabilities` 등 실제 Edge-local service/transport 작업을 전혀 호출하지 않고 `time.Sleep` 후 `completed`를 반환한다. `apps/edge/internal/service/service_test.go:501` 테스트도 `Client`가 없는 `NodeEntry`로 성공을 기대해 이 결함을 고정한다. `agent.command`는 지원하는 operation/parameters를 기존 service command/run/cancel 경로에 매핑하고 downstream ACK/error를 response로 반영하거나, 아직 구현하지 않은 command는 `unsupported`로 반환해야 한다. + - Required: `apps/edge/internal/service/service.go:550`의 capability summary는 연결된 OTO가 `LifecycleState: "deploying"`이어도 `build-deploy` capability를 항상 `status: "ready"`로 반환한다. `apps/edge/internal/service/service_test.go:395`는 non-ready 상태 fixture를 만들고도 `ready`를 기대해 busy/error 상태 요구를 검증하지 못한다. lifecycle state를 ready/busy/error summary로 변환하고, command가 busy/error를 명확히 반환하는 회귀 테스트를 추가해야 한다. + - Required: `apps/edge/internal/controlplane/connector.go:366`에서 status response node snapshot에 `Config: s.Config`를 그대로 복사하고, `apps/edge/internal/controlplane/connector_test.go:731`은 이 동작을 요구한다. `NodeConfigPayload`는 `apps/edge/internal/node/mapper.go:16`, `apps/edge/internal/node/mapper.go:69` 기준 workspace root, adapter endpoint, CLI command/args/env를 포함할 수 있으므로 review checklist의 "secret/config detail이 status response로 새지 않는다"와 summary-only 계약을 깨뜨린다. Control Plane status에는 summary 필드만 남기고 full config를 제거하거나 명시적으로 sanitize해야 한다. + - Nit: `git diff --check`가 `apps/edge/internal/controlplane/connector_test.go:818`, `apps/edge/internal/service/service.go:552` 등 trailing whitespace와 `proto/iop/control.proto:139` EOF blank line을 보고한다. 후속 구현에서 `gofmt`와 `git diff --check`로 정리한다. +- 다음 단계: FAIL follow-up으로 `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성한다. + +## 코드리뷰 전용 체크리스트 + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-cloud-G05.md`를 `code_review_cloud_G05_0.log`로 아카이브한다. +- [x] active `PLAN-cloud-G05.md`를 `plan_cloud_G05_0.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리를 archive로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] 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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_1.log b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_1.log new file mode 100644 index 0000000..fe666bb --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_1.log @@ -0,0 +1,207 @@ + + +# Code Review Reference - REVIEW_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 user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> 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-03 +task=m-multi-edge-operations/02+01_edge_fleet_ops, plan=1, tag=REVIEW_API + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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-multi-edge-operations/02+01_edge_fleet_ops/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-multi-edge-operations`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_API-1] `agent.command` 실제 dispatch/unsupported 처리 | [x] | +| [REVIEW_API-2] lifecycle 기반 ready/busy/error summary | [x] | +| [REVIEW_API-3] status config 비노출 | [x] | +| [REVIEW_API-4] 검증과 whitespace 정리 | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_API-1] `agent.command`가 실제 Edge-local service/node command 경로를 사용하거나 명시적으로 `unsupported`를 반환하도록 수정하고, fake success 회귀 테스트를 추가한다. +- [x] [REVIEW_API-2] OTO/build-deploy capability/domain agent summary가 lifecycle 기반 ready/busy/error 상태를 반영하도록 수정하고 테스트한다. +- [x] [REVIEW_API-3] Control Plane status response에서 full `NodeConfigPayload`/secret-like config detail이 새지 않도록 수정하고 테스트한다. +- [x] [REVIEW_API-4] `gofmt`/`git diff --check`/fresh Go tests를 실행하고 결과를 기록한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_1.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_1.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/`를 `agent-task/archive/YYYY/MM/m-multi-edge-operations/02+01_edge_fleet_ops/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-multi-edge-operations/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] 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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +없음 + +## 주요 설계 결정 + +1. **Config Leakage 방지**: `buildStatusResponse` 루프 내부에서 `EdgeNodeSnapshot.Config` 에 `s.Config` 를 주입하지 않고 `nil` 로 대입하여 Control Plane으로 민감 정보가 포함된 Node 설정 전체가 유출되지 않도록 구조적으로 제한했습니다. 기존 config 전파 테스트도 non-leakage 검증 테스트로 전환했습니다. +2. **Lifecycle 기반 상태 판단**: OTO 에이전트들의 `LifecycleState` 값을 ready/busy/error 상태값으로 매핑하는 `lifecycleToSummaryStatus` 헬퍼를 추가하여 OTO 에이전트의 연결/빌드/배포 및 에러 상황이 capability summary 및 domain agent summary 에 동적으로 매핑되도록 처리했습니다. +3. **실제 Node Command 위임**: `agent.command` 진입 시 `Capabilities` 명령 등 실제 downstream node command를 호출하고 그 결과를 파싱해서 `EdgeCommandResponse` 로 변환하도록 수정하여 fake success 시뮬레이션을 걷어냈습니다. 또한, client 연결이 유실되었을 때 transport panic이 일어나지 않도록 `entry.Client == nil` 가드를 선행 배치했습니다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `agent.command`가 fake success를 만들지 않고 실제 service/node command result 또는 unsupported/error를 반환하는지 확인한다. +- OTO/build-deploy summary가 lifecycle 기반 ready/busy/error 상태를 반영하는지 확인한다. +- Control Plane status response에 `NodeConfigPayload`, CLI env, adapter endpoint, workspace root 같은 config detail이 새지 않는지 확인한다. +- `git diff --check`, fresh Go tests, Control Plane-Edge wire smoke 결과가 실제 stdout/stderr로 기록됐는지 확인한다. + +## 검증 결과 + +### REVIEW_API-1 중간 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/service +ok iop/apps/edge/internal/service 0.018s +``` + +### REVIEW_API-2 중간 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/service +ok iop/apps/edge/internal/service 0.018s +``` + +### REVIEW_API-3 중간 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/controlplane +ok iop/apps/edge/internal/controlplane 4.456s +``` + +### REVIEW_API-4 중간 검증 + +```bash +$ git diff --check +(아무 출력 없음, 정상) +``` + +### 최종 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/controlplane +ok iop/apps/edge/internal/service 0.018s +ok iop/apps/edge/internal/controlplane 4.456s + +$ go test -count=1 ./apps/edge/... +ok iop/apps/edge/cmd/edge 0.087s +ok iop/apps/edge/internal/bootstrap 0.044s +ok iop/apps/edge/internal/controlplane 4.464s +ok iop/apps/edge/internal/events 0.009s +ok iop/apps/edge/internal/input 0.018s +ok iop/apps/edge/internal/input/a2a 0.014s +ok iop/apps/edge/internal/node 0.018s +ok iop/apps/edge/internal/openai 1.523s +ok iop/apps/edge/internal/opsconsole 0.007s +ok iop/apps/edge/internal/service 0.016s +ok iop/apps/edge/internal/transport 0.008s + +$ make test-control-plane-edge-wire +./scripts/e2e-control-plane-edge-wire.sh +[cp-edge-wire] NOTE: auxiliary smoke only - verifies Control Plane-Edge hello and disconnect via real processes. +[cp-edge-wire] shellcheck not found, skipping +[cp-edge-wire] ports: cp_http=29826 cp_ws=30235 cp_edge_wire=32079 edge_node=32598 edge_bootstrap=33572 edge_metrics=34995 +[cp-edge-wire] building temp binaries... +[cp-edge-wire] starting Control Plane... +[cp-edge-wire] waiting for Control Plane edge wire port 32079 (timeout: 20s)... +[cp-edge-wire] Control Plane edge wire port ready +[cp-edge-wire] starting Edge... +[cp-edge-wire] waiting for hello accepted (timeout: 30s)... +[cp-edge-wire] CP: hello accepted +[cp-edge-wire] Edge: connected to control plane +[cp-edge-wire] stopping Edge process to trigger disconnect... +[cp-edge-wire] waiting for disconnect marker on CP (timeout: 20s)... +[cp-edge-wire] CP: edge disconnected +=== CONTROL PLANE OUTPUT === +... +=== EDGE PROCESS OUTPUT === +... +[cp-edge-wire] Control Plane-Edge wire smoke PASSED. + +$ git diff --check +(아무 출력 없음, 정상) +``` + +--- + +> **[IMPLEMENTING AGENT - BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## 섹션 소유권 + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요, 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크 | +| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크 | +| 코드리뷰 전용 체크리스트 | Review agent only | 구현 에이전트가 수정하지 않음 | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트 | placeholder를 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트 | 필요 없으면 `상태: 없음` 유지 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 | 구현 에이전트 | 실제 stdout/stderr 기록 | +| 코드리뷰 결과 | 리뷰 에이전트 | 스텁에 포함하지 않음 | + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - correctness: Pass - `agent.command`의 nil-client fake success는 제거됐고, supported `capabilities` command는 `Service.Capabilities`를 통해 node command 경로로 라우팅된다. + - completeness: Fail - 계획의 `REVIEW_API-1` 세부 요구였던 supported command 성공 path와 downstream node error 전파 테스트가 없다. + - test coverage: Fail - 현재 `apps/edge/internal/service/service_test.go:522`는 nil client error만 검증하고, connected node에서 `agent.command`가 실제 `NodeCommandRequest`를 보내는지와 node error를 `EdgeCommandResponse.Status=error`로 바꾸는지를 검증하지 않는다. + - API contract: Pass - Control Plane status response는 `EdgeNodeSnapshot.Config`를 nil로 유지하고 capability/domain agent summary만 추가한다. + - code quality: Pass - `git diff --check` 통과, `gofmt` 상태도 대상 테스트 통과로 확인됐다. + - plan deviation: Fail - follow-up plan의 `apps/edge/internal/service/service_test.go`: supported command가 실제 `NodeCommandRequest`/service method를 호출하고 node error를 전파하는 테스트 추가 항목이 미완료다. + - verification trust: Pass - `go test -count=1` 대상/edge 전체 테스트, `make test-control-plane-edge-wire`, `git diff --check`를 재실행해 기록과 일치함을 확인했다. +- 발견된 문제: + - Required: `apps/edge/internal/service/service_test.go:522`의 `agent.command does not fake success on nil client`는 fake success 회귀만 막고, `apps/edge/internal/service/service.go:770`의 실제 supported `capabilities` branch가 `NodeCommandRequest`를 보내는지 검증하지 않는다. 같은 테스트 파일에 fake proto-socket node 또는 주입 가능한 command sender를 사용해 `agent.command` + `parameters.command=capabilities`가 `NODE_COMMAND_TYPE_CAPABILITIES` request를 전송하고, successful `NodeCommandResponse.Result`가 completed `EdgeCommandResponse` summary로 반영되는 테스트를 추가해야 한다. + - Required: 같은 supported command path에서 node가 `NodeCommandResponse{Error: ...}`를 반환할 때 `apps/edge/internal/service/service.go:639`/`sendNodeCommand` 경유 error가 `EdgeCommandResponse{Status:"error", Error:...}`와 failed event로 전파되는 테스트가 없다. node error fixture를 추가해 `node reported error`가 response/error event로 보존되는지 검증해야 한다. +- 다음 단계: FAIL follow-up으로 supported `agent.command` success/error coverage만 좁게 보강하는 `PLAN-cloud-G06.md`와 `CODE_REVIEW-cloud-G06.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_2.log b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_2.log new file mode 100644 index 0000000..613d4e4 --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_2.log @@ -0,0 +1,185 @@ + + +# Code Review Reference - REVIEW_REVIEW_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 user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> 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-03 +task=m-multi-edge-operations/02+01_edge_fleet_ops, plan=2, tag=REVIEW_REVIEW_API + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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-multi-edge-operations/02+01_edge_fleet_ops/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-multi-edge-operations`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_REVIEW_API-1] supported command success test | [x] | +| [REVIEW_REVIEW_API-2] downstream error test | [x] | +| [REVIEW_REVIEW_API-3] verification | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_REVIEW_API-1] `agent.command` capabilities success path가 실제 `NodeCommandRequest`를 보내고 completed response/event를 반환하는 테스트를 추가한다. +- [x] [REVIEW_REVIEW_API-2] `agent.command` capabilities downstream node error가 error response and failed event로 전파되는 테스트를 추가한다. +- [x] [REVIEW_REVIEW_API-3] fresh service/edge tests와 `git diff --check`를 실행하고 결과를 기록한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다. +- [x] `.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-multi-edge-operations/02+01_edge_fleet_ops/`를 `agent-task/archive/YYYY/MM/m-multi-edge-operations/02+01_edge_fleet_ops/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-multi-edge-operations/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- `TestServiceCapabilitiesAndDomainAgents`에서 OTO lifecycle table-driven test의 기대 lifecycle_state 매핑 불일치("connected" 기본값)를 해결하기 위해 `expectedState` 필드를 보강했습니다. +- `ExecuteCommand`에서 `agent.command` 분기 시 `ResolveNode` 이전에 `"started"` 이벤트를 먼저 쏘도록 변경하여, events assert (`len(events) >= 2`) 조건을 만족하도록 하고 내부 `capabilities` 안의 중복 `started` event 송출은 제거했습니다. + +## 주요 설계 결정 + +- `net.Pipe` 기반의 `edgeClient` 와 `nodeClient` 에 request listener를 바인딩하여, 실제 `NodeCommandRequest` 및 `NodeCommandResponse` 통신을 검증하는 독립적인 테스트 구조 구축. +- `agent.command` 흐름 시작 시 단일한 `started` 이벤트 방출 구조 통일. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `agent.command` capabilities success test가 실제 `NodeCommandRequest` type/adapter/target/session을 확인하는지 본다. +- downstream `NodeCommandResponse.Error`가 `EdgeCommandResponse.Status=error`와 failed event로 전파되는지 본다. +- 새 테스트가 fake success만 반복하지 않고 connected node command round-trip을 검증하는지 본다. +- fresh `go test -count=1` 출력과 `git diff --check`가 기록됐는지 본다. + +## 검증 결과 + +### REVIEW_REVIEW_API-1 중간 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/service +ok iop/apps/edge/internal/service 0.025s +``` + +### REVIEW_REVIEW_API-2 중간 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/service +ok iop/apps/edge/internal/service 0.025s +``` + +### 최종 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/service +ok iop/apps/edge/internal/service 0.025s + +$ go test -count=1 ./apps/edge/... +ok iop/apps/edge/cmd/edge 0.171s +ok iop/apps/edge/internal/bootstrap 0.112s +ok iop/apps/edge/internal/controlplane 4.458s +ok iop/apps/edge/internal/events 0.005s +ok iop/apps/edge/internal/input 0.009s +ok iop/apps/edge/internal/input/a2a 0.007s +ok iop/apps/edge/internal/node 0.010s +ok iop/apps/edge/internal/openai 1.510s +ok iop/apps/edge/internal/opsconsole 0.014s +ok iop/apps/edge/internal/service 0.024s +ok iop/apps/edge/internal/transport 0.015s + +$ git diff --check +(출력 없음 - exit code 0) + +$ make test-control-plane-edge-wire +./scripts/e2e-control-plane-edge-wire.sh +[cp-edge-wire] NOTE: auxiliary smoke only - verifies Control Plane-Edge hello and disconnect via real processes. +[cp-edge-wire] shellcheck not found, skipping +[cp-edge-wire] ports: cp_http=29914 cp_ws=31044 cp_edge_wire=31614 edge_node=32795 edge_bootstrap=33538 edge_metrics=34378 +[cp-edge-wire] building temp binaries... +[cp-edge-wire] starting Control Plane... +[cp-edge-wire] waiting for Control Plane edge wire port 31614 (timeout: 20s)... +[cp-edge-wire] Control Plane edge wire port ready +[cp-edge-wire] starting Edge... +[cp-edge-wire] waiting for hello accepted (timeout: 30s)... +[cp-edge-wire] CP: hello accepted +[cp-edge-wire] Edge: connected to control plane +[cp-edge-wire] stopping Edge process to trigger disconnect... +[cp-edge-wire] waiting for disconnect marker on CP (timeout: 20s)... +[cp-edge-wire] CP: edge disconnected +=== CONTROL PLANE OUTPUT === +... +=== EDGE PROCESS OUTPUT === +... +=== EDGE LOG === +... +=========================== +[cp-edge-wire] Control Plane-Edge wire smoke PASSED. +``` + +--- + +> **[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. + +## 코드리뷰 결과 + +- 종합 판정: FAIL + +### 차원별 평가 + +| 차원 | 평가 | 근거 | +|------|------|------| +| Correctness | Pass | `agent.command` capabilities path는 실제 node command로 연결되고 downstream error도 error response/failed event로 변환된다. | +| Completeness | Fail | 계획의 success test 요구사항 중 explicit `session_id` 전달 검증이 누락됐다. | +| Test coverage | Fail | `TestServiceExecuteCommandAgentCommandCapabilitiesSuccess`가 type/adapter/target은 확인하지만 session propagation을 고정하지 않는다. | +| API contract | Pass | 현재 구현은 `parameters["session_id"]`를 `NodeCommandRequestSpec.SessionID`로 전달한다. | +| Code quality | Pass | 이번 루프 변경에는 debug print, dead code, TODO가 보이지 않는다. | +| Plan deviation | Fail | REVIEW_REVIEW_API-1의 adapter/target/session request propagation 검증 요구 중 session 축이 빠졌다. | +| Verification trust | Pass | `go test -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`, `git diff --check`, `make test-control-plane-edge-wire`를 재실행했고 모두 통과했다. | + +### 발견된 문제 + +- Required - `apps/edge/internal/service/service_test.go:604`: success listener는 `NodeCommandRequest.Type`, `Adapter`, `Target`만 검증하고, request parameters도 `apps/edge/internal/service/service_test.go:636`에서 `session_id`를 보내지 않는다. 따라서 `apps/edge/internal/service/service.go:775`의 `parameters["session_id"]` -> `NodeCommandRequest.SessionId` 전달이 깨져도 이 테스트가 통과한다. `TestServiceExecuteCommandAgentCommandCapabilitiesSuccess`에 explicit `session_id` 값을 넣고 listener에서 `req.GetSessionId()`가 그 값으로 전달되는지 assert하라. + +### 다음 단계 + +- FAIL 후속: 현재 활성 plan/review를 아카이브한 뒤 `session_id` propagation assertion만 보강하는 다음 `PLAN-cloud-G06.md` / `CODE_REVIEW-cloud-G06.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_3.log b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_3.log new file mode 100644 index 0000000..414e908 --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_3.log @@ -0,0 +1,184 @@ + + +# Code Review Reference - REVIEW_REVIEW_REVIEW_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 user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> 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-03 +task=m-multi-edge-operations/02+01_edge_fleet_ops, plan=3, tag=REVIEW_REVIEW_REVIEW_API + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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-multi-edge-operations/02+01_edge_fleet_ops/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-multi-edge-operations`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_REVIEW_REVIEW_API-1] explicit session propagation test | [x] | +| [REVIEW_REVIEW_REVIEW_API-2] verification | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_REVIEW_REVIEW_API-1] `agent.command` capabilities success test가 explicit `session_id`를 보내고 실제 `NodeCommandRequest.SessionId` 전달을 검증하도록 보강한다. +- [x] [REVIEW_REVIEW_REVIEW_API-2] fresh service/edge tests와 `git diff --check`를 실행하고 결과를 기록한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G06_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/`를 `agent-task/archive/YYYY/MM/m-multi-edge-operations/02+01_edge_fleet_ops/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-multi-edge-operations/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- `TestServiceExecuteCommandAgentCommandCapabilitiesSuccess`와 `TestServiceExecuteCommandAgentCommandCapabilitiesNodeError`에서 `session_id` 파라미터가 실제 `NodeCommandRequest.SessionId`로 전달되는지 검증을 추가했습니다. +- 완료/실패 event `Summary` 검증을 함께 보강했습니다. +- plan=3 범위에서 production code 변경은 추가하지 않았습니다. + +## 주요 설계 결정 + +- 기존 `net.Pipe` 기반 connected-node round-trip 테스트를 유지하고, listener assertion만 보강했습니다. +- success/error 양쪽에 서로 다른 session id를 사용해 assertion이 실제 request payload를 보고 있는지 분명히 했습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `TestServiceExecuteCommandAgentCommandCapabilitiesSuccess`가 explicit `session_id`를 request parameters에 포함하는지 본다. +- fake node listener가 실제 `NodeCommandRequest.SessionId` 값을 assert하는지 본다. +- 새 assertion이 실패해서 production code를 수정했다면 `ExecuteCommand`의 `parameters["session_id"]` -> `NodeCommandRequestSpec.SessionID` mapping 외 범위를 건드리지 않았는지 본다. +- fresh `go test -count=1` 출력과 `git diff --check`가 기록됐는지 본다. + +## 검증 결과 + +### REVIEW_REVIEW_REVIEW_API-1 중간 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/service +ok iop/apps/edge/internal/service 0.017s +``` + +### REVIEW_REVIEW_REVIEW_API-2 중간 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/service +ok iop/apps/edge/internal/service 0.017s +``` + +### 최종 검증 + +```bash +$ go test -count=1 ./apps/edge/internal/service +ok iop/apps/edge/internal/service 0.017s + +$ go test -count=1 ./apps/edge/... +ok iop/apps/edge/cmd/edge 0.078s +ok iop/apps/edge/internal/bootstrap 0.033s +ok iop/apps/edge/internal/controlplane 4.456s +ok iop/apps/edge/internal/events 0.009s +ok iop/apps/edge/internal/input 0.013s +ok iop/apps/edge/internal/input/a2a 0.024s +ok iop/apps/edge/internal/node 0.016s +ok iop/apps/edge/internal/openai 1.512s +ok iop/apps/edge/internal/opsconsole 0.021s +ok iop/apps/edge/internal/service 0.017s +ok iop/apps/edge/internal/transport 0.011s + +$ git diff --check +(출력 없음 - exit code 0) + +$ make test-control-plane-edge-wire +./scripts/e2e-control-plane-edge-wire.sh +[cp-edge-wire] NOTE: auxiliary smoke only - verifies Control Plane-Edge hello and disconnect via real processes. +[cp-edge-wire] shellcheck not found, skipping +[cp-edge-wire] ports: cp_http=29727 cp_ws=30781 cp_edge_wire=31711 edge_node=32467 edge_bootstrap=33274 edge_metrics=34376 +[cp-edge-wire] building temp binaries... +[cp-edge-wire] starting Control Plane... +[cp-edge-wire] waiting for Control Plane edge wire port 31711 (timeout: 20s)... +[cp-edge-wire] Control Plane edge wire port ready +[cp-edge-wire] starting Edge... +[cp-edge-wire] waiting for hello accepted (timeout: 30s)... +[cp-edge-wire] CP: hello accepted +[cp-edge-wire] Edge: connected to control plane +[cp-edge-wire] stopping Edge process to trigger disconnect... +[cp-edge-wire] waiting for disconnect marker on CP (timeout: 20s)... +[cp-edge-wire] CP: edge disconnected +=== CONTROL PLANE OUTPUT === +... +=== EDGE PROCESS OUTPUT === +... +=== EDGE LOG === +... +=========================== +[cp-edge-wire] Control Plane-Edge wire smoke PASSED. +``` + +--- + +> **[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. + +## 코드리뷰 결과 + +- 종합 판정: PASS + +### 차원별 평가 + +| 차원 | 평가 | 근거 | +|------|------|------| +| Correctness | Pass | `agent.command` capabilities path가 실제 node command request를 보내고, success/error event와 response를 기대대로 반환한다. | +| Completeness | Pass | plan=3의 explicit `session_id` propagation 보강 요구를 충족했다. | +| Test coverage | Pass | success test가 `session_id` request parameter와 실제 `NodeCommandRequest.SessionId` assertion을 포함한다. error test도 session id와 failed event summary를 함께 고정한다. | +| API contract | Pass | `parameters["session_id"]` -> `NodeCommandRequestSpec.SessionID` -> `NodeCommandRequest.SessionId` 흐름이 테스트로 고정됐다. | +| Code quality | Pass | plan=3 범위에서는 test assertion 보강만 추가됐고 불필요한 production 변경은 없다. | +| Plan deviation | Pass | active review 문서의 plan/tag artifact drift는 리뷰 중 plan=3 기준으로 정리했고, 구현 범위는 계획과 일치한다. | +| Verification trust | Pass | `go test -count=1 ./apps/edge/internal/service`, `go test -count=1 ./apps/edge/...`, `git diff --check`, `make test-control-plane-edge-wire`를 재실행했고 모두 통과했다. | + +### 발견된 문제 + +없음 + +### 다음 단계 + +- PASS: `complete.log`를 작성하고 task 디렉터리를 archive로 이동한다. diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/complete.log b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/complete.log new file mode 100644 index 0000000..33755f9 --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/complete.log @@ -0,0 +1,41 @@ +# Complete - m-multi-edge-operations/02+01_edge_fleet_ops + +## 완료 일시 + +2026-06-03 + +## 요약 + +Edge fleet ops Control Plane/Edge command and status surface review loop completed after 4 reviews; final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G05_0.log` | `code_review_cloud_G05_0.log` | FAIL | Initial implementation left fake `agent.command` success, lifecycle summary gaps, status config leakage, and whitespace issues. | +| `plan_cloud_G06_1.log` | `code_review_cloud_G06_1.log` | FAIL | Follow-up fixed core behavior but lacked real supported command success/error coverage. | +| `plan_cloud_G06_2.log` | `code_review_cloud_G06_2.log` | FAIL | Connected-node tests were added, but explicit `session_id` propagation was not asserted. | +| `plan_cloud_G06_3.log` | `code_review_cloud_G06_3.log` | PASS | Explicit `session_id` propagation assertions and fresh verification completed. | + +## 구현/정리 내용 + +- Added summary-only Control Plane status capability/domain-agent surfaces without leaking full node config. +- Routed supported `agent.command` capabilities requests through the real node command path and propagated downstream errors to response/events. +- Added connected-node `agent.command` success and node-error tests using `net.Pipe`/proto-socket request listeners. +- Added explicit `session_id` propagation assertions for `NodeCommandRequest.SessionId`. +- Repaired active review artifact drift to match the terminal plan=3 review before archiving. + +## 최종 검증 + +- `go test -count=1 ./apps/edge/internal/service` - PASS; `ok iop/apps/edge/internal/service 0.017s`. +- `go test -count=1 ./apps/edge/...` - PASS; all Edge packages passed. +- `git diff --check` - PASS; no output, exit code 0. +- `make test-control-plane-edge-wire` - PASS; Control Plane-Edge wire smoke accepted hello and observed disconnect. + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/PLAN-cloud-G05.md b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G05_0.log similarity index 100% rename from agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/PLAN-cloud-G05.md rename to agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G05_0.log diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_1.log b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_1.log new file mode 100644 index 0000000..5e2b0a9 --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_1.log @@ -0,0 +1,265 @@ + + +# Plan - REVIEW_API Edge Fleet Ops Follow-up + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 코드 변경, 테스트 실행, 실제 stdout/stderr 기록을 끝낸 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. + +구현 중 사용자만 결정할 수 있는 scope 변경, 사용자 소유 외부 환경/secret, 또는 자동 후속으로 해결할 수 없는 차단이 생기면 `CODE_REVIEW-cloud-G06.md`의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 채우고 중단한다. 명령 재실행이나 증거 수집으로 닫을 수 있는 공백은 사용자 리뷰 요청이 아니라 구현/검증 항목으로 처리한다. + +## 배경 + +이전 구현은 native fleet command 표면을 추가했지만 `agent.command`가 실제 node/service command를 실행하지 않고 성공을 시뮬레이션한다. 또한 OTO/build-deploy 상태가 lifecycle과 무관하게 `ready`로 표시되고, Control Plane status에 full `NodeConfigPayload`가 남아 summary-only 및 config 비노출 요구와 충돌한다. 이 follow-up은 기존 Edge-owned service 경계를 유지하면서 해당 Required 결함만 닫는다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. code-review가 그 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G05_0.log` +- `agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G05_0.log` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `apps/edge/internal/service/service.go` +- `apps/edge/internal/service/service_test.go` +- `apps/edge/internal/controlplane/connector.go` +- `apps/edge/internal/controlplane/connector_test.go` +- `apps/edge/internal/node/mapper.go` +- `apps/edge/internal/node/registry.go` +- `proto/iop/control.proto` +- `proto/iop/runtime.proto` + +### 테스트 환경 규칙 + +- test_env: `local` +- `agent-test/local/rules.md` present/read: yes +- matched profile: `agent-test/local/edge-smoke.md` +- applied commands: `go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/controlplane`, `go test -count=1 ./apps/edge/...` +- follow-up final commands: use fresh Go tests, `git diff --check`, and Control Plane-Edge wire smoke when command/status wire behavior changes. + +### 테스트 커버리지 공백 + +- `agent.command` real dispatch: not covered. Current `TestServiceExecuteCommand/agent.command` succeeds with a `NodeEntry` that has no client. +- busy/error summary: not covered. Current capability test uses lifecycle `deploying` but expects `ready`. +- status config non-leak: inverted. Current connector test asserts `NodeConfigPayload` is propagated. +- diff cleanliness: not covered. `git diff --check` currently fails on trailing whitespace and EOF blank lines. + +### 심볼 참조 + +- Renamed/removed symbols: none. +- Interface impact: `StatusProvider` is implemented by `service.Service` and test fake providers in `apps/edge/internal/controlplane/connector_test.go`. + +### 분할 판단 + +- Existing split subtask: `m-multi-edge-operations/02+01_edge_fleet_ops`. +- Predecessor `01_native_contract` is satisfied by `agent-task/archive/2026/06/m-multi-edge-operations/01_native_contract/complete.log`. +- No new split is created because all fixes are in the same Edge service/controlplane status-command boundary and share the same verification commands. + +### 범위 결정 근거 + +- Do not implement Control Plane HTTP fleet API or client UI; those belong to dependent subtasks `03+01,02_control_plane_fleet_api` and `04+03_client_fleet_ui`. +- Do not expand OTO artifact/log storage. Status remains summary-only. +- Prefer not to change proto schema unless unavoidable. It is sufficient to stop populating sensitive config in Edge status and add regression tests; generated proto churn belongs only to an explicit contract change. +- This follow-up does not add `Roadmap Targets` because the archived parent plan did not contain a fixed Roadmap Targets section. + +### 빌드 등급 + +- build=`cloud-G06`, review=`cloud-G06`: native wire command semantics, status/security summary, and weak existing tests require stronger review than a mechanical local cleanup. + +## 구현 체크리스트 + +- [ ] [REVIEW_API-1] `agent.command`가 실제 Edge-local service/node command 경로를 사용하거나 명시적으로 `unsupported`를 반환하도록 수정하고, fake success 회귀 테스트를 추가한다. +- [ ] [REVIEW_API-2] OTO/build-deploy capability/domain agent summary가 lifecycle 기반 ready/busy/error 상태를 반영하도록 수정하고 테스트한다. +- [ ] [REVIEW_API-3] Control Plane status response에서 full `NodeConfigPayload`/secret-like config detail이 새지 않도록 수정하고 테스트한다. +- [ ] [REVIEW_API-4] `gofmt`/`git diff --check`/fresh Go tests를 실행하고 결과를 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_API-1] 실제 command dispatch + +문제: + +`apps/edge/internal/service/service.go:654`의 `agent.command` 분기는 node를 resolve한 뒤 실제 downstream command를 보내지 않고 synthetic `running/completed` event와 `completed` response를 만든다. + +현재: + +```go +// apps/edge/internal/service/service.go:654 +case "agent.command": + entry, err := s.ResolveNode(req.TargetSelector) + ... + time.Sleep(10 * time.Millisecond) + return &iop.EdgeCommandResponse{ + Status: "completed", + Summary: fmt.Sprintf("Command successfully executed on agent %s", entry.Alias), + }, nil +``` + +해결 방법: + +`agent.command`는 `req.Parameters`의 명시 command를 기존 service command로 매핑한다. 지원 범위가 아직 확정되지 않은 command는 성공 시뮬레이션 대신 `unsupported`를 반환한다. + +예시 방향: + +```go +// after +case "agent.command": + switch req.GetParameters()["command"] { + case "capabilities": + view, err := s.Capabilities(ctx, NodeCommandRequestSpec{NodeRef: req.TargetSelector, Adapter: req.GetParameters()["adapter"], Target: req.GetParameters()["target"], SessionID: req.GetParameters()["session_id"]}) + return edgeCommandResponseFromNodeCommand(req, view, err), nil + default: + return unsupportedEdgeCommand(req, "unsupported agent.command command"), nil + } +``` + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/service/service.go`: synthetic success 제거. +- [ ] `apps/edge/internal/service/service.go`: downstream error를 `Status: "error"`와 `Error`로 변환. +- [ ] `apps/edge/internal/service/service_test.go`: `agent.command`가 client 없는 node에서 성공하지 않는 테스트 추가. +- [ ] `apps/edge/internal/service/service_test.go`: supported command가 실제 `NodeCommandRequest`/service method를 호출하고 node error를 전파하는 테스트 추가. + +테스트 작성: + +- Write `TestServiceExecuteCommandAgentCommandDoesNotFakeSuccess`. +- Write a supported-command test with a fake proto-socket node or injectable command sender if the current service cannot be tested cleanly. + +중간 검증: + +```bash +go test -count=1 ./apps/edge/internal/service +``` + +### [REVIEW_API-2] lifecycle 기반 상태 요약 + +문제: + +`apps/edge/internal/service/service.go:550`은 OTO가 `LifecycleState: "deploying"`이어도 `build-deploy` capability를 항상 `Status: "ready"`로 반환한다. busy/error 상태를 명확히 반환하라는 계획 요구와 맞지 않는다. + +현재: + +```go +// apps/edge/internal/service/service.go:573 +out = append(out, &iop.EdgeCapabilitySummary{ + Kind: "build-deploy", + Available: true, + Status: "ready", + Summary: "OTO build-deploy capability available", +}) +``` + +해결 방법: + +`NodeSnapshot.LifecycleState`를 summary status로 변환하는 작은 helper를 둔다. 최소 기준은 connected/empty = `ready`, deploying/running/busy = `busy`, error/failed = `error`, 그 밖은 `busy` 또는 명시 summary로 둔다. `EdgeDomainAgentSummary`도 같은 판단을 반영한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/service/service.go`: lifecycle-to-summary helper 추가. +- [ ] `apps/edge/internal/service/service.go`: OTO capability status와 available 값을 helper 결과로 설정. +- [ ] `apps/edge/internal/service/service_test.go`: deploying/running/error 상태별 ready/busy/error 기대값 추가. +- [ ] `apps/edge/internal/service/service_test.go`: busy/error command response가 명확한지 확인하는 테스트 추가. + +테스트 작성: + +- Extend `TestServiceCapabilitiesAndDomainAgents` or split table tests for lifecycle states. + +중간 검증: + +```bash +go test -count=1 ./apps/edge/internal/service +``` + +### [REVIEW_API-3] status config 비노출 + +문제: + +`apps/edge/internal/controlplane/connector.go:366`은 `EdgeNodeSnapshot.Config`에 full node config를 복사한다. `apps/edge/internal/node/mapper.go:16`와 `apps/edge/internal/node/mapper.go:69` 기준 이 payload에는 workspace root, adapter endpoint, CLI command/args/env가 포함될 수 있다. + +현재: + +```go +// apps/edge/internal/controlplane/connector.go:367 +nodes = append(nodes, &iop.EdgeNodeSnapshot{ + NodeId: s.NodeID, + Alias: s.Alias, + Label: s.Label, + Connected: true, + Config: s.Config, +}) +``` + +해결 방법: + +Control Plane status response에는 identity, connected flag, capability/domain-agent summary만 싣는다. `Config`는 nil로 두거나 summary-safe 필드로 별도 변환한다. 기존 `TestConnectorBuildStatusResponseWithConfig`는 config propagation 기대 대신 non-leak 기대로 바꾼다. + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/controlplane/connector.go`: `EdgeNodeSnapshot.Config` population 제거 또는 sanitize. +- [ ] `apps/edge/internal/controlplane/connector_test.go`: config가 nil이거나 sanitized임을 검증. +- [ ] `apps/edge/internal/controlplane/connector_test.go`: CLI env/base URL/workspace root가 status response에 나오지 않는 회귀 fixture 추가. +- [ ] `apps/edge/internal/service/service_test.go`: 필요하면 `ListNodeSnapshotsWithConfig` 기대 범위를 Edge-internal snapshot으로만 명확히 조정. + +테스트 작성: + +- Replace or rename `TestConnectorBuildStatusResponseWithConfig` with a non-leak regression test. + +중간 검증: + +```bash +go test -count=1 ./apps/edge/internal/controlplane +``` + +### [REVIEW_API-4] 검증과 정리 + +문제: + +현재 `git diff --check`가 trailing whitespace와 EOF blank line을 보고한다. 코드 변경 후 fresh tests와 diff cleanliness를 같이 확인해야 한다. + +해결 방법: + +Go 파일은 `gofmt`로 정리하고, proto EOF blank line과 trailing whitespace를 제거한다. 테스트는 cache 없는 명령으로 실행한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/service/service.go`, `apps/edge/internal/service/service_test.go`, `apps/edge/internal/controlplane/connector.go`, `apps/edge/internal/controlplane/connector_test.go`에 `gofmt` 적용. +- [ ] `proto/iop/control.proto` EOF blank line 정리. proto schema 자체는 불필요하면 변경하지 않는다. +- [ ] `git diff --check` 통과. + +테스트 작성: + +- 별도 테스트 파일 추가 없음. 위 항목들의 회귀 테스트와 diff check가 검증이다. + +중간 검증: + +```bash +git diff --check +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/edge/internal/service/service.go` | REVIEW_API-1, REVIEW_API-2, REVIEW_API-4 | +| `apps/edge/internal/service/service_test.go` | REVIEW_API-1, REVIEW_API-2, REVIEW_API-4 | +| `apps/edge/internal/controlplane/connector.go` | REVIEW_API-3, REVIEW_API-4 | +| `apps/edge/internal/controlplane/connector_test.go` | REVIEW_API-3, REVIEW_API-4 | +| `proto/iop/control.proto` | REVIEW_API-4 only if whitespace cleanup is still needed | + +## 최종 검증 + +```bash +go test -count=1 ./apps/edge/internal/service ./apps/edge/internal/controlplane +go test -count=1 ./apps/edge/... +make test-control-plane-edge-wire +git diff --check +``` + +예상 결과: 모든 명령 exit code 0. `go test` cache output은 완료 증거로 쓰지 말고 `-count=1` fresh output을 기록한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_2.log b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_2.log new file mode 100644 index 0000000..4c58f25 --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_2.log @@ -0,0 +1,165 @@ + + +# Plan - REVIEW_REVIEW_API Agent Command Coverage + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 코드 변경, 테스트 실행, 실제 stdout/stderr 기록을 끝낸 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. + +구현 중 사용자만 결정할 수 있는 scope 변경, 사용자 소유 외부 환경/secret, 또는 자동 후속으로 해결할 수 없는 차단이 생기면 `CODE_REVIEW-cloud-G06.md`의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 채우고 중단한다. 명령 재실행이나 증거 수집으로 닫을 수 있는 공백은 사용자 리뷰 요청이 아니라 구현/검증 항목으로 처리한다. + +## 배경 + +G06 구현은 `agent.command` fake success를 제거했지만, 실제 supported command branch의 성공과 downstream node error 전파를 직접 검증하지 않는다. 이 follow-up은 `apps/edge/internal/service` 테스트만 보강해 `agent.command`가 실제 `NodeCommandRequest`를 보내는지, node error를 `EdgeCommandResponse`와 event로 반영하는지 확인한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. code-review가 그 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_1.log` +- `agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_1.log` +- `apps/edge/internal/service/service.go` +- `apps/edge/internal/service/service_test.go` +- `apps/edge/internal/controlplane/connector.go` +- `apps/edge/internal/controlplane/connector_test.go` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` + +### 테스트 환경 규칙 + +- test_env: `local` +- matched profile: `agent-test/local/edge-smoke.md` +- applied commands: service package fresh test, edge package fresh test, `git diff --check` +- no external secret, Docker, Flutter, or remote runner is required for this follow-up. + +### 테스트 커버리지 공백 + +- Missing: connected-node `agent.command` + `parameters.command=capabilities` success path sends a real `NodeCommandRequest`. +- Missing: supported command downstream `NodeCommandResponse.Error` becomes `EdgeCommandResponse.Status="error"` plus failed event. +- Already covered: nil client no longer fakes success, lifecycle summary states, status config non-leak, command relay parser. + +### 심볼 참조 + +- Renamed/removed symbols: none. + +### 분할 판단 + +- Existing split subtask remains `m-multi-edge-operations/02+01_edge_fleet_ops`. +- No additional split: this is one test-coverage repair inside `apps/edge/internal/service`. + +### 범위 결정 근거 + +- Do not change proto schema, Control Plane API, or client UI. +- Do not add broad E2E harness. A package-level fake proto-socket node or injectable test helper is enough. +- Production code changes are allowed only if needed to make the command path testable without weakening behavior. + +### 빌드 등급 + +- build=`cloud-G06`, review=`cloud-G06`: this is protocol/transport-path coverage for a prior Required issue, and the same cloud route remains appropriate. + +## 구현 체크리스트 + +- [ ] [REVIEW_REVIEW_API-1] `agent.command` capabilities success path가 실제 `NodeCommandRequest`를 보내고 completed response/event를 반환하는 테스트를 추가한다. +- [ ] [REVIEW_REVIEW_API-2] `agent.command` capabilities downstream node error가 error response와 failed event로 전파되는 테스트를 추가한다. +- [ ] [REVIEW_REVIEW_API-3] fresh service/edge tests와 `git diff --check`를 실행하고 결과를 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_REVIEW_API-1] Supported Command Success Test + +문제: + +`apps/edge/internal/service/service.go:770`의 `agent.command` capabilities branch는 code상 `s.Capabilities`를 호출하지만, 현재 `apps/edge/internal/service/service_test.go:522`는 nil client error만 검증한다. + +해결 방법: + +`service_test`에 fake proto-socket node client를 구성하거나, 기존 service 구조에 맞는 최소 test helper를 만든다. `ExecuteCommand`에 `Operation: "agent.command"`, `TargetSelector: "deployer"`, `Parameters: {"command":"capabilities","adapter":"cli","target":"codex"}`를 보내고 fake node가 받은 `NodeCommandRequest.Type == NODE_COMMAND_TYPE_CAPABILITIES`를 assert한다. fake node가 `Result`를 반환하면 response는 `Status: "completed"`이고 event sequence는 `started`, `completed`여야 한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/service/service_test.go`: fake node command transport/helper 추가. +- [ ] `apps/edge/internal/service/service_test.go`: success test 추가. +- [ ] 요청의 adapter/target/session 값이 `NodeCommandRequest`로 전달되는지 검증. + +테스트 작성: + +- Add `TestServiceExecuteCommandAgentCommandCapabilitiesSuccess`. + +중간 검증: + +```bash +go test -count=1 ./apps/edge/internal/service +``` + +### [REVIEW_REVIEW_API-2] Downstream Error Test + +문제: + +`apps/edge/internal/service/service.go:639`는 downstream error를 `EdgeCommandResponse{Status:"error"}`로 변환하지만, `agent.command` 경유 node error fixture가 없다. + +해결 방법: + +success test의 fake node를 재사용해 `NodeCommandResponse{Error:"boom"}`를 반환하게 한다. `ExecuteCommand` 결과가 `Status: "error"`, `Error`에 `node reported error: boom`을 포함하고 마지막 event가 `Phase: "failed"`인지 확인한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/service/service_test.go`: node error variant 추가. +- [ ] response error와 failed event summary를 함께 검증. + +테스트 작성: + +- Add `TestServiceExecuteCommandAgentCommandCapabilitiesNodeError`. + +중간 검증: + +```bash +go test -count=1 ./apps/edge/internal/service +``` + +### [REVIEW_REVIEW_API-3] Verification + +문제: + +이 follow-up은 테스트 신뢰 회복 작업이므로 fresh output이 필요하다. + +해결 방법: + +대상 package, edge 전체 package, diff cleanliness를 fresh run으로 확인한다. + +수정 파일 및 체크리스트: + +- [ ] `go test -count=1 ./apps/edge/internal/service` +- [ ] `go test -count=1 ./apps/edge/...` +- [ ] `git diff --check` + +테스트 작성: + +- 별도 테스트 없음. 위 명령 결과가 검증이다. + +중간 검증: + +```bash +git diff --check +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/edge/internal/service/service_test.go` | REVIEW_REVIEW_API-1, REVIEW_REVIEW_API-2, REVIEW_REVIEW_API-3 | +| `apps/edge/internal/service/service.go` | REVIEW_REVIEW_API-1, REVIEW_REVIEW_API-2 only if testability requires a small helper | + +## 최종 검증 + +```bash +go test -count=1 ./apps/edge/internal/service +go test -count=1 ./apps/edge/... +git diff --check +``` + +예상 결과: 모든 명령 exit code 0. `go test` cache output은 완료 증거로 쓰지 말고 `-count=1` fresh output을 기록한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_3.log b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_3.log new file mode 100644 index 0000000..eda49da --- /dev/null +++ b/agent-task/archive/2026/06/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_3.log @@ -0,0 +1,138 @@ + + +# Plan - REVIEW_REVIEW_REVIEW_API Session Propagation Coverage + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G06.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 코드 변경, 테스트 실행, 실제 stdout/stderr 기록을 끝낸 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. + +구현 중 사용자만 결정할 수 있는 scope 변경, 사용자 소유 외부 환경/secret, 또는 자동 후속으로 해결할 수 없는 차단이 생기면 `CODE_REVIEW-cloud-G06.md`의 `사용자 리뷰 요청` 섹션에 근거와 재개 조건을 채우고 중단한다. 명령 재실행이나 증거 수집으로 닫을 수 있는 공백은 사용자 리뷰 요청이 아니라 구현/검증 항목으로 처리한다. + +## 배경 + +G06 plan=2 follow-up은 connected-node `agent.command` + `parameters.command=capabilities` success path가 실제 `NodeCommandRequest`를 보내는지 검증하도록 요구했다. 현재 테스트는 `NodeCommandRequest.Type`, `Adapter`, `Target`은 확인하지만 explicit `session_id`를 보내지 않고 `NodeCommandRequest.SessionId`도 assert하지 않는다. + +이 후속은 기존 success round-trip 테스트의 session propagation만 보강한다. 새 프로토콜, Control Plane API, connector relay, UI 변경은 범위 밖이다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. code-review가 그 요청을 검증하고 필요할 때만 `USER_REVIEW.md`를 작성한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/plan_cloud_G06_2.log` +- `agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/code_review_cloud_G06_2.log` +- `apps/edge/internal/service/service.go` +- `apps/edge/internal/service/service_test.go` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` + +### 테스트 환경 규칙 + +- test_env: `local` +- matched profile: `agent-test/local/edge-smoke.md` +- applied commands: service package fresh test, edge package fresh test, `git diff --check` +- no external secret, Docker, Flutter, or remote runner is required for this follow-up. + +### 테스트 커버리지 공백 + +- Missing: `TestServiceExecuteCommandAgentCommandCapabilitiesSuccess` does not send `parameters["session_id"]` and does not assert `NodeCommandRequest.SessionId`. +- Already covered: real `NodeCommandRequest.Type == NODE_COMMAND_TYPE_CAPABILITIES`, adapter/target propagation, completed response/event, downstream node error response and failed event. + +### 심볼 참조 + +- Renamed/removed symbols: none. + +### 분할 판단 + +- Existing split subtask remains `m-multi-edge-operations/02+01_edge_fleet_ops`. +- No additional split: this is one test-coverage repair inside `apps/edge/internal/service`. + +### 범위 결정 근거 + +- Do not change proto schema, Control Plane API, connector relay, or client UI. +- Prefer a test-only change: add an explicit `session_id` parameter to the existing success test and assert `req.GetSessionId()`. +- Production code change is allowed only if the new assertion exposes a real mapping bug in `ExecuteCommand`. + +### 빌드 등급 + +- build=`cloud-G06`, review=`cloud-G06`: this remains a protocol-path coverage repair for a repeated Required issue in the same cloud review lane. + +## 구현 체크리스트 + +- [ ] [REVIEW_REVIEW_REVIEW_API-1] `agent.command` capabilities success test가 explicit `session_id`를 보내고 실제 `NodeCommandRequest.SessionId` 전달을 검증하도록 보강한다. +- [ ] [REVIEW_REVIEW_REVIEW_API-2] fresh service/edge tests와 `git diff --check`를 실행하고 결과를 기록한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_REVIEW_REVIEW_API-1] Explicit Session Propagation Test + +문제: + +`apps/edge/internal/service/service_test.go:604`의 node request listener는 type/adapter/target만 검증한다. `apps/edge/internal/service/service_test.go:636`의 request parameters도 `session_id`를 보내지 않아, `apps/edge/internal/service/service.go:775`의 `session_id` bridge가 깨져도 테스트가 통과한다. + +해결 방법: + +기존 `TestServiceExecuteCommandAgentCommandCapabilitiesSuccess`를 수정한다. `EdgeCommandRequest.Parameters`에 예를 들어 `"session_id": "session-a"`를 추가하고, fake node listener에서 `req.GetSessionId() == "session-a"`를 assert한다. 현재 구현이 이미 맞다면 service production code는 변경하지 않는다. 새 assertion이 실패하면 `ExecuteCommand`의 `NodeCommandRequestSpec.SessionID` mapping만 고친다. + +수정 파일 및 체크리스트: + +- [ ] `apps/edge/internal/service/service_test.go`: success request parameters에 explicit `session_id` 추가. +- [ ] `apps/edge/internal/service/service_test.go`: fake node listener에서 `req.GetSessionId()` assertion 추가. +- [ ] `apps/edge/internal/service/service.go`: 필요할 때만 `parameters["session_id"]` propagation 수정. + +테스트 작성: + +- Update `TestServiceExecuteCommandAgentCommandCapabilitiesSuccess`. + +중간 검증: + +```bash +go test -count=1 ./apps/edge/internal/service +``` + +### [REVIEW_REVIEW_REVIEW_API-2] Verification + +문제: + +이 follow-up은 테스트 신뢰 회복 작업이므로 fresh output이 필요하다. + +해결 방법: + +대상 package, edge 전체 package, diff cleanliness를 fresh run으로 확인한다. + +수정 파일 및 체크리스트: + +- [ ] `go test -count=1 ./apps/edge/internal/service` +- [ ] `go test -count=1 ./apps/edge/...` +- [ ] `git diff --check` + +테스트 작성: + +- 별도 테스트 없음. 위 명령 결과가 검증이다. + +중간 검증: + +```bash +git diff --check +``` + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/edge/internal/service/service_test.go` | REVIEW_REVIEW_REVIEW_API-1, REVIEW_REVIEW_REVIEW_API-2 | +| `apps/edge/internal/service/service.go` | REVIEW_REVIEW_REVIEW_API-1 only if the new assertion exposes a production mapping bug | + +## 최종 검증 + +```bash +go test -count=1 ./apps/edge/internal/service +go test -count=1 ./apps/edge/... +git diff --check +``` + +예상 결과: 모든 명령 exit code 0. `go test` cache output은 완료 증거로 쓰지 말고 `-count=1` fresh output을 기록한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-multi-edge-operations/01_native_contract/CODE_REVIEW-cloud-G06.md b/agent-task/m-multi-edge-operations/01_native_contract/CODE_REVIEW-cloud-G06.md deleted file mode 100644 index 1bb69f7..0000000 --- a/agent-task/m-multi-edge-operations/01_native_contract/CODE_REVIEW-cloud-G06.md +++ /dev/null @@ -1,39 +0,0 @@ - -# Code Review - Native Fleet Contract - -## Review Scope - -- `proto/iop/control.proto` -- proto generated files -- `apps/control-plane/internal/wire/edge.go` -- `apps/edge/internal/controlplane/connector.go` -- ownership guard tests - -## Roadmap Targets - -- `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md` `[native-fleet]` -- `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md` `[edge-ownership]` - -## Reviewer Checklist - -- [ ] Control Plane이 Edge 설정, Node registry, runtime/automation state 원본을 소유하는 필드나 저장소가 생기지 않았다. -- [ ] OTO/build-deploy는 capability/status/command summary로만 표현된다. -- [ ] command 요청/응답/event relay 메시지가 request/result 추적에 충분하다. -- [ ] parser map이 양쪽에서 대칭적으로 갱신되었다. -- [ ] proto 생성물이 누락되지 않았다. -- [ ] 실패/unknown enum 처리 테스트가 있다. - -## Verification Result - -- [ ] `make proto` -- [ ] `make proto-dart` -- [ ] `go test ./apps/control-plane/internal/wire ./apps/edge/internal/controlplane` -- [ ] 필요한 경우 `cd apps/client && flutter test` - -## User Review - -검토 기본값: 이 리뷰는 사용자가 명시적으로 보류하지 않으면 구현자가 자체 검증 후 완료 처리한다. - -## Notes - -- 리뷰 중 발견한 계약 변경은 후속 plan의 구현 전제에 영향을 주므로 milestone 작업 맥락에 함께 반영한다. diff --git a/agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/CODE_REVIEW-cloud-G05.md b/agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/CODE_REVIEW-cloud-G05.md deleted file mode 100644 index 01795e0..0000000 --- a/agent-task/m-multi-edge-operations/02+01_edge_fleet_ops/CODE_REVIEW-cloud-G05.md +++ /dev/null @@ -1,30 +0,0 @@ - -# Code Review - Edge Fleet Operations - -## Review Scope - -- `apps/edge/internal/controlplane/connector.go` -- `apps/edge/internal/service/service.go` -- Edge connector/service tests - -## Dependency Check - -- [ ] `agent-task/m-multi-edge-operations/01_native_contract/complete.log` 확인 - -## Reviewer Checklist - -- [ ] command 처리가 Edge-local service 책임으로 남아 있다. -- [ ] Control Plane 연결/재연결이 Edge 설정 원본을 변경하지 않는다. -- [ ] OTO/build-deploy 정보는 summary 수준이다. -- [ ] unsupported/busy/error command 응답이 명확하다. -- [ ] event relay가 연결 실패/재연결 상황에서 안전하다. -- [ ] secret/config detail이 status response로 새지 않는다. - -## Verification Result - -- [ ] `go test ./apps/edge/internal/service ./apps/edge/internal/controlplane` -- [ ] 필요한 경우 `go test ./apps/edge/...` - -## User Review - -검토 기본값: 이 리뷰는 사용자가 명시적으로 보류하지 않으면 구현자가 자체 검증 후 완료 처리한다. diff --git a/apps/client/lib/gen/proto/iop/control.pb.dart b/apps/client/lib/gen/proto/iop/control.pb.dart index 8509857..3f92b8c 100644 --- a/apps/client/lib/gen/proto/iop/control.pb.dart +++ b/apps/client/lib/gen/proto/iop/control.pb.dart @@ -797,6 +797,8 @@ class EdgeStatusResponse extends $pb.GeneratedMessage { $core.Iterable? nodes, $core.Iterable<$core.MapEntry<$core.String, $core.String>>? metadata, $core.String? error, + $core.Iterable? capabilities, + $core.Iterable? domainAgents, }) { final result = create(); if (requestId != null) result.requestId = requestId; @@ -807,6 +809,8 @@ class EdgeStatusResponse extends $pb.GeneratedMessage { if (nodes != null) result.nodes.addAll(nodes); if (metadata != null) result.metadata.addEntries(metadata); if (error != null) result.error = error; + if (capabilities != null) result.capabilities.addAll(capabilities); + if (domainAgents != null) result.domainAgents.addAll(domainAgents); return result; } @@ -835,6 +839,10 @@ class EdgeStatusResponse extends $pb.GeneratedMessage { valueFieldType: $pb.PbFieldType.OS, packageName: const $pb.PackageName('iop')) ..aOS(7, _omitFieldNames ? '' : 'error') + ..pPM(8, _omitFieldNames ? '' : 'capabilities', + subBuilder: EdgeCapabilitySummary.create) + ..pPM(9, _omitFieldNames ? '' : 'domainAgents', + subBuilder: EdgeDomainAgentSummary.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -906,6 +914,522 @@ class EdgeStatusResponse extends $pb.GeneratedMessage { $core.bool hasError() => $_has(6); @$pb.TagNumber(7) void clearError() => $_clearField(7); + + @$pb.TagNumber(8) + $pb.PbList get capabilities => $_getList(7); + + @$pb.TagNumber(9) + $pb.PbList get domainAgents => $_getList(8); +} + +class EdgeCapabilitySummary extends $pb.GeneratedMessage { + factory EdgeCapabilitySummary({ + $core.String? kind, + $core.bool? available, + $core.String? status, + $core.String? summary, + }) { + final result = create(); + if (kind != null) result.kind = kind; + if (available != null) result.available = available; + if (status != null) result.status = status; + if (summary != null) result.summary = summary; + return result; + } + + EdgeCapabilitySummary._(); + + factory EdgeCapabilitySummary.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory EdgeCapabilitySummary.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'EdgeCapabilitySummary', + package: const $pb.PackageName(_omitMessageNames ? '' : 'iop'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'kind') + ..aOB(2, _omitFieldNames ? '' : 'available') + ..aOS(3, _omitFieldNames ? '' : 'status') + ..aOS(4, _omitFieldNames ? '' : 'summary') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeCapabilitySummary clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeCapabilitySummary copyWith( + void Function(EdgeCapabilitySummary) updates) => + super.copyWith((message) => updates(message as EdgeCapabilitySummary)) + as EdgeCapabilitySummary; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EdgeCapabilitySummary create() => EdgeCapabilitySummary._(); + @$core.override + EdgeCapabilitySummary createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static EdgeCapabilitySummary getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static EdgeCapabilitySummary? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get kind => $_getSZ(0); + @$pb.TagNumber(1) + set kind($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasKind() => $_has(0); + @$pb.TagNumber(1) + void clearKind() => $_clearField(1); + + @$pb.TagNumber(2) + $core.bool get available => $_getBF(1); + @$pb.TagNumber(2) + set available($core.bool value) => $_setBool(1, value); + @$pb.TagNumber(2) + $core.bool hasAvailable() => $_has(1); + @$pb.TagNumber(2) + void clearAvailable() => $_clearField(2); + + @$pb.TagNumber(3) + $core.String get status => $_getSZ(2); + @$pb.TagNumber(3) + set status($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasStatus() => $_has(2); + @$pb.TagNumber(3) + void clearStatus() => $_clearField(3); + + @$pb.TagNumber(4) + $core.String get summary => $_getSZ(3); + @$pb.TagNumber(4) + set summary($core.String value) => $_setString(3, value); + @$pb.TagNumber(4) + $core.bool hasSummary() => $_has(3); + @$pb.TagNumber(4) + void clearSummary() => $_clearField(4); +} + +class EdgeDomainAgentSummary extends $pb.GeneratedMessage { + factory EdgeDomainAgentSummary({ + $core.String? agentKind, + $core.bool? available, + $core.String? lifecycleState, + $core.String? activeCommandId, + $core.String? summary, + }) { + final result = create(); + if (agentKind != null) result.agentKind = agentKind; + if (available != null) result.available = available; + if (lifecycleState != null) result.lifecycleState = lifecycleState; + if (activeCommandId != null) result.activeCommandId = activeCommandId; + if (summary != null) result.summary = summary; + return result; + } + + EdgeDomainAgentSummary._(); + + factory EdgeDomainAgentSummary.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory EdgeDomainAgentSummary.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'EdgeDomainAgentSummary', + package: const $pb.PackageName(_omitMessageNames ? '' : 'iop'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'agentKind') + ..aOB(2, _omitFieldNames ? '' : 'available') + ..aOS(3, _omitFieldNames ? '' : 'lifecycleState') + ..aOS(4, _omitFieldNames ? '' : 'activeCommandId') + ..aOS(5, _omitFieldNames ? '' : 'summary') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeDomainAgentSummary clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeDomainAgentSummary copyWith( + void Function(EdgeDomainAgentSummary) updates) => + super.copyWith((message) => updates(message as EdgeDomainAgentSummary)) + as EdgeDomainAgentSummary; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EdgeDomainAgentSummary create() => EdgeDomainAgentSummary._(); + @$core.override + EdgeDomainAgentSummary createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static EdgeDomainAgentSummary getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static EdgeDomainAgentSummary? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get agentKind => $_getSZ(0); + @$pb.TagNumber(1) + set agentKind($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasAgentKind() => $_has(0); + @$pb.TagNumber(1) + void clearAgentKind() => $_clearField(1); + + @$pb.TagNumber(2) + $core.bool get available => $_getBF(1); + @$pb.TagNumber(2) + set available($core.bool value) => $_setBool(1, value); + @$pb.TagNumber(2) + $core.bool hasAvailable() => $_has(1); + @$pb.TagNumber(2) + void clearAvailable() => $_clearField(2); + + @$pb.TagNumber(3) + $core.String get lifecycleState => $_getSZ(2); + @$pb.TagNumber(3) + set lifecycleState($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasLifecycleState() => $_has(2); + @$pb.TagNumber(3) + void clearLifecycleState() => $_clearField(3); + + @$pb.TagNumber(4) + $core.String get activeCommandId => $_getSZ(3); + @$pb.TagNumber(4) + set activeCommandId($core.String value) => $_setString(3, value); + @$pb.TagNumber(4) + $core.bool hasActiveCommandId() => $_has(3); + @$pb.TagNumber(4) + void clearActiveCommandId() => $_clearField(4); + + @$pb.TagNumber(5) + $core.String get summary => $_getSZ(4); + @$pb.TagNumber(5) + set summary($core.String value) => $_setString(4, value); + @$pb.TagNumber(5) + $core.bool hasSummary() => $_has(4); + @$pb.TagNumber(5) + void clearSummary() => $_clearField(5); +} + +class EdgeCommandRequest extends $pb.GeneratedMessage { + factory EdgeCommandRequest({ + $core.String? requestId, + $core.String? commandId, + $core.String? targetSelector, + $core.String? operation, + $core.Iterable<$core.MapEntry<$core.String, $core.String>>? parameters, + }) { + final result = create(); + if (requestId != null) result.requestId = requestId; + if (commandId != null) result.commandId = commandId; + if (targetSelector != null) result.targetSelector = targetSelector; + if (operation != null) result.operation = operation; + if (parameters != null) result.parameters.addEntries(parameters); + return result; + } + + EdgeCommandRequest._(); + + factory EdgeCommandRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory EdgeCommandRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'EdgeCommandRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'iop'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'requestId') + ..aOS(2, _omitFieldNames ? '' : 'commandId') + ..aOS(3, _omitFieldNames ? '' : 'targetSelector') + ..aOS(4, _omitFieldNames ? '' : 'operation') + ..m<$core.String, $core.String>(5, _omitFieldNames ? '' : 'parameters', + entryClassName: 'EdgeCommandRequest.ParametersEntry', + keyFieldType: $pb.PbFieldType.OS, + valueFieldType: $pb.PbFieldType.OS, + packageName: const $pb.PackageName('iop')) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeCommandRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeCommandRequest copyWith(void Function(EdgeCommandRequest) updates) => + super.copyWith((message) => updates(message as EdgeCommandRequest)) + as EdgeCommandRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EdgeCommandRequest create() => EdgeCommandRequest._(); + @$core.override + EdgeCommandRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static EdgeCommandRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static EdgeCommandRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get requestId => $_getSZ(0); + @$pb.TagNumber(1) + set requestId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasRequestId() => $_has(0); + @$pb.TagNumber(1) + void clearRequestId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get commandId => $_getSZ(1); + @$pb.TagNumber(2) + set commandId($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasCommandId() => $_has(1); + @$pb.TagNumber(2) + void clearCommandId() => $_clearField(2); + + @$pb.TagNumber(3) + $core.String get targetSelector => $_getSZ(2); + @$pb.TagNumber(3) + set targetSelector($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasTargetSelector() => $_has(2); + @$pb.TagNumber(3) + void clearTargetSelector() => $_clearField(3); + + @$pb.TagNumber(4) + $core.String get operation => $_getSZ(3); + @$pb.TagNumber(4) + set operation($core.String value) => $_setString(3, value); + @$pb.TagNumber(4) + $core.bool hasOperation() => $_has(3); + @$pb.TagNumber(4) + void clearOperation() => $_clearField(4); + + @$pb.TagNumber(5) + $pb.PbMap<$core.String, $core.String> get parameters => $_getMap(4); +} + +class EdgeCommandResponse extends $pb.GeneratedMessage { + factory EdgeCommandResponse({ + $core.String? requestId, + $core.String? commandId, + $core.String? edgeId, + $core.String? status, + $core.String? summary, + $core.String? error, + }) { + final result = create(); + if (requestId != null) result.requestId = requestId; + if (commandId != null) result.commandId = commandId; + if (edgeId != null) result.edgeId = edgeId; + if (status != null) result.status = status; + if (summary != null) result.summary = summary; + if (error != null) result.error = error; + return result; + } + + EdgeCommandResponse._(); + + factory EdgeCommandResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory EdgeCommandResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'EdgeCommandResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'iop'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'requestId') + ..aOS(2, _omitFieldNames ? '' : 'commandId') + ..aOS(3, _omitFieldNames ? '' : 'edgeId') + ..aOS(4, _omitFieldNames ? '' : 'status') + ..aOS(5, _omitFieldNames ? '' : 'summary') + ..aOS(6, _omitFieldNames ? '' : 'error') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeCommandResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeCommandResponse copyWith(void Function(EdgeCommandResponse) updates) => + super.copyWith((message) => updates(message as EdgeCommandResponse)) + as EdgeCommandResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EdgeCommandResponse create() => EdgeCommandResponse._(); + @$core.override + EdgeCommandResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static EdgeCommandResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static EdgeCommandResponse? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get requestId => $_getSZ(0); + @$pb.TagNumber(1) + set requestId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasRequestId() => $_has(0); + @$pb.TagNumber(1) + void clearRequestId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get commandId => $_getSZ(1); + @$pb.TagNumber(2) + set commandId($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasCommandId() => $_has(1); + @$pb.TagNumber(2) + void clearCommandId() => $_clearField(2); + + @$pb.TagNumber(3) + $core.String get edgeId => $_getSZ(2); + @$pb.TagNumber(3) + set edgeId($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasEdgeId() => $_has(2); + @$pb.TagNumber(3) + void clearEdgeId() => $_clearField(3); + + @$pb.TagNumber(4) + $core.String get status => $_getSZ(3); + @$pb.TagNumber(4) + set status($core.String value) => $_setString(3, value); + @$pb.TagNumber(4) + $core.bool hasStatus() => $_has(3); + @$pb.TagNumber(4) + void clearStatus() => $_clearField(4); + + @$pb.TagNumber(5) + $core.String get summary => $_getSZ(4); + @$pb.TagNumber(5) + set summary($core.String value) => $_setString(4, value); + @$pb.TagNumber(5) + $core.bool hasSummary() => $_has(4); + @$pb.TagNumber(5) + void clearSummary() => $_clearField(5); + + @$pb.TagNumber(6) + $core.String get error => $_getSZ(5); + @$pb.TagNumber(6) + set error($core.String value) => $_setString(5, value); + @$pb.TagNumber(6) + $core.bool hasError() => $_has(5); + @$pb.TagNumber(6) + void clearError() => $_clearField(6); +} + +class EdgeCommandEvent extends $pb.GeneratedMessage { + factory EdgeCommandEvent({ + $core.String? commandId, + $core.String? edgeId, + $core.String? phase, + $core.String? summary, + $fixnum.Int64? occurredAt, + }) { + final result = create(); + if (commandId != null) result.commandId = commandId; + if (edgeId != null) result.edgeId = edgeId; + if (phase != null) result.phase = phase; + if (summary != null) result.summary = summary; + if (occurredAt != null) result.occurredAt = occurredAt; + return result; + } + + EdgeCommandEvent._(); + + factory EdgeCommandEvent.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory EdgeCommandEvent.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'EdgeCommandEvent', + package: const $pb.PackageName(_omitMessageNames ? '' : 'iop'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'commandId') + ..aOS(2, _omitFieldNames ? '' : 'edgeId') + ..aOS(3, _omitFieldNames ? '' : 'phase') + ..aOS(4, _omitFieldNames ? '' : 'summary') + ..aInt64(5, _omitFieldNames ? '' : 'occurredAt') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeCommandEvent clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeCommandEvent copyWith(void Function(EdgeCommandEvent) updates) => + super.copyWith((message) => updates(message as EdgeCommandEvent)) + as EdgeCommandEvent; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EdgeCommandEvent create() => EdgeCommandEvent._(); + @$core.override + EdgeCommandEvent createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static EdgeCommandEvent getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static EdgeCommandEvent? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get commandId => $_getSZ(0); + @$pb.TagNumber(1) + set commandId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasCommandId() => $_has(0); + @$pb.TagNumber(1) + void clearCommandId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get edgeId => $_getSZ(1); + @$pb.TagNumber(2) + set edgeId($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasEdgeId() => $_has(1); + @$pb.TagNumber(2) + void clearEdgeId() => $_clearField(2); + + @$pb.TagNumber(3) + $core.String get phase => $_getSZ(2); + @$pb.TagNumber(3) + set phase($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasPhase() => $_has(2); + @$pb.TagNumber(3) + void clearPhase() => $_clearField(3); + + @$pb.TagNumber(4) + $core.String get summary => $_getSZ(3); + @$pb.TagNumber(4) + set summary($core.String value) => $_setString(3, value); + @$pb.TagNumber(4) + $core.bool hasSummary() => $_has(3); + @$pb.TagNumber(4) + void clearSummary() => $_clearField(4); + + @$pb.TagNumber(5) + $fixnum.Int64 get occurredAt => $_getI64(4); + @$pb.TagNumber(5) + set occurredAt($fixnum.Int64 value) => $_setInt64(4, value); + @$pb.TagNumber(5) + $core.bool hasOccurredAt() => $_has(4); + @$pb.TagNumber(5) + void clearOccurredAt() => $_clearField(5); } const $core.bool _omitFieldNames = diff --git a/apps/client/lib/gen/proto/iop/control.pbjson.dart b/apps/client/lib/gen/proto/iop/control.pbjson.dart index 3dfd80f..79df9b1 100644 --- a/apps/client/lib/gen/proto/iop/control.pbjson.dart +++ b/apps/client/lib/gen/proto/iop/control.pbjson.dart @@ -277,6 +277,22 @@ const EdgeStatusResponse$json = { '10': 'metadata' }, {'1': 'error', '3': 7, '4': 1, '5': 9, '10': 'error'}, + { + '1': 'capabilities', + '3': 8, + '4': 3, + '5': 11, + '6': '.iop.EdgeCapabilitySummary', + '10': 'capabilities' + }, + { + '1': 'domain_agents', + '3': 9, + '4': 3, + '5': 11, + '6': '.iop.EdgeDomainAgentSummary', + '10': 'domainAgents' + }, ], '3': [EdgeStatusResponse_MetadataEntry$json], }; @@ -298,5 +314,120 @@ final $typed_data.Uint8List edgeStatusResponseDescriptor = $convert.base64Decode 'c2VydmVkX3RpbWVfdW5peF9uYW5vGAQgASgDUhRvYnNlcnZlZFRpbWVVbml4TmFubxIrCgVub2' 'RlcxgFIAMoCzIVLmlvcC5FZGdlTm9kZVNuYXBzaG90UgVub2RlcxJBCghtZXRhZGF0YRgGIAMo' 'CzIlLmlvcC5FZGdlU3RhdHVzUmVzcG9uc2UuTWV0YWRhdGFFbnRyeVIIbWV0YWRhdGESFAoFZX' - 'Jyb3IYByABKAlSBWVycm9yGjsKDU1ldGFkYXRhRW50cnkSEAoDa2V5GAEgASgJUgNrZXkSFAoF' - 'dmFsdWUYAiABKAlSBXZhbHVlOgI4AQ=='); + 'Jyb3IYByABKAlSBWVycm9yEj4KDGNhcGFiaWxpdGllcxgIIAMoCzIaLmlvcC5FZGdlQ2FwYWJp' + 'bGl0eVN1bW1hcnlSDGNhcGFiaWxpdGllcxJACg1kb21haW5fYWdlbnRzGAkgAygLMhsuaW9wLk' + 'VkZ2VEb21haW5BZ2VudFN1bW1hcnlSDGRvbWFpbkFnZW50cxo7Cg1NZXRhZGF0YUVudHJ5EhAK' + 'A2tleRgBIAEoCVIDa2V5EhQKBXZhbHVlGAIgASgJUgV2YWx1ZToCOAE='); + +@$core.Deprecated('Use edgeCapabilitySummaryDescriptor instead') +const EdgeCapabilitySummary$json = { + '1': 'EdgeCapabilitySummary', + '2': [ + {'1': 'kind', '3': 1, '4': 1, '5': 9, '10': 'kind'}, + {'1': 'available', '3': 2, '4': 1, '5': 8, '10': 'available'}, + {'1': 'status', '3': 3, '4': 1, '5': 9, '10': 'status'}, + {'1': 'summary', '3': 4, '4': 1, '5': 9, '10': 'summary'}, + ], +}; + +/// Descriptor for `EdgeCapabilitySummary`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List edgeCapabilitySummaryDescriptor = $convert.base64Decode( + 'ChVFZGdlQ2FwYWJpbGl0eVN1bW1hcnkSEgoEa2luZBgBIAEoCVIEa2luZBIcCglhdmFpbGFibG' + 'UYAiABKAhSCWF2YWlsYWJsZRIWCgZzdGF0dXMYAyABKAlSBnN0YXR1cxIYCgdzdW1tYXJ5GAQg' + 'ASgJUgdzdW1tYXJ5'); + +@$core.Deprecated('Use edgeDomainAgentSummaryDescriptor instead') +const EdgeDomainAgentSummary$json = { + '1': 'EdgeDomainAgentSummary', + '2': [ + {'1': 'agent_kind', '3': 1, '4': 1, '5': 9, '10': 'agentKind'}, + {'1': 'available', '3': 2, '4': 1, '5': 8, '10': 'available'}, + {'1': 'lifecycle_state', '3': 3, '4': 1, '5': 9, '10': 'lifecycleState'}, + {'1': 'active_command_id', '3': 4, '4': 1, '5': 9, '10': 'activeCommandId'}, + {'1': 'summary', '3': 5, '4': 1, '5': 9, '10': 'summary'}, + ], +}; + +/// Descriptor for `EdgeDomainAgentSummary`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List edgeDomainAgentSummaryDescriptor = $convert.base64Decode( + 'ChZFZGdlRG9tYWluQWdlbnRTdW1tYXJ5Eh0KCmFnZW50X2tpbmQYASABKAlSCWFnZW50S2luZB' + 'IcCglhdmFpbGFibGUYAiABKAhSCWF2YWlsYWJsZRInCg9saWZlY3ljbGVfc3RhdGUYAyABKAlS' + 'DmxpZmVjeWNsZVN0YXRlEioKEWFjdGl2ZV9jb21tYW5kX2lkGAQgASgJUg9hY3RpdmVDb21tYW' + '5kSWQSGAoHc3VtbWFyeRgFIAEoCVIHc3VtbWFyeQ=='); + +@$core.Deprecated('Use edgeCommandRequestDescriptor instead') +const EdgeCommandRequest$json = { + '1': 'EdgeCommandRequest', + '2': [ + {'1': 'request_id', '3': 1, '4': 1, '5': 9, '10': 'requestId'}, + {'1': 'command_id', '3': 2, '4': 1, '5': 9, '10': 'commandId'}, + {'1': 'target_selector', '3': 3, '4': 1, '5': 9, '10': 'targetSelector'}, + {'1': 'operation', '3': 4, '4': 1, '5': 9, '10': 'operation'}, + { + '1': 'parameters', + '3': 5, + '4': 3, + '5': 11, + '6': '.iop.EdgeCommandRequest.ParametersEntry', + '10': 'parameters' + }, + ], + '3': [EdgeCommandRequest_ParametersEntry$json], +}; + +@$core.Deprecated('Use edgeCommandRequestDescriptor instead') +const EdgeCommandRequest_ParametersEntry$json = { + '1': 'ParametersEntry', + '2': [ + {'1': 'key', '3': 1, '4': 1, '5': 9, '10': 'key'}, + {'1': 'value', '3': 2, '4': 1, '5': 9, '10': 'value'}, + ], + '7': {'7': true}, +}; + +/// Descriptor for `EdgeCommandRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List edgeCommandRequestDescriptor = $convert.base64Decode( + 'ChJFZGdlQ29tbWFuZFJlcXVlc3QSHQoKcmVxdWVzdF9pZBgBIAEoCVIJcmVxdWVzdElkEh0KCm' + 'NvbW1hbmRfaWQYAiABKAlSCWNvbW1hbmRJZBInCg90YXJnZXRfc2VsZWN0b3IYAyABKAlSDnRh' + 'cmdldFNlbGVjdG9yEhwKCW9wZXJhdGlvbhgEIAEoCVIJb3BlcmF0aW9uEkcKCnBhcmFtZXRlcn' + 'MYBSADKAsyJy5pb3AuRWRnZUNvbW1hbmRSZXF1ZXN0LlBhcmFtZXRlcnNFbnRyeVIKcGFyYW1l' + 'dGVycxo9Cg9QYXJhbWV0ZXJzRW50cnkSEAoDa2V5GAEgASgJUgNrZXkSFAoFdmFsdWUYAiABKA' + 'lSBXZhbHVlOgI4AQ=='); + +@$core.Deprecated('Use edgeCommandResponseDescriptor instead') +const EdgeCommandResponse$json = { + '1': 'EdgeCommandResponse', + '2': [ + {'1': 'request_id', '3': 1, '4': 1, '5': 9, '10': 'requestId'}, + {'1': 'command_id', '3': 2, '4': 1, '5': 9, '10': 'commandId'}, + {'1': 'edge_id', '3': 3, '4': 1, '5': 9, '10': 'edgeId'}, + {'1': 'status', '3': 4, '4': 1, '5': 9, '10': 'status'}, + {'1': 'summary', '3': 5, '4': 1, '5': 9, '10': 'summary'}, + {'1': 'error', '3': 6, '4': 1, '5': 9, '10': 'error'}, + ], +}; + +/// Descriptor for `EdgeCommandResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List edgeCommandResponseDescriptor = $convert.base64Decode( + 'ChNFZGdlQ29tbWFuZFJlc3BvbnNlEh0KCnJlcXVlc3RfaWQYASABKAlSCXJlcXVlc3RJZBIdCg' + 'pjb21tYW5kX2lkGAIgASgJUgljb21tYW5kSWQSFwoHZWRnZV9pZBgDIAEoCVIGZWRnZUlkEhYK' + 'BnN0YXR1cxgEIAEoCVIGc3RhdHVzEhgKB3N1bW1hcnkYBSABKAlSB3N1bW1hcnkSFAoFZXJyb3' + 'IYBiABKAlSBWVycm9y'); + +@$core.Deprecated('Use edgeCommandEventDescriptor instead') +const EdgeCommandEvent$json = { + '1': 'EdgeCommandEvent', + '2': [ + {'1': 'command_id', '3': 1, '4': 1, '5': 9, '10': 'commandId'}, + {'1': 'edge_id', '3': 2, '4': 1, '5': 9, '10': 'edgeId'}, + {'1': 'phase', '3': 3, '4': 1, '5': 9, '10': 'phase'}, + {'1': 'summary', '3': 4, '4': 1, '5': 9, '10': 'summary'}, + {'1': 'occurred_at', '3': 5, '4': 1, '5': 3, '10': 'occurredAt'}, + ], +}; + +/// Descriptor for `EdgeCommandEvent`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List edgeCommandEventDescriptor = $convert.base64Decode( + 'ChBFZGdlQ29tbWFuZEV2ZW50Eh0KCmNvbW1hbmRfaWQYASABKAlSCWNvbW1hbmRJZBIXCgdlZG' + 'dlX2lkGAIgASgJUgZlZGdlSWQSFAoFcGhhc2UYAyABKAlSBXBoYXNlEhgKB3N1bW1hcnkYBCAB' + 'KAlSB3N1bW1hcnkSHwoLb2NjdXJyZWRfYXQYBSABKANSCm9jY3VycmVkQXQ='); diff --git a/apps/control-plane/internal/wire/edge.go b/apps/control-plane/internal/wire/edge.go index 0cfa0c0..82f88ee 100644 --- a/apps/control-plane/internal/wire/edge.go +++ b/apps/control-plane/internal/wire/edge.go @@ -31,5 +31,17 @@ func EdgeParserMap() proto_socket.ParserMap { event := &iop.EdgeNodeEvent{} return event, proto.Unmarshal(b, event) }, + proto_socket.TypeNameOf(&iop.EdgeCommandRequest{}): func(b []byte) (proto.Message, error) { + req := &iop.EdgeCommandRequest{} + return req, proto.Unmarshal(b, req) + }, + proto_socket.TypeNameOf(&iop.EdgeCommandResponse{}): func(b []byte) (proto.Message, error) { + res := &iop.EdgeCommandResponse{} + return res, proto.Unmarshal(b, res) + }, + proto_socket.TypeNameOf(&iop.EdgeCommandEvent{}): func(b []byte) (proto.Message, error) { + event := &iop.EdgeCommandEvent{} + return event, proto.Unmarshal(b, event) + }, } } diff --git a/apps/control-plane/internal/wire/edge_test.go b/apps/control-plane/internal/wire/edge_test.go index e352a93..6735436 100644 --- a/apps/control-plane/internal/wire/edge_test.go +++ b/apps/control-plane/internal/wire/edge_test.go @@ -1,11 +1,13 @@ package wire import ( + "reflect" "testing" "time" proto_socket "git.toki-labs.com/toki/proto-socket/go" iop "iop/proto/gen/iop" + "iop/packages/go/config" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -270,3 +272,72 @@ func TestWireTransportBoundary(t *testing.T) { t.Fatalf("ClientTransport = %q, want %q", ClientTransport, "proto-socket-ws") } } + +func TestProtoOwnershipGuard(t *testing.T) { + forbiddenNames := []string{ + "edge_config_source", + "node_registry_source", + "artifact_store", + "build_log_store", + } + + messages := []proto.Message{ + &iop.EdgeStatusRequest{}, + &iop.EdgeStatusResponse{}, + &iop.EdgeHelloRequest{}, + &iop.EdgeHelloResponse{}, + &iop.EdgeCommandRequest{}, + &iop.EdgeCommandResponse{}, + &iop.EdgeCommandEvent{}, + &iop.EdgeCapabilitySummary{}, + &iop.EdgeDomainAgentSummary{}, + } + + for _, msg := range messages { + fields := msg.ProtoReflect().Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + f := fields.Get(i) + name := string(f.Name()) + for _, forbidden := range forbiddenNames { + if name == forbidden { + t.Errorf("Message %s contains forbidden field %s", msg.ProtoReflect().Descriptor().Name(), name) + } + } + } + } +} + +func TestControlPlaneRegistryOwnershipGuard(t *testing.T) { + visited := make(map[reflect.Type]bool) + if hasEdgeConfig(reflect.TypeOf(EdgeRegistry{}), visited) { + t.Errorf("EdgeRegistry must not store EdgeConfig or configurations containing EdgeConfig") + } + + visitedState := make(map[reflect.Type]bool) + if hasEdgeConfig(reflect.TypeOf(EdgeConnectionState{}), visitedState) { + t.Errorf("EdgeConnectionState must not store EdgeConfig or configurations containing EdgeConfig") + } +} + +func hasEdgeConfig(t reflect.Type, visited map[reflect.Type]bool) bool { + if visited[t] { + return false + } + visited[t] = true + if t == reflect.TypeOf(config.EdgeConfig{}) { + return true + } + switch t.Kind() { + case reflect.Struct: + for i := 0; i < t.NumField(); i++ { + if hasEdgeConfig(t.Field(i).Type, visited) { + return true + } + } + case reflect.Ptr, reflect.Slice, reflect.Array: + return hasEdgeConfig(t.Elem(), visited) + case reflect.Map: + return hasEdgeConfig(t.Key(), visited) || hasEdgeConfig(t.Elem(), visited) + } + return false +} diff --git a/apps/edge/internal/controlplane/connector.go b/apps/edge/internal/controlplane/connector.go index ae11b1f..e8d0c20 100644 --- a/apps/edge/internal/controlplane/connector.go +++ b/apps/edge/internal/controlplane/connector.go @@ -44,6 +44,9 @@ const ( // the node registry or transport directly. type StatusProvider interface { ListNodeSnapshots() []edgeservice.NodeSnapshot + GetCapabilities() []*iop.EdgeCapabilitySummary + GetDomainAgents() []*iop.EdgeDomainAgentSummary + ExecuteCommand(ctx context.Context, req *iop.EdgeCommandRequest, onEvent func(*iop.EdgeCommandEvent)) (*iop.EdgeCommandResponse, error) } // Option configures optional Connector dependencies without breaking existing @@ -230,6 +233,10 @@ func (c *Connector) connect(ctx context.Context) error { return c.buildStatusResponse(req), nil }) + toki.AddRequestListenerTyped(&cl.Communicator, func(req *iop.EdgeCommandRequest) (*iop.EdgeCommandResponse, error) { + return c.handleEdgeCommand(ctx, req) + }) + c.mu.Lock() c.client = cl c.mu.Unlock() @@ -362,13 +369,57 @@ func (c *Connector) buildStatusResponse(req *iop.EdgeStatusRequest) *iop.EdgeSta Alias: s.Alias, Label: s.Label, Connected: true, - Config: s.Config, + Config: nil, }) } resp.Nodes = nodes + resp.Capabilities = c.statusProvider.GetCapabilities() + resp.DomainAgents = c.statusProvider.GetDomainAgents() return resp } +func (c *Connector) handleEdgeCommand(ctx context.Context, req *iop.EdgeCommandRequest) (*iop.EdgeCommandResponse, error) { + c.logger.Info("received edge command request", zap.String("command_id", req.CommandId), zap.String("operation", req.Operation)) + if c.statusProvider == nil { + return &iop.EdgeCommandResponse{ + RequestId: req.RequestId, + CommandId: req.CommandId, + EdgeId: c.edgeInfo.ID, + Status: "error", + Error: "edge status provider not configured", + }, nil + } + + onEvent := func(event *iop.EdgeCommandEvent) { + event.EdgeId = c.edgeInfo.ID + c.mu.Lock() + client := c.client + c.mu.Unlock() + if client != nil { + if err := client.Send(event); err != nil { + c.logger.Error("failed to relay edge command event", zap.Error(err)) + } + } + } + + resp, err := c.statusProvider.ExecuteCommand(ctx, req, onEvent) + if err != nil { + c.logger.Error("edge command execution failed", zap.Error(err)) + return &iop.EdgeCommandResponse{ + RequestId: req.RequestId, + CommandId: req.CommandId, + EdgeId: c.edgeInfo.ID, + Status: "error", + Error: err.Error(), + }, nil + } + + resp.RequestId = req.RequestId + resp.CommandId = req.CommandId + resp.EdgeId = c.edgeInfo.ID + return resp, nil +} + // cpParserMap returns the ParserMap for Control Plane messages. It parses the // hello response and the Control Plane-initiated status request, plus the // status response the Edge marshals back. @@ -386,5 +437,17 @@ func cpParserMap() toki.ParserMap { m := &iop.EdgeStatusResponse{} return m, proto.Unmarshal(b, m) }, + toki.TypeNameOf(&iop.EdgeCommandRequest{}): func(b []byte) (proto.Message, error) { + m := &iop.EdgeCommandRequest{} + return m, proto.Unmarshal(b, m) + }, + toki.TypeNameOf(&iop.EdgeCommandResponse{}): func(b []byte) (proto.Message, error) { + m := &iop.EdgeCommandResponse{} + return m, proto.Unmarshal(b, m) + }, + toki.TypeNameOf(&iop.EdgeCommandEvent{}): func(b []byte) (proto.Message, error) { + m := &iop.EdgeCommandEvent{} + return m, proto.Unmarshal(b, m) + }, } } diff --git a/apps/edge/internal/controlplane/connector_test.go b/apps/edge/internal/controlplane/connector_test.go index b9b5e73..869ee06 100644 --- a/apps/edge/internal/controlplane/connector_test.go +++ b/apps/edge/internal/controlplane/connector_test.go @@ -17,13 +17,42 @@ import ( ) type fakeStatusProvider struct { - nodes []edgeservice.NodeSnapshot + nodes []edgeservice.NodeSnapshot + capabilities []*iop.EdgeCapabilitySummary + domainAgents []*iop.EdgeDomainAgentSummary + executeErr error + executeResp *iop.EdgeCommandResponse + eventsToEmit []*iop.EdgeCommandEvent } func (f fakeStatusProvider) ListNodeSnapshots() []edgeservice.NodeSnapshot { return append([]edgeservice.NodeSnapshot(nil), f.nodes...) } +func (f fakeStatusProvider) GetCapabilities() []*iop.EdgeCapabilitySummary { + return f.capabilities +} + +func (f fakeStatusProvider) GetDomainAgents() []*iop.EdgeDomainAgentSummary { + return f.domainAgents +} + +func (f fakeStatusProvider) ExecuteCommand(ctx context.Context, req *iop.EdgeCommandRequest, onEvent func(*iop.EdgeCommandEvent)) (*iop.EdgeCommandResponse, error) { + for _, ev := range f.eventsToEmit { + onEvent(ev) + } + if f.executeErr != nil { + return nil, f.executeErr + } + if f.executeResp != nil { + return f.executeResp, nil + } + return &iop.EdgeCommandResponse{ + Status: "completed", + Summary: "mock completed", + }, nil +} + // fakeCPParserMap returns a parser for EdgeHelloRequest on the server side. func fakeCPParserMap() toki.ParserMap { return toki.ParserMap{ @@ -699,7 +728,7 @@ func TestConnectorStopPreventsReconnect(t *testing.T) { } } -func TestConnectorBuildStatusResponseWithConfig(t *testing.T) { +func TestConnectorBuildStatusResponseNonLeak(t *testing.T) { fakeProvider := fakeStatusProvider{ nodes: []edgeservice.NodeSnapshot{ { @@ -708,7 +737,19 @@ func TestConnectorBuildStatusResponseWithConfig(t *testing.T) { Label: "node0", Config: &iop.NodeConfigPayload{ Runtime: &iop.NodeRuntimeConfig{ - Concurrency: 5, + Concurrency: 5, + WorkspaceRoot: "/sensitive/workspace", + }, + Adapters: []*iop.AdapterConfig{ + { + Type: "ollama", + Enabled: true, + Config: &iop.AdapterConfig_Ollama{ + Ollama: &iop.OllamaAdapterConfig{ + BaseUrl: "http://sensitive-url:11434", + }, + }, + }, }, }, }, @@ -719,7 +760,7 @@ func TestConnectorBuildStatusResponseWithConfig(t *testing.T) { config.EdgeInfo{ID: "edge-1", Name: "Edge 1"}, config.EdgeControlPlaneConf{}, "1.0.0", - nil, + noopLogger(), WithStatusProvider(fakeProvider), ) @@ -737,7 +778,122 @@ func TestConnectorBuildStatusResponseWithConfig(t *testing.T) { t.Errorf("unexpected node: %+v", n) } - if n.GetConfig() == nil || n.GetConfig().GetRuntime().GetConcurrency() != 5 { - t.Errorf("config not propagated properly: %+v", n.GetConfig()) + if n.GetConfig() != nil { + t.Errorf("expected config to be nil (non-leak), but got: %+v", n.GetConfig()) + } +} + +func commandCapableCPParserMap() toki.ParserMap { + m := cpParserMap() + m[toki.TypeNameOf(&iop.EdgeHelloRequest{})] = func(b []byte) (proto.Message, error) { + req := &iop.EdgeHelloRequest{} + return req, proto.Unmarshal(b, req) + } + return m +} + +func TestConnectorEdgeCommandRelay(t *testing.T) { + fakeProvider := fakeStatusProvider{ + executeResp: &iop.EdgeCommandResponse{ + Status: "completed", + Summary: "test OK", + }, + eventsToEmit: []*iop.EdgeCommandEvent{ + {CommandId: "cmd-1", Phase: "started", Summary: "started test"}, + {CommandId: "cmd-1", Phase: "completed", Summary: "finished test"}, + }, + } + + port, err := freeTCPPort() + if err != nil { + t.Fatalf("freeTCPPort: %v", err) + } + + eventsReceived := make(chan *iop.EdgeCommandEvent, 8) + var cpClient *toki.TcpClient + + srv := toki.NewTcpServer("127.0.0.1", port, func(conn net.Conn) *toki.TcpClient { + return toki.NewTcpClient(conn, 0, 0, commandCapableCPParserMap()) + }) + srv.OnClientConnected = func(client *toki.TcpClient) { + cpClient = client + toki.AddRequestListenerTyped[*iop.EdgeHelloRequest, *iop.EdgeHelloResponse]( + &client.Communicator, + func(_ *iop.EdgeHelloRequest) (*iop.EdgeHelloResponse, error) { + return &iop.EdgeHelloResponse{Accepted: true, Protocol: "iop/1"}, nil + }, + ) + toki.AddListenerTyped[*iop.EdgeCommandEvent](&client.Communicator, func(ev *iop.EdgeCommandEvent) { + eventsReceived <- ev + }) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := srv.Start(ctx); err != nil { + t.Fatalf("Start cp server: %v", err) + } + defer func() { _ = srv.Stop() }() + + c := NewConnector( + config.EdgeInfo{ID: "edge-test-1", Name: "Edge Test"}, + config.EdgeControlPlaneConf{ + Enabled: true, + WireAddr: wireAddr(port), + ReconnectIntervalSec: 60, + }, + "1.0.0", + noopLogger(), + WithStatusProvider(fakeProvider), + ) + if err := c.Start(context.Background()); err != nil { + t.Fatalf("Start connector: %v", err) + } + defer c.Stop() + + deadline := time.Now().Add(3 * time.Second) + for c.CurrentState() != StateConnected { + if time.Now().After(deadline) { + t.Fatalf("connector failed to connect, got %v", c.CurrentState()) + } + time.Sleep(10 * time.Millisecond) + } + + if cpClient == nil { + t.Fatal("control plane client not connected") + } + + resp, err := toki.SendRequestTyped[*iop.EdgeCommandRequest, *iop.EdgeCommandResponse]( + &cpClient.Communicator, + &iop.EdgeCommandRequest{ + RequestId: "req-command-1", + CommandId: "cmd-1", + Operation: "test.op", + }, + 3*time.Second, + ) + if err != nil { + t.Fatalf("SendRequestTyped edge command: %v", err) + } + + if resp.GetStatus() != "completed" || resp.GetSummary() != "test OK" { + t.Errorf("unexpected response: %+v", resp) + } + if resp.GetEdgeId() != "edge-test-1" { + t.Errorf("expected edge_id edge-test-1, got %q", resp.GetEdgeId()) + } + + for i := 0; i < 2; i++ { + select { + case ev := <-eventsReceived: + if ev.GetCommandId() != "cmd-1" { + t.Errorf("unexpected event command_id: %q", ev.GetCommandId()) + } + if ev.GetEdgeId() != "edge-test-1" { + t.Errorf("unexpected event edge_id: %q", ev.GetEdgeId()) + } + case <-time.After(2 * time.Second): + t.Fatalf("timeout waiting for event %d to be relayed", i) + } } } diff --git a/apps/edge/internal/service/service.go b/apps/edge/internal/service/service.go index 1edc8fd..3217485 100644 --- a/apps/edge/internal/service/service.go +++ b/apps/edge/internal/service/service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "time" toki "git.toki-labs.com/toki/proto-socket/go" @@ -11,6 +12,7 @@ import ( edgeevents "iop/apps/edge/internal/events" edgenode "iop/apps/edge/internal/node" + "iop/packages/go/config" eventpkg "iop/packages/go/events" iop "iop/proto/gen/iop" ) @@ -545,3 +547,262 @@ func normalizeTimeoutSec(timeoutSec int) int { func nodeLabel(entry *edgenode.NodeEntry) string { return entry.DisplayLabel() } + +func (s *Service) GetCapabilities() []*iop.EdgeCapabilitySummary { + snaps := s.ListNodeSnapshots() + + hasGeneric := false + hasOTO := false + + otoAvailable := false + var otoStates []string + + for _, snap := range snaps { + if snap.AgentKind == config.AgentKindOTOAgent { + hasOTO = true + status, avail := lifecycleToSummaryStatus(snap.LifecycleState) + if avail { + otoAvailable = true + } + otoStates = append(otoStates, status) + } else { + hasGeneric = true + } + } + + var out []*iop.EdgeCapabilitySummary + if hasGeneric { + out = append(out, &iop.EdgeCapabilitySummary{ + Kind: "run", + Available: true, + Status: "ready", + Summary: "Generic execution node available", + }) + } + if hasOTO { + otoStatus := "ready" + hasBusy := false + hasError := false + for _, st := range otoStates { + if st == "busy" { + hasBusy = true + } else if st == "error" { + hasError = true + } + } + if hasBusy { + otoStatus = "busy" + } else if hasError { + otoStatus = "error" + } + + out = append(out, &iop.EdgeCapabilitySummary{ + Kind: "build-deploy", + Available: otoAvailable, + Status: otoStatus, + Summary: "OTO build-deploy capability available", + }) + } + return out +} + +func (s *Service) GetDomainAgents() []*iop.EdgeDomainAgentSummary { + snaps := s.ListNodeSnapshots() + var out []*iop.EdgeDomainAgentSummary + for _, snap := range snaps { + if snap.AgentKind == config.AgentKindOTOAgent { + _, avail := lifecycleToSummaryStatus(snap.LifecycleState) + out = append(out, &iop.EdgeDomainAgentSummary{ + AgentKind: snap.AgentKind, + Available: avail, + LifecycleState: snap.LifecycleState, + Summary: fmt.Sprintf("OTO agent alias=%s state=%s", snap.Alias, snap.LifecycleState), + }) + } + } + return out +} + +func lifecycleToSummaryStatus(state string) (string, bool) { + switch state { + case "", "connected": + return "ready", true + case "deploying", "running", "busy": + return "busy", true + case "error", "failed": + return "error", false + default: + return "busy", true + } +} + +func edgeCommandResponseFromNodeCommand(req *iop.EdgeCommandRequest, view NodeCommandView, err error) *iop.EdgeCommandResponse { + if err != nil { + return &iop.EdgeCommandResponse{ + Status: "error", + Error: err.Error(), + } + } + var summaries []string + var keys []string + for k := range view.Result { + keys = append(keys, k) + } + importSort := func(a, b string) bool { return a < b } + for i := 0; i < len(keys); i++ { + for j := i + 1; j < len(keys); j++ { + if importSort(keys[j], keys[i]) { + keys[i], keys[j] = keys[j], keys[i] + } + } + } + for _, k := range keys { + summaries = append(summaries, fmt.Sprintf("%s=%s", k, view.Result[k])) + } + summaryStr := fmt.Sprintf("NodeCommand %s completed: %s", req.GetParameters()["command"], strings.Join(summaries, ", ")) + return &iop.EdgeCommandResponse{ + Status: "completed", + Summary: summaryStr, + } +} + +func unsupportedEdgeCommand(req *iop.EdgeCommandRequest, msg string) *iop.EdgeCommandResponse { + return &iop.EdgeCommandResponse{ + Status: "unsupported", + Error: msg, + } +} + +func (s *Service) ExecuteCommand(ctx context.Context, req *iop.EdgeCommandRequest, onEvent func(*iop.EdgeCommandEvent)) (*iop.EdgeCommandResponse, error) { + switch req.Operation { + case "health.check": + onEvent(&iop.EdgeCommandEvent{ + CommandId: req.CommandId, + Phase: "started", + Summary: "Starting health check", + OccurredAt: time.Now().UnixNano(), + }) + time.Sleep(10 * time.Millisecond) // simulation + onEvent(&iop.EdgeCommandEvent{ + CommandId: req.CommandId, + Phase: "completed", + Summary: "Health check passed", + OccurredAt: time.Now().UnixNano(), + }) + return &iop.EdgeCommandResponse{ + Status: "completed", + Summary: "Edge health check OK", + }, nil + + case "agent.status": + if req.TargetSelector == "" { + return nil, fmt.Errorf("target_selector is required for agent.status") + } + + onEvent(&iop.EdgeCommandEvent{ + CommandId: req.CommandId, + Phase: "started", + Summary: fmt.Sprintf("Resolving agent status for %s", req.TargetSelector), + OccurredAt: time.Now().UnixNano(), + }) + + entry, err := s.ResolveNode(req.TargetSelector) + if err != nil { + return &iop.EdgeCommandResponse{ + Status: "error", + Error: err.Error(), + }, nil + } + + snap := NodeEntrySnapshot(entry) + onEvent(&iop.EdgeCommandEvent{ + CommandId: req.CommandId, + Phase: "completed", + Summary: fmt.Sprintf("Resolved agent status for %s", req.TargetSelector), + OccurredAt: time.Now().UnixNano(), + }) + + return &iop.EdgeCommandResponse{ + Status: "completed", + Summary: fmt.Sprintf("node ID=%s Alias=%s Kind=%s State=%s", snap.NodeID, snap.Alias, snap.AgentKind, snap.LifecycleState), + }, nil + + case "agent.command": + cmdName := req.GetParameters()["command"] + if cmdName == "" { + return unsupportedEdgeCommand(req, "missing command parameter for agent.command"), nil + } + + onEvent(&iop.EdgeCommandEvent{ + CommandId: req.CommandId, + Phase: "started", + Summary: fmt.Sprintf("Routing command %s to node %s", cmdName, req.TargetSelector), + OccurredAt: time.Now().UnixNano(), + }) + + entry, err := s.ResolveNode(req.TargetSelector) + if err != nil { + onEvent(&iop.EdgeCommandEvent{ + CommandId: req.CommandId, + Phase: "failed", + Summary: fmt.Sprintf("Command %s failed: %v", cmdName, err), + OccurredAt: time.Now().UnixNano(), + }) + return &iop.EdgeCommandResponse{ + Status: "error", + Error: err.Error(), + }, nil + } + if entry.Client == nil { + onEvent(&iop.EdgeCommandEvent{ + CommandId: req.CommandId, + Phase: "failed", + Summary: fmt.Sprintf("Command %s failed: node client not connected", cmdName), + OccurredAt: time.Now().UnixNano(), + }) + return &iop.EdgeCommandResponse{ + Status: "error", + Error: "node client not connected", + }, nil + } + + switch cmdName { + case "capabilities": + view, err := s.Capabilities(ctx, NodeCommandRequestSpec{ + NodeRef: req.TargetSelector, + Adapter: req.GetParameters()["adapter"], + Target: req.GetParameters()["target"], + SessionID: req.GetParameters()["session_id"], + TimeoutSec: 10, + }) + + var phase string + var summary string + if err != nil { + phase = "failed" + summary = fmt.Sprintf("Command %s failed: %v", cmdName, err) + } else { + phase = "completed" + summary = fmt.Sprintf("Command %s completed successfully", cmdName) + } + + onEvent(&iop.EdgeCommandEvent{ + CommandId: req.CommandId, + Phase: phase, + Summary: summary, + OccurredAt: time.Now().UnixNano(), + }) + + return edgeCommandResponseFromNodeCommand(req, view, err), nil + + default: + return unsupportedEdgeCommand(req, fmt.Sprintf("unsupported agent.command command %q", cmdName)), nil + } + + default: + return &iop.EdgeCommandResponse{ + Status: "unsupported", + Error: fmt.Sprintf("unsupported operation %s", req.Operation), + }, nil + } +} diff --git a/apps/edge/internal/service/service_test.go b/apps/edge/internal/service/service_test.go index 6f50a55..e1d4d68 100644 --- a/apps/edge/internal/service/service_test.go +++ b/apps/edge/internal/service/service_test.go @@ -1,9 +1,16 @@ package service_test import ( + "context" + "fmt" + "net" + "strings" "testing" "time" + toki "git.toki-labs.com/toki/proto-socket/go" + "google.golang.org/protobuf/proto" + edgenode "iop/apps/edge/internal/node" edgeservice "iop/apps/edge/internal/service" "iop/packages/go/config" @@ -383,3 +390,356 @@ func TestResolveNodeSnapshotCarriesAgentKind(t *testing.T) { t.Errorf("LifecycleState: got %q want %q", snap.LifecycleState, edgenode.LifecycleConnected) } } + +func TestServiceCapabilitiesAndDomainAgents(t *testing.T) { + cases := []struct { + state string + expectedState string + expectedAvail bool + expectedStat string + }{ + {"", "connected", true, "ready"}, + {"connected", "connected", true, "ready"}, + {"deploying", "deploying", true, "busy"}, + {"running", "running", true, "busy"}, + {"busy", "busy", true, "busy"}, + {"error", "error", false, "error"}, + {"failed", "failed", false, "error"}, + {"unknown-state", "unknown-state", true, "busy"}, + } + + for _, tc := range cases { + t.Run(fmt.Sprintf("state-%s", tc.state), func(t *testing.T) { + reg := edgenode.NewRegistry() + reg.Register(&edgenode.NodeEntry{ + NodeID: "generic-1", + Alias: "gen-node", + AgentKind: config.AgentKindGenericNode, + }) + reg.Register(&edgenode.NodeEntry{ + NodeID: "oto-1", + Alias: "deployer", + AgentKind: config.AgentKindOTOAgent, + LifecycleState: tc.state, + }) + svc := edgeservice.New(reg, nil) + + caps := svc.GetCapabilities() + if len(caps) != 2 { + t.Fatalf("expected 2 capabilities, got %d", len(caps)) + } + capMap := map[string]*iop.EdgeCapabilitySummary{} + for _, c := range caps { + capMap[c.Kind] = c + } + if c := capMap["run"]; c == nil || !c.Available || c.Status != "ready" { + t.Errorf("invalid run capability: %+v", c) + } + if c := capMap["build-deploy"]; c == nil || c.Available != tc.expectedAvail || c.Status != tc.expectedStat { + t.Errorf("state %q: invalid build-deploy capability: %+v (want avail=%v, stat=%q)", tc.state, c, tc.expectedAvail, tc.expectedStat) + } + + agents := svc.GetDomainAgents() + if len(agents) != 1 { + t.Fatalf("expected 1 domain agent, got %d", len(agents)) + } + a := agents[0] + if a.AgentKind != config.AgentKindOTOAgent || a.Available != tc.expectedAvail || a.LifecycleState != tc.expectedState { + t.Errorf("invalid domain agent summary: %+v", a) + } + }) + } +} + +func TestServiceExecuteCommand(t *testing.T) { + reg := edgenode.NewRegistry() + reg.Register(&edgenode.NodeEntry{ + NodeID: "oto-1", + Alias: "deployer", + AgentKind: config.AgentKindOTOAgent, + }) + svc := edgeservice.New(reg, nil) + + t.Run("health.check", func(t *testing.T) { + var events []*iop.EdgeCommandEvent + onEvent := func(e *iop.EdgeCommandEvent) { + events = append(events, e) + } + req := &iop.EdgeCommandRequest{ + CommandId: "cmd-hc", + Operation: "health.check", + } + resp, err := svc.ExecuteCommand(context.Background(), req, onEvent) + if err != nil { + t.Fatalf("ExecuteCommand error: %v", err) + } + if resp.Status != "completed" { + t.Errorf("status: got %q want completed", resp.Status) + } + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Phase != "started" || events[1].Phase != "completed" { + t.Errorf("unexpected event sequence: %+v", events) + } + }) + + t.Run("agent.status found", func(t *testing.T) { + var events []*iop.EdgeCommandEvent + onEvent := func(e *iop.EdgeCommandEvent) { + events = append(events, e) + } + req := &iop.EdgeCommandRequest{ + CommandId: "cmd-status", + Operation: "agent.status", + TargetSelector: "deployer", + } + resp, err := svc.ExecuteCommand(context.Background(), req, onEvent) + if err != nil { + t.Fatalf("ExecuteCommand error: %v", err) + } + if resp.Status != "completed" { + t.Errorf("status: got %q want completed", resp.Status) + } + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Phase != "started" || events[1].Phase != "completed" { + t.Errorf("unexpected event sequence: %+v", events) + } + }) + + t.Run("agent.status missing", func(t *testing.T) { + req := &iop.EdgeCommandRequest{ + CommandId: "cmd-status", + Operation: "agent.status", + TargetSelector: "missing-agent", + } + resp, err := svc.ExecuteCommand(context.Background(), req, func(*iop.EdgeCommandEvent) {}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "error" || resp.Error == "" { + t.Errorf("expected error status, got: %+v", resp) + } + }) + + t.Run("agent.command does not fake success on nil client", func(t *testing.T) { + var events []*iop.EdgeCommandEvent + onEvent := func(e *iop.EdgeCommandEvent) { + events = append(events, e) + } + req := &iop.EdgeCommandRequest{ + CommandId: "cmd-run", + Operation: "agent.command", + TargetSelector: "deployer", + Parameters: map[string]string{ + "command": "capabilities", + }, + } + resp, err := svc.ExecuteCommand(context.Background(), req, onEvent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "error" { + t.Errorf("expected error status, got: %q", resp.Status) + } + if len(events) < 2 { + t.Fatalf("expected at least started and failed events, got %d", len(events)) + } + if events[0].Phase != "started" || events[len(events)-1].Phase != "failed" { + t.Errorf("unexpected event sequence: %+v", events) + } + }) + + t.Run("agent.command missing command param", func(t *testing.T) { + req := &iop.EdgeCommandRequest{ + CommandId: "cmd-run", + Operation: "agent.command", + TargetSelector: "deployer", + } + resp, err := svc.ExecuteCommand(context.Background(), req, func(*iop.EdgeCommandEvent) {}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "unsupported" { + t.Errorf("expected unsupported status, got %q", resp.Status) + } + }) + + t.Run("unsupported operation", func(t *testing.T) { + req := &iop.EdgeCommandRequest{ + CommandId: "cmd-bad", + Operation: "bad.op", + } + resp, err := svc.ExecuteCommand(context.Background(), req, func(*iop.EdgeCommandEvent) {}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Status != "unsupported" { + t.Errorf("status: got %q want unsupported", resp.Status) + } + }) +} + +func TestServiceExecuteCommandAgentCommandCapabilitiesSuccess(t *testing.T) { + edgeConn, nodeConn := net.Pipe() + defer edgeConn.Close() + defer nodeConn.Close() + + parserMap := toki.ParserMap{ + toki.TypeNameOf(&iop.NodeCommandRequest{}): func(b []byte) (proto.Message, error) { + m := &iop.NodeCommandRequest{} + return m, proto.Unmarshal(b, m) + }, + toki.TypeNameOf(&iop.NodeCommandResponse{}): func(b []byte) (proto.Message, error) { + m := &iop.NodeCommandResponse{} + return m, proto.Unmarshal(b, m) + }, + } + + edgeClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap) + nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap) + + toki.AddRequestListenerTyped(&nodeClient.Communicator, func(req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) { + if req.GetType() != iop.NodeCommandType_NODE_COMMAND_TYPE_CAPABILITIES { + return nil, fmt.Errorf("unexpected command type: %v", req.GetType()) + } + if req.GetAdapter() != "cli" || req.GetTarget() != "codex" || req.GetSessionId() != "sess-success" { + return nil, fmt.Errorf("unexpected adapter/target/session: %s/%s/%s", req.GetAdapter(), req.GetTarget(), req.GetSessionId()) + } + return &iop.NodeCommandResponse{ + RequestId: req.RequestId, + Type: req.Type, + Result: map[string]string{"caps": "cli,codex"}, + }, nil + }) + + reg := edgenode.NewRegistry() + reg.Register(&edgenode.NodeEntry{ + NodeID: "oto-1", + Alias: "deployer", + AgentKind: config.AgentKindOTOAgent, + Client: edgeClient, + }) + svc := edgeservice.New(reg, nil) + + var events []*iop.EdgeCommandEvent + onEvent := func(e *iop.EdgeCommandEvent) { + events = append(events, e) + } + + req := &iop.EdgeCommandRequest{ + CommandId: "cmd-success-1", + Operation: "agent.command", + TargetSelector: "deployer", + Parameters: map[string]string{ + "command": "capabilities", + "adapter": "cli", + "target": "codex", + "session_id": "sess-success", + }, + } + + resp, err := svc.ExecuteCommand(context.Background(), req, onEvent) + if err != nil { + t.Fatalf("ExecuteCommand: %v", err) + } + + if resp.Status != "completed" { + t.Errorf("status: got %q want completed", resp.Status) + } + if !strings.Contains(resp.Summary, "caps=cli,codex") { + t.Errorf("summary: got %q, expected containing caps=cli,codex", resp.Summary) + } + + if len(events) < 2 { + t.Fatalf("expected at least 2 events, got %d", len(events)) + } + if events[0].Phase != "started" || events[len(events)-1].Phase != "completed" { + t.Errorf("unexpected event sequence: %+v", events) + } + if events[len(events)-1].Summary != "Command capabilities completed successfully" { + t.Errorf("unexpected event summary: got %q, expected 'Command capabilities completed successfully'", events[len(events)-1].Summary) + } +} + +func TestServiceExecuteCommandAgentCommandCapabilitiesNodeError(t *testing.T) { + edgeConn, nodeConn := net.Pipe() + defer edgeConn.Close() + defer nodeConn.Close() + + parserMap := toki.ParserMap{ + toki.TypeNameOf(&iop.NodeCommandRequest{}): func(b []byte) (proto.Message, error) { + m := &iop.NodeCommandRequest{} + return m, proto.Unmarshal(b, m) + }, + toki.TypeNameOf(&iop.NodeCommandResponse{}): func(b []byte) (proto.Message, error) { + m := &iop.NodeCommandResponse{} + return m, proto.Unmarshal(b, m) + }, + } + + edgeClient := toki.NewTcpClient(edgeConn, 0, 0, parserMap) + nodeClient := toki.NewTcpClient(nodeConn, 0, 0, parserMap) + + toki.AddRequestListenerTyped(&nodeClient.Communicator, func(req *iop.NodeCommandRequest) (*iop.NodeCommandResponse, error) { + if req.GetSessionId() != "sess-error" { + return nil, fmt.Errorf("unexpected session_id: %s", req.GetSessionId()) + } + return &iop.NodeCommandResponse{ + RequestId: req.RequestId, + Type: req.Type, + Error: "node failure", + }, nil + }) + + reg := edgenode.NewRegistry() + reg.Register(&edgenode.NodeEntry{ + NodeID: "oto-1", + Alias: "deployer", + AgentKind: config.AgentKindOTOAgent, + Client: edgeClient, + }) + svc := edgeservice.New(reg, nil) + + var events []*iop.EdgeCommandEvent + onEvent := func(e *iop.EdgeCommandEvent) { + events = append(events, e) + } + + req := &iop.EdgeCommandRequest{ + CommandId: "cmd-error-1", + Operation: "agent.command", + TargetSelector: "deployer", + Parameters: map[string]string{ + "command": "capabilities", + "adapter": "cli", + "target": "codex", + "session_id": "sess-error", + }, + } + + resp, err := svc.ExecuteCommand(context.Background(), req, onEvent) + if err != nil { + t.Fatalf("ExecuteCommand: %v", err) + } + + if resp.Status != "error" { + t.Errorf("status: got %q want error", resp.Status) + } + if !strings.Contains(resp.Error, "node reported error: node failure") { + t.Errorf("error: got %q, expected containing node failure", resp.Error) + } + + if len(events) < 2 { + t.Fatalf("expected at least 2 events, got %d", len(events)) + } + if events[0].Phase != "started" || events[len(events)-1].Phase != "failed" { + t.Errorf("unexpected event sequence: %+v", events) + } + expectedSummary := "Command capabilities failed: node reported error: node failure" + if events[len(events)-1].Summary != expectedSummary { + t.Errorf("unexpected event summary: got %q, expected %q", events[len(events)-1].Summary, expectedSummary) + } +} diff --git a/proto/gen/iop/control.pb.go b/proto/gen/iop/control.pb.go index 89e0df4..2f7bbda 100644 --- a/proto/gen/iop/control.pb.go +++ b/proto/gen/iop/control.pb.go @@ -622,14 +622,16 @@ func (x *EdgeNodeSnapshot) GetConfig() *NodeConfigPayload { // EdgeStatusRequest. The Control Plane observes this snapshot rather than // replicating the Edge-local registry. type EdgeStatusResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` - EdgeId string `protobuf:"bytes,2,opt,name=edge_id,json=edgeId,proto3" json:"edge_id,omitempty"` - EdgeName string `protobuf:"bytes,3,opt,name=edge_name,json=edgeName,proto3" json:"edge_name,omitempty"` - ObservedTimeUnixNano int64 `protobuf:"varint,4,opt,name=observed_time_unix_nano,json=observedTimeUnixNano,proto3" json:"observed_time_unix_nano,omitempty"` - Nodes []*EdgeNodeSnapshot `protobuf:"bytes,5,rep,name=nodes,proto3" json:"nodes,omitempty"` - Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + EdgeId string `protobuf:"bytes,2,opt,name=edge_id,json=edgeId,proto3" json:"edge_id,omitempty"` + EdgeName string `protobuf:"bytes,3,opt,name=edge_name,json=edgeName,proto3" json:"edge_name,omitempty"` + ObservedTimeUnixNano int64 `protobuf:"varint,4,opt,name=observed_time_unix_nano,json=observedTimeUnixNano,proto3" json:"observed_time_unix_nano,omitempty"` + Nodes []*EdgeNodeSnapshot `protobuf:"bytes,5,rep,name=nodes,proto3" json:"nodes,omitempty"` + Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + Capabilities []*EdgeCapabilitySummary `protobuf:"bytes,8,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + DomainAgents []*EdgeDomainAgentSummary `protobuf:"bytes,9,rep,name=domain_agents,json=domainAgents,proto3" json:"domain_agents,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -713,6 +715,400 @@ func (x *EdgeStatusResponse) GetError() string { return "" } +func (x *EdgeStatusResponse) GetCapabilities() []*EdgeCapabilitySummary { + if x != nil { + return x.Capabilities + } + return nil +} + +func (x *EdgeStatusResponse) GetDomainAgents() []*EdgeDomainAgentSummary { + if x != nil { + return x.DomainAgents + } + return nil +} + +type EdgeCapabilitySummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` + Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + Summary string `protobuf:"bytes,4,opt,name=summary,proto3" json:"summary,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EdgeCapabilitySummary) Reset() { + *x = EdgeCapabilitySummary{} + mi := &file_proto_iop_control_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EdgeCapabilitySummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EdgeCapabilitySummary) ProtoMessage() {} + +func (x *EdgeCapabilitySummary) ProtoReflect() protoreflect.Message { + mi := &file_proto_iop_control_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EdgeCapabilitySummary.ProtoReflect.Descriptor instead. +func (*EdgeCapabilitySummary) Descriptor() ([]byte, []int) { + return file_proto_iop_control_proto_rawDescGZIP(), []int{10} +} + +func (x *EdgeCapabilitySummary) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *EdgeCapabilitySummary) GetAvailable() bool { + if x != nil { + return x.Available + } + return false +} + +func (x *EdgeCapabilitySummary) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *EdgeCapabilitySummary) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + +type EdgeDomainAgentSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentKind string `protobuf:"bytes,1,opt,name=agent_kind,json=agentKind,proto3" json:"agent_kind,omitempty"` + Available bool `protobuf:"varint,2,opt,name=available,proto3" json:"available,omitempty"` + LifecycleState string `protobuf:"bytes,3,opt,name=lifecycle_state,json=lifecycleState,proto3" json:"lifecycle_state,omitempty"` + ActiveCommandId string `protobuf:"bytes,4,opt,name=active_command_id,json=activeCommandId,proto3" json:"active_command_id,omitempty"` + Summary string `protobuf:"bytes,5,opt,name=summary,proto3" json:"summary,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EdgeDomainAgentSummary) Reset() { + *x = EdgeDomainAgentSummary{} + mi := &file_proto_iop_control_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EdgeDomainAgentSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EdgeDomainAgentSummary) ProtoMessage() {} + +func (x *EdgeDomainAgentSummary) ProtoReflect() protoreflect.Message { + mi := &file_proto_iop_control_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EdgeDomainAgentSummary.ProtoReflect.Descriptor instead. +func (*EdgeDomainAgentSummary) Descriptor() ([]byte, []int) { + return file_proto_iop_control_proto_rawDescGZIP(), []int{11} +} + +func (x *EdgeDomainAgentSummary) GetAgentKind() string { + if x != nil { + return x.AgentKind + } + return "" +} + +func (x *EdgeDomainAgentSummary) GetAvailable() bool { + if x != nil { + return x.Available + } + return false +} + +func (x *EdgeDomainAgentSummary) GetLifecycleState() string { + if x != nil { + return x.LifecycleState + } + return "" +} + +func (x *EdgeDomainAgentSummary) GetActiveCommandId() string { + if x != nil { + return x.ActiveCommandId + } + return "" +} + +func (x *EdgeDomainAgentSummary) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + +type EdgeCommandRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + TargetSelector string `protobuf:"bytes,3,opt,name=target_selector,json=targetSelector,proto3" json:"target_selector,omitempty"` + Operation string `protobuf:"bytes,4,opt,name=operation,proto3" json:"operation,omitempty"` + Parameters map[string]string `protobuf:"bytes,5,rep,name=parameters,proto3" json:"parameters,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EdgeCommandRequest) Reset() { + *x = EdgeCommandRequest{} + mi := &file_proto_iop_control_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EdgeCommandRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EdgeCommandRequest) ProtoMessage() {} + +func (x *EdgeCommandRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iop_control_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EdgeCommandRequest.ProtoReflect.Descriptor instead. +func (*EdgeCommandRequest) Descriptor() ([]byte, []int) { + return file_proto_iop_control_proto_rawDescGZIP(), []int{12} +} + +func (x *EdgeCommandRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *EdgeCommandRequest) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *EdgeCommandRequest) GetTargetSelector() string { + if x != nil { + return x.TargetSelector + } + return "" +} + +func (x *EdgeCommandRequest) GetOperation() string { + if x != nil { + return x.Operation + } + return "" +} + +func (x *EdgeCommandRequest) GetParameters() map[string]string { + if x != nil { + return x.Parameters + } + return nil +} + +type EdgeCommandResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + EdgeId string `protobuf:"bytes,3,opt,name=edge_id,json=edgeId,proto3" json:"edge_id,omitempty"` + Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` + Summary string `protobuf:"bytes,5,opt,name=summary,proto3" json:"summary,omitempty"` + Error string `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EdgeCommandResponse) Reset() { + *x = EdgeCommandResponse{} + mi := &file_proto_iop_control_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EdgeCommandResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EdgeCommandResponse) ProtoMessage() {} + +func (x *EdgeCommandResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_iop_control_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EdgeCommandResponse.ProtoReflect.Descriptor instead. +func (*EdgeCommandResponse) Descriptor() ([]byte, []int) { + return file_proto_iop_control_proto_rawDescGZIP(), []int{13} +} + +func (x *EdgeCommandResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *EdgeCommandResponse) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *EdgeCommandResponse) GetEdgeId() string { + if x != nil { + return x.EdgeId + } + return "" +} + +func (x *EdgeCommandResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *EdgeCommandResponse) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + +func (x *EdgeCommandResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type EdgeCommandEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + EdgeId string `protobuf:"bytes,2,opt,name=edge_id,json=edgeId,proto3" json:"edge_id,omitempty"` + Phase string `protobuf:"bytes,3,opt,name=phase,proto3" json:"phase,omitempty"` + Summary string `protobuf:"bytes,4,opt,name=summary,proto3" json:"summary,omitempty"` + OccurredAt int64 `protobuf:"varint,5,opt,name=occurred_at,json=occurredAt,proto3" json:"occurred_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EdgeCommandEvent) Reset() { + *x = EdgeCommandEvent{} + mi := &file_proto_iop_control_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EdgeCommandEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EdgeCommandEvent) ProtoMessage() {} + +func (x *EdgeCommandEvent) ProtoReflect() protoreflect.Message { + mi := &file_proto_iop_control_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EdgeCommandEvent.ProtoReflect.Descriptor instead. +func (*EdgeCommandEvent) Descriptor() ([]byte, []int) { + return file_proto_iop_control_proto_rawDescGZIP(), []int{14} +} + +func (x *EdgeCommandEvent) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *EdgeCommandEvent) GetEdgeId() string { + if x != nil { + return x.EdgeId + } + return "" +} + +func (x *EdgeCommandEvent) GetPhase() string { + if x != nil { + return x.Phase + } + return "" +} + +func (x *EdgeCommandEvent) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + +func (x *EdgeCommandEvent) GetOccurredAt() int64 { + if x != nil { + return x.OccurredAt + } + return 0 +} + var File_proto_iop_control_proto protoreflect.FileDescriptor const file_proto_iop_control_proto_rawDesc = "" + @@ -771,7 +1167,7 @@ const file_proto_iop_control_proto_rawDesc = "" + "\x05alias\x18\x02 \x01(\tR\x05alias\x12\x14\n" + "\x05label\x18\x03 \x01(\tR\x05label\x12\x1c\n" + "\tconnected\x18\x04 \x01(\bR\tconnected\x12.\n" + - "\x06config\x18\x05 \x01(\v2\x16.iop.NodeConfigPayloadR\x06config\"\xe3\x02\n" + + "\x06config\x18\x05 \x01(\v2\x16.iop.NodeConfigPayloadR\x06config\"\xe5\x03\n" + "\x12EdgeStatusResponse\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x17\n" + @@ -780,10 +1176,54 @@ const file_proto_iop_control_proto_rawDesc = "" + "\x17observed_time_unix_nano\x18\x04 \x01(\x03R\x14observedTimeUnixNano\x12+\n" + "\x05nodes\x18\x05 \x03(\v2\x15.iop.EdgeNodeSnapshotR\x05nodes\x12A\n" + "\bmetadata\x18\x06 \x03(\v2%.iop.EdgeStatusResponse.MetadataEntryR\bmetadata\x12\x14\n" + - "\x05error\x18\a \x01(\tR\x05error\x1a;\n" + + "\x05error\x18\a \x01(\tR\x05error\x12>\n" + + "\fcapabilities\x18\b \x03(\v2\x1a.iop.EdgeCapabilitySummaryR\fcapabilities\x12@\n" + + "\rdomain_agents\x18\t \x03(\v2\x1b.iop.EdgeDomainAgentSummaryR\fdomainAgents\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x13Z\x11iop/proto/gen/iopb\x06proto3" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"{\n" + + "\x15EdgeCapabilitySummary\x12\x12\n" + + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x1c\n" + + "\tavailable\x18\x02 \x01(\bR\tavailable\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x18\n" + + "\asummary\x18\x04 \x01(\tR\asummary\"\xc4\x01\n" + + "\x16EdgeDomainAgentSummary\x12\x1d\n" + + "\n" + + "agent_kind\x18\x01 \x01(\tR\tagentKind\x12\x1c\n" + + "\tavailable\x18\x02 \x01(\bR\tavailable\x12'\n" + + "\x0flifecycle_state\x18\x03 \x01(\tR\x0elifecycleState\x12*\n" + + "\x11active_command_id\x18\x04 \x01(\tR\x0factiveCommandId\x12\x18\n" + + "\asummary\x18\x05 \x01(\tR\asummary\"\xa1\x02\n" + + "\x12EdgeCommandRequest\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" + + "\n" + + "command_id\x18\x02 \x01(\tR\tcommandId\x12'\n" + + "\x0ftarget_selector\x18\x03 \x01(\tR\x0etargetSelector\x12\x1c\n" + + "\toperation\x18\x04 \x01(\tR\toperation\x12G\n" + + "\n" + + "parameters\x18\x05 \x03(\v2'.iop.EdgeCommandRequest.ParametersEntryR\n" + + "parameters\x1a=\n" + + "\x0fParametersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb4\x01\n" + + "\x13EdgeCommandResponse\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" + + "\n" + + "command_id\x18\x02 \x01(\tR\tcommandId\x12\x17\n" + + "\aedge_id\x18\x03 \x01(\tR\x06edgeId\x12\x16\n" + + "\x06status\x18\x04 \x01(\tR\x06status\x12\x18\n" + + "\asummary\x18\x05 \x01(\tR\asummary\x12\x14\n" + + "\x05error\x18\x06 \x01(\tR\x05error\"\x9b\x01\n" + + "\x10EdgeCommandEvent\x12\x1d\n" + + "\n" + + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" + + "\aedge_id\x18\x02 \x01(\tR\x06edgeId\x12\x14\n" + + "\x05phase\x18\x03 \x01(\tR\x05phase\x12\x18\n" + + "\asummary\x18\x04 \x01(\tR\asummary\x12\x1f\n" + + "\voccurred_at\x18\x05 \x01(\x03R\n" + + "occurredAtB\x13Z\x11iop/proto/gen/iopb\x06proto3" var ( file_proto_iop_control_proto_rawDescOnce sync.Once @@ -797,37 +1237,46 @@ func file_proto_iop_control_proto_rawDescGZIP() []byte { return file_proto_iop_control_proto_rawDescData } -var file_proto_iop_control_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_proto_iop_control_proto_msgTypes = make([]protoimpl.MessageInfo, 20) var file_proto_iop_control_proto_goTypes = []any{ - (*PolicyRule)(nil), // 0: iop.PolicyRule - (*ScheduleRequest)(nil), // 1: iop.ScheduleRequest - (*ScheduleResponse)(nil), // 2: iop.ScheduleResponse - (*ClientHelloRequest)(nil), // 3: iop.ClientHelloRequest - (*ClientHelloResponse)(nil), // 4: iop.ClientHelloResponse - (*EdgeHelloRequest)(nil), // 5: iop.EdgeHelloRequest - (*EdgeHelloResponse)(nil), // 6: iop.EdgeHelloResponse - (*EdgeStatusRequest)(nil), // 7: iop.EdgeStatusRequest - (*EdgeNodeSnapshot)(nil), // 8: iop.EdgeNodeSnapshot - (*EdgeStatusResponse)(nil), // 9: iop.EdgeStatusResponse - nil, // 10: iop.PolicyRule.ParamsEntry - nil, // 11: iop.ScheduleRequest.MetadataEntry - nil, // 12: iop.EdgeHelloRequest.MetadataEntry - nil, // 13: iop.EdgeStatusResponse.MetadataEntry - (*NodeConfigPayload)(nil), // 14: iop.NodeConfigPayload + (*PolicyRule)(nil), // 0: iop.PolicyRule + (*ScheduleRequest)(nil), // 1: iop.ScheduleRequest + (*ScheduleResponse)(nil), // 2: iop.ScheduleResponse + (*ClientHelloRequest)(nil), // 3: iop.ClientHelloRequest + (*ClientHelloResponse)(nil), // 4: iop.ClientHelloResponse + (*EdgeHelloRequest)(nil), // 5: iop.EdgeHelloRequest + (*EdgeHelloResponse)(nil), // 6: iop.EdgeHelloResponse + (*EdgeStatusRequest)(nil), // 7: iop.EdgeStatusRequest + (*EdgeNodeSnapshot)(nil), // 8: iop.EdgeNodeSnapshot + (*EdgeStatusResponse)(nil), // 9: iop.EdgeStatusResponse + (*EdgeCapabilitySummary)(nil), // 10: iop.EdgeCapabilitySummary + (*EdgeDomainAgentSummary)(nil), // 11: iop.EdgeDomainAgentSummary + (*EdgeCommandRequest)(nil), // 12: iop.EdgeCommandRequest + (*EdgeCommandResponse)(nil), // 13: iop.EdgeCommandResponse + (*EdgeCommandEvent)(nil), // 14: iop.EdgeCommandEvent + nil, // 15: iop.PolicyRule.ParamsEntry + nil, // 16: iop.ScheduleRequest.MetadataEntry + nil, // 17: iop.EdgeHelloRequest.MetadataEntry + nil, // 18: iop.EdgeStatusResponse.MetadataEntry + nil, // 19: iop.EdgeCommandRequest.ParametersEntry + (*NodeConfigPayload)(nil), // 20: iop.NodeConfigPayload } var file_proto_iop_control_proto_depIdxs = []int32{ - 10, // 0: iop.PolicyRule.params:type_name -> iop.PolicyRule.ParamsEntry + 15, // 0: iop.PolicyRule.params:type_name -> iop.PolicyRule.ParamsEntry 0, // 1: iop.ScheduleRequest.policies:type_name -> iop.PolicyRule - 11, // 2: iop.ScheduleRequest.metadata:type_name -> iop.ScheduleRequest.MetadataEntry - 12, // 3: iop.EdgeHelloRequest.metadata:type_name -> iop.EdgeHelloRequest.MetadataEntry - 14, // 4: iop.EdgeNodeSnapshot.config:type_name -> iop.NodeConfigPayload + 16, // 2: iop.ScheduleRequest.metadata:type_name -> iop.ScheduleRequest.MetadataEntry + 17, // 3: iop.EdgeHelloRequest.metadata:type_name -> iop.EdgeHelloRequest.MetadataEntry + 20, // 4: iop.EdgeNodeSnapshot.config:type_name -> iop.NodeConfigPayload 8, // 5: iop.EdgeStatusResponse.nodes:type_name -> iop.EdgeNodeSnapshot - 13, // 6: iop.EdgeStatusResponse.metadata:type_name -> iop.EdgeStatusResponse.MetadataEntry - 7, // [7:7] is the sub-list for method output_type - 7, // [7:7] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 18, // 6: iop.EdgeStatusResponse.metadata:type_name -> iop.EdgeStatusResponse.MetadataEntry + 10, // 7: iop.EdgeStatusResponse.capabilities:type_name -> iop.EdgeCapabilitySummary + 11, // 8: iop.EdgeStatusResponse.domain_agents:type_name -> iop.EdgeDomainAgentSummary + 19, // 9: iop.EdgeCommandRequest.parameters:type_name -> iop.EdgeCommandRequest.ParametersEntry + 10, // [10:10] is the sub-list for method output_type + 10, // [10:10] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_proto_iop_control_proto_init() } @@ -842,7 +1291,7 @@ func file_proto_iop_control_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_iop_control_proto_rawDesc), len(file_proto_iop_control_proto_rawDesc)), NumEnums: 0, - NumMessages: 14, + NumMessages: 20, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/iop/control.proto b/proto/iop/control.proto index aa2c660..5158eeb 100644 --- a/proto/iop/control.proto +++ b/proto/iop/control.proto @@ -93,4 +93,46 @@ message EdgeStatusResponse { repeated EdgeNodeSnapshot nodes = 5; map metadata = 6; string error = 7; + repeated EdgeCapabilitySummary capabilities = 8; + repeated EdgeDomainAgentSummary domain_agents = 9; +} + +message EdgeCapabilitySummary { + string kind = 1; + bool available = 2; + string status = 3; + string summary = 4; +} + +message EdgeDomainAgentSummary { + string agent_kind = 1; + bool available = 2; + string lifecycle_state = 3; + string active_command_id = 4; + string summary = 5; +} + +message EdgeCommandRequest { + string request_id = 1; + string command_id = 2; + string target_selector = 3; + string operation = 4; + map parameters = 5; +} + +message EdgeCommandResponse { + string request_id = 1; + string command_id = 2; + string edge_id = 3; + string status = 4; + string summary = 5; + string error = 6; +} + +message EdgeCommandEvent { + string command_id = 1; + string edge_id = 2; + string phase = 3; + string summary = 4; + int64 occurred_at = 5; }