test(multi-edge): Edge별 이력 상태 검증을 보강한다

Edge별 command audit과 domain agent 상태가 서로 섞이지 않도록 control-plane/client fixture와 Runtime panel widget 검증을 보강한다.

로드맵 상태와 Agent-Ops 완료 로그를 함께 정리한다.
This commit is contained in:
toki 2026-06-05 03:49:17 +09:00
parent a43d2172ae
commit ea949daa3b
13 changed files with 1559 additions and 312 deletions

View file

@ -49,7 +49,7 @@
- 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/policy-history-audit.md`
- 요약: 권한, 정책, 실행 이력, 감사 로그를 제품 운영에 필요한 수준으로 확장하고, 공통 audit schema evidence까지 확정했다.
- [계획] Multi-Edge 운영
- [진행중] Multi-Edge 운영
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md`
- 요약: 여러 Edge group을 관찰하고 운영하는 fleet-level 기능을 구축한다.

View file

@ -12,7 +12,7 @@ Control Plane은 Edge를 제어하기 쉽게 연결하는 레이어이며, Edge
## 상태
[계획]
[진행중]
## 구현 잠금
@ -36,7 +36,7 @@ Control Plane은 Edge를 제어하기 쉽게 연결하는 레이어이며, Edge
### Epic: [fleet-ops-boundary] Fleet Operations Boundary
- [x] [native-fleet] Multi-edge 운영 명령과 이벤트 relay는 IOP native protocol을 기준으로 설계한다.
- [ ] [edge-status-view] Control Plane은 여러 Edge 상태를 구분해 조회하고 표시할 수 있다. 검증: 두 개 이상의 Edge 상태가 구분되는 조회/표시 경로를 확인한다.
- [x] [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 경로로 제한한다.
@ -73,3 +73,4 @@ Control Plane은 Edge를 제어하기 쉽게 연결하는 레이어이며, Edge
- 완료 근거: [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` 통과.
- 완료 근거: [edge-status-view] `apps/client/test/widget_test.dart`에서 두 Edge의 fleet status가 선택에 따라 `Edge Alpha`/`Edge Beta`, `edge-a`/`edge-b`, connected/disconnected, health 값을 구분해 표시되는 경로를 고정했다. 검증: `cd apps/client && flutter test test/widget_test.dart` 통과.

View file

@ -0,0 +1,157 @@
<!-- task=m-multi-edge-operations/01_history_agent_contract plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a 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-04
task=m-multi-edge-operations/01_history_agent_contract, plan=0, tag=API
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대리조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-multi-edge-operations/01_history_agent_contract/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-multi-edge-operations`이면 완료 이벤트 메타데이터를 보고한다. 이 plan에는 Roadmap Targets가 없으므로 roadmap Task 체크는 no-op이어야 한다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Fleet Status Domain Agent Isolation | [x] |
| [API-2] Edge Operations Audit Isolation | [x] |
## 구현 체크리스트
- [x] `/fleet/status`가 두 connected Edge의 서로 다른 domain agent capability/status/command summary를 구분해 반환하는 focused test를 추가한다.
- [x] `/edges/{edge_id}/operations`가 Edge별 command audit을 섞지 않는 focused test를 추가한다.
- [x] 필요 시 backend mapper/registry 구현을 테스트 기대에 맞게 보강하되 Control Plane이 Edge-owned 원본 데이터를 소유하지 않도록 유지한다.
- [x] `go test ./apps/control-plane/cmd/control-plane ./apps/control-plane/internal/wire ./apps/edge/internal/service -count=1`를 실행한다.
- [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_local_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_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/01_history_agent_contract/`를 `agent-task/archive/YYYY/MM/m-multi-edge-operations/01_history_agent_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-multi-edge-operations`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-multi-edge-operations/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- `TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities`에서 `edge-offline`을 disconnect 처리할 때, token 값을 하드코딩 `3`에서 `MarkConnected` 시 반환받은 토큰인 `offlineToken`으로 대체하였습니다. `edge-other`를 테스트에 새로 추가함에 따라 token의 시퀀스 ID가 변하는 것을 방지하여 보다 안정적인 disconnect 상태 관찰이 가능하게 했습니다.
## 주요 설계 결정
- Control Plane이 Edge가 소유하는 상태(Domain Agent status 등)의 원본을 소유하지 않도록, `registry.RequestStatus` mock handler를 통해서만 해당 정보를 전달받고 status response mapping 레이어를 검증하는 방식을 유지했습니다.
- cross-edge isolation 검증을 위해 `TestEdgeOperationsHTTPHandlerListsAudit`에서 `edge-a`와 `edge-b`에 각각 서로 다른 `RecordCommandRequest`/`RecordCommandEvent`/`RecordCommandResult`를 등록하여 각 엔드포인트가 서로의 감사 기록을 노출하지 않는지 엄격하게 assertion을 추가했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `/fleet/status`가 Edge별 `domain_agents`를 분리해 반환하는지 확인한다.
- `/edges/{edge_id}/operations`가 다른 Edge command를 섞지 않는지 확인한다.
- Control Plane response가 Node address/token/raw config settings를 노출하지 않는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### API-1 중간 검증
```bash
$ go test ./apps/control-plane/cmd/control-plane -run 'TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities' -count=1
ok iop/apps/control-plane/cmd/control-plane 0.006s
```
### API-2 중간 검증
```bash
$ go test ./apps/control-plane/cmd/control-plane -run 'TestEdgeOperationsHTTPHandlerListsAudit' -count=1
ok iop/apps/control-plane/cmd/control-plane 0.008s
```
### 최종 검증
```bash
$ go test ./apps/control-plane/cmd/control-plane ./apps/control-plane/internal/wire ./apps/edge/internal/service -count=1
ok iop/apps/control-plane/cmd/control-plane 0.017s
ok iop/apps/control-plane/internal/wire 1.483s
ok iop/apps/edge/internal/service 0.018s
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless blocked |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
| 검증 결과 | Implementing agent | Paste actual output |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제:
- Nit: `apps/control-plane/cmd/control-plane/main_test.go:764` blank line has trailing whitespace. `gofmt -w apps/control-plane/cmd/control-plane/main_test.go` removes it.
- Nit: `apps/control-plane/cmd/control-plane/main_test.go:787` blank line has trailing whitespace. `gofmt -w apps/control-plane/cmd/control-plane/main_test.go` removes it.
- 다음 단계: PASS이므로 `complete.log`를 작성하고 active task 디렉터리를 archive로 이동한다.

View file

@ -0,0 +1,37 @@
# Complete - m-multi-edge-operations/01_history_agent_contract
## 완료 일시
2026-06-04
## 요약
History agent state backend HTTP contract review loop 1 completed with PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | Edge별 domain agent summary와 command audit isolation focused tests가 추가되었고 검증이 통과했다. |
## 구현/정리 내용
- `/fleet/status` handler test가 두 connected Edge의 서로 다른 `domain_agents` summary를 분리해 반환하는지 확인하도록 보강되었다.
- `/edges/{edge_id}/operations` handler test가 `edge-a`와 `edge-b` command audit을 서로 섞지 않는지 양방향으로 확인하도록 보강되었다.
- `edge-offline` disconnect fixture가 `MarkConnected` 반환 token을 사용하도록 안정화되었다.
## 최종 검증
- `go test ./apps/control-plane/cmd/control-plane -run 'TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities' -count=1` - PASS; `ok iop/apps/control-plane/cmd/control-plane 0.012s`.
- `go test ./apps/control-plane/cmd/control-plane -run 'TestEdgeOperationsHTTPHandlerListsAudit' -count=1` - PASS; `ok iop/apps/control-plane/cmd/control-plane 0.008s`.
- `go test ./apps/control-plane/cmd/control-plane ./apps/control-plane/internal/wire ./apps/edge/internal/service -count=1` - PASS; all three packages passed.
- `git diff --check -- apps/control-plane/cmd/control-plane/main_test.go` - FAIL; Nit-only trailing whitespace reported at lines 764 and 787.
## 잔여 Nit
- `apps/control-plane/cmd/control-plane/main_test.go:764` blank line has trailing whitespace.
- `apps/control-plane/cmd/control-plane/main_test.go:787` blank line has trailing whitespace.
## 후속 작업
- 없음

View file

@ -0,0 +1,144 @@
<!-- task=m-multi-edge-operations/01_history_agent_contract plan=0 tag=API -->
# Plan - History Agent State Backend Contract
## 이 파일을 읽는 구현 에이전트에게
구현 완료는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션까지 실제 구현 내용과 검증 출력으로 채운 뒤에만 성립한다. 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 붙인다. active 파일은 그대로 두고 리뷰 준비 상태를 보고하며, `complete.log` 작성, `.log` rename, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 충돌로 막히면 review stub의 `사용자 리뷰 요청`에 정확한 근거를 남기고 멈춘다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
`history-agent-state`는 Edge별 실행/명령 결과와 domain agent summary가 섞이지 않는 운영면을 요구한다. Control Plane에는 이미 `/fleet/status`, `/edges/{id}/operations`, command relay가 있지만, Edge별로 서로 다른 domain agent 상태와 command audit이 분리되는 HTTP 계약 검증이 약하다. 이 선행 작업은 client 작업이 의존할 backend JSON 계약을 작고 결정적으로 고정한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 기준이며, 실제 `USER_REVIEW.md` 작성 여부 판단은 code-review가 소유한다.
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
- `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md`
- `agent-test/local/rules.md`
- `agent-test/local/control-plane-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/client-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-ops/rules/project/domain/control-plane/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/client/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `apps/control-plane/cmd/control-plane/main.go`
- `apps/control-plane/cmd/control-plane/main_test.go`
- `apps/control-plane/internal/wire/edge_registry.go`
- `apps/control-plane/internal/wire/edge_server.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
- `proto/iop/control.proto`
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md` present/read. Matching profiles read: `control-plane-smoke`, `edge-smoke`, `client-smoke`, `platform-common-smoke`.
- 적용 명령: control-plane 변경은 `go test ./apps/control-plane/...`; Edge wire/registry lifecycle가 바뀌면 `make test-control-plane-edge-wire`; edge service가 바뀌면 `go test ./apps/edge/...`.
- 이 subtask는 backend HTTP contract/test 고정이므로 primary verification은 focused Go tests다. Proto 원본 변경은 계획 범위 밖이며, proto를 바꾸면 별도 `make proto proto-dart`가 필요하다.
- `<확인 필요>` 값 없음.
### 테스트 커버리지 공백
- `/fleet/status`는 `TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities`에서 online/degraded/offline과 capability를 확인하지만, 두 connected Edge의 서로 다른 `domain_agents` 구분은 고정하지 않는다.
- `/edges/{id}/operations`는 `TestEdgeOperationsHTTPHandlerListsAudit`에서 단일 Edge audit만 확인한다. 다른 Edge command가 섞이지 않는지, 각 Edge 조회가 자기 command만 반환하는지 고정하지 않는다.
- Edge service는 `TestServiceCapabilitiesAndDomainAgents`와 `TestServiceExecuteCommand...`로 domain agent summary와 command event를 확인한다. Control Plane HTTP aggregation과 Edge별 isolation을 함께 검증하지는 않는다.
### 심볼 참조
- none. Rename/remove 없음.
### 분할 판단
- split decision policy 적용 완료.
- shared task group: `agent-task/m-multi-edge-operations`.
- `01_history_agent_contract`: backend HTTP/registry contract 검증. 선행 없음.
- `02+01_history_agent_client`: Flutter 운영 화면 구현/검증. `01_history_agent_contract`의 `complete.log`가 필요하다.
- split 이유: backend JSON contract와 Flutter UI state는 ownership, verification command, failure profile이 다르다.
### 범위 결정 근거
- 포함: `apps/control-plane/cmd/control-plane/main_test.go`; 테스트 실패가 실제 contract gap을 드러낼 때만 `apps/control-plane/cmd/control-plane/main.go`.
- 포함 가능: Edge domain agent summary 생성에 gap이 있으면 `apps/edge/internal/service/service.go`와 `apps/edge/internal/service/service_test.go`.
- 제외: proto schema 변경, durable audit/history 저장소, OTO artifact/log 원본 저장, Flutter UI 변경, docs/README 갱신.
- Control Plane은 Edge-owned summary만 관찰한다. Node token/address/config raw settings를 HTTP response에 추가하지 않는다.
### 빌드 등급
- build lane/grade: `local-G05`. Go HTTP contract와 focused tests로 검증 가능한 bounded backend 작업이다.
## 구현 체크리스트
- [ ] `/fleet/status`가 두 connected Edge의 서로 다른 domain agent capability/status/command summary를 구분해 반환하는 focused test를 추가한다.
- [ ] `/edges/{edge_id}/operations`가 Edge별 command audit을 섞지 않는 focused test를 추가한다.
- [ ] 필요 시 backend mapper/registry 구현을 테스트 기대에 맞게 보강하되 Control Plane이 Edge-owned 원본 데이터를 소유하지 않도록 유지한다.
- [ ] `go test ./apps/control-plane/cmd/control-plane ./apps/control-plane/internal/wire ./apps/edge/internal/service -count=1`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [API-1] Fleet Status Domain Agent Isolation
문제: `apps/control-plane/cmd/control-plane/main.go:501`의 `/fleet/status`는 registry snapshot별로 `fleetEdgeViewFromState`를 호출하고, `main.go:690`에서 live status의 domain agents를 view에 복사한다. 기존 `apps/control-plane/cmd/control-plane/main_test.go:675`는 capability와 health만 고정하고 두 Edge의 서로 다른 `domain_agents` 구분을 검증하지 않는다.
해결 방법: `TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities`에 connected Edge를 하나 더 추가하거나 subtest를 추가한다. `requestStatus`는 Edge ID별로 서로 다른 `EdgeDomainAgentSummary`를 반환하게 한다. JSON decode 후 edge map에서 `edge-online`과 새 Edge의 `DomainAgents`가 각각 기대 `agent_kind`, `lifecycle_state`, `active_command_id`, `summary`를 갖는지 확인한다. 기존 node internals leak guard는 유지한다.
수정 파일 및 체크리스트:
- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: fleet status test fixture에 distinct domain agent summary 추가.
- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: Edge별 `DomainAgents` assertion 추가.
- [ ] `apps/control-plane/cmd/control-plane/main.go`: 테스트가 드러낸 mapper gap이 있을 때만 수정.
테스트 작성: 작성. `TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities` 또는 새 focused test에서 Edge별 domain agent summary를 검증한다.
중간 검증:
```bash
go test ./apps/control-plane/cmd/control-plane -run 'TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities' -count=1
```
### [API-2] Edge Operations Audit Isolation
문제: `apps/control-plane/cmd/control-plane/main.go:450`의 `/edges/{id}/operations`는 `registry.Commands(edgeID)`를 반환하고, `apps/control-plane/internal/wire/edge_registry.go:328`은 Edge ID로 command records를 필터링한다. 기존 `apps/control-plane/cmd/control-plane/main_test.go:643`은 edge-a command 하나만 seed해 cross-edge 섞임을 검증하지 않는다.
해결 방법: `TestEdgeOperationsHTTPHandlerListsAudit`에 `edge-b` command request/event/result를 추가한다. `/edges/edge-a/operations` 응답에 edge-b command가 없는지 확인하고, `/edges/edge-b/operations`도 조회해 edge-b command만 반환되는지 확인한다.
수정 파일 및 체크리스트:
- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: edge-b command record fixture 추가.
- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: edge-a/edge-b 양방향 operations isolation assertion 추가.
- [ ] `apps/control-plane/internal/wire/edge_registry.go`: 테스트가 기존 filtering gap을 드러낼 때만 수정.
테스트 작성: 작성. HTTP handler 레벨에서 Edge별 operations isolation을 검증한다.
중간 검증:
```bash
go test ./apps/control-plane/cmd/control-plane -run 'TestEdgeOperationsHTTPHandlerListsAudit' -count=1
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/control-plane/cmd/control-plane/main_test.go` | API-1, API-2 |
| `apps/control-plane/cmd/control-plane/main.go` | API-1, API-2 only if tests reveal mapper gap |
| `apps/control-plane/internal/wire/edge_registry.go` | API-2 only if filtering gap appears |
| `apps/edge/internal/service/service.go` | API-1 only if domain agent source summary gap appears |
| `apps/edge/internal/service/service_test.go` | API-1 only if edge service behavior changes |
## 최종 검증
```bash
go test ./apps/control-plane/cmd/control-plane ./apps/control-plane/internal/wire ./apps/edge/internal/service -count=1
```
기대 결과: all packages pass. Go test cache output은 허용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,188 @@
<!-- task=m-multi-edge-operations/02+01_history_agent_client plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a 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-04
task=m-multi-edge-operations/02+01_history_agent_client, plan=0, tag=API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md`
- Task ids:
- `history-agent-state`: Edge별 실행/명령 결과와 domain agent capability/status/command summary가 운영 화면에서 구분된다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-multi-edge-operations/02+01_history_agent_client/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-multi-edge-operations`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Runtime Panel Clears Stale Edge Operations | [x] |
| [API-2] Edge-Specific Runtime Fixture And Assertions | [x] |
## 구현 체크리스트
- [x] `01_history_agent_contract` predecessor complete.log가 있는지 확인한다.
- [x] Runtime panel이 Edge 선택 변경과 느린 fetch 중 stale operations/domain agent state를 섞지 않도록 보강한다.
- [x] Fake repository가 Edge별로 서로 다른 domain agents, active command id, operations history, command target Edge를 반환/기록하도록 만든다.
- [x] widget test에서 Runtime panel of Edge switch 후 domain agents와 operations history가 Edge별로 구분되는지 검증한다.
- [x] `cd apps/client && flutter test test/widget_test.dart`를 실행한다.
- [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_local_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_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_history_agent_client/`를 `agent-task/archive/YYYY/MM/m-multi-edge-operations/02+01_history_agent_client/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-multi-edge-operations`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-multi-edge-operations/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.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. **Stale Operations Clear & Response Guard**:
`RuntimePanel`이 selected Edge 변경 시 기존 operations 데이터를 즉시 `null`로 비우도록 하여, 새 Edge의 데이터 로딩 중 이전 Edge의 operations 내역이 잘못 노출되는 UX 결함을 수정했습니다. 또한 비동기 응답 처리 완료 시점의 selected Edge ID와 응답 대상 Edge ID를 비교하여, 고속 Edge 전환 시 stale response가 반영되지 않도록 guard하였습니다.
2. **Edge별 Domain Agent & Operations 이력 분리**:
`FakeControlPlaneStatusRepository`에서 `edge-a`와 `edge-b`에 대해 서로 다른 `domainAgents` 상태(`READY`/`READY` vs `BUSY`/`READY`) 및 `operations` 내역(`oto-agent.sync` vs `build-deploy.deploy`)을 반환하도록 설정하고, command 호출 시 target Edge ID to `lastCommandEdgeId`로 캡처하여 검증하도록 설계했습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- predecessor `01_history_agent_contract` 완료 근거가 확인됐는지 본다.
- Runtime panel이 선택 Edge 변경 중 stale operations를 보여주지 않는지 본다.
- widget test가 alpha-only/beta-only domain agent와 operations text를 모두 검증하는지 본다.
- Client가 Edge/Node transport에 직접 붙지 않고 Control Plane repository boundary만 소비하는지 본다.
## 검증 결과
### API-1 중간 검증
```bash
$ cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'
00:00 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:00 +0: Client App opens Operations & Domain Agents panel and verifies agents,
operations history, and command triggering
00:01 +1: All tests passed!
```
### API-2 중간 검증
```bash
$ cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'
00:00 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:00 +0: Client App opens Operations & Domain Agents panel and verifies agents,
operations history, and command triggering
00:01 +1: All tests passed!
```
### 최종 검증
```bash
$ cd apps/client && flutter test test/widget_test.dart
00:00 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:01 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:02 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:03 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:04 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:04 +0: Client App basic rendering and success handshake test
00:05 +0: Client App basic rendering and success handshake test
00:05 +1: Client App connection error state test
00:05 +2: Client App opens IOP agent panel from the left rail
00:05 +3: Client App opens IOP agent panel from the left rail
00:05 +3: Client App opens Edges panel and displays Edge details
00:05 +4: Client App opens Edges panel and displays Edge details
00:05 +4: Client ... Nodes panel and displays active Nodes and configurations
00:06 +4: Client ... Nodes panel and displays active Nodes and configurations
00:06 +5: Client App opens Execution/Logs panel and displays lifecycle events
00:06 +6: Client App opens Execution/Logs panel and displays lifecycle events
00:06 +6: Client App refresh behavior when selected edge disappears
00:06 +7: Client ... agents, operations history, and command triggering
00:07 +7: Client ... agents, operations history, and command triggering
00:07 +8: Client ... agents, operations history, and command triggering
00:07 +8: Client ... or error command responses and shows error banner
00:07 +9: Client ... agent.status and agent.command without required inputs
00:08 +9: Client ... agent.status and agent.command without required inputs
00:08 +10: Client ... agent.status and agent.command without required inputs
00:08 +10: Client App mobile screen layout verification for layout and overflow
00:08 +11: Client App mobile screen layout verification for layout and overflow
00:08 +11: All tests 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.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation | Do not modify; code-review copies to `complete.log` on PASS |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless blocked |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
| 검증 결과 | Implementing agent | Paste actual output |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- 발견된 문제:
- Required: `apps/client/test/widget_test.dart:660`의 Edge Beta 전환 테스트가 `pumpAndSettle()`로 즉시 최종 상태만 확인하고, 이전 Edge의 operations가 새 Edge 로딩 중 사라지는지 검증하지 않습니다. 이 상태에서는 `_fetchOperations()`의 fetch-start clear가 제거되어도 빠른 fake response 때문에 테스트가 통과할 수 있습니다. `fetchEdgeOperations('edge-b')`를 지연/completer로 제어하는 fixture를 추가하고, Edge 전환 직후 beta response를 완료하기 전 `oto-agent.sync`와 `Synced 5 models`가 화면에서 사라졌는지 확인하세요.
- Required: `apps/client/test/widget_test.dart:666`의 Edge Beta 검증은 `BUSY`/`READY`와 beta operation presence만 확인하고, 계획에서 요구한 alpha-only domain agent command summary/operation text의 부재를 확인하지 않습니다. 전환 전 `Active Command: cmd-alpha-active`를 확인하고, 전환 후 `Active Command: cmd-alpha-active`, `oto-agent.sync`, `Synced 5 models`는 `findsNothing`, `Active Command: cmd-beta-active`, `build-deploy.deploy`, `Deploy queued for edge beta`는 표시되도록 assertion을 추가하세요.
- 다음 단계: WARN/FAIL 후속 plan/review 파일을 작성한다.

View file

@ -0,0 +1,185 @@
<!-- task=m-multi-edge-operations/02+01_history_agent_client plan=1 tag=REVIEW_API -->
# 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-04
task=m-multi-edge-operations/02+01_history_agent_client, plan=1, tag=REVIEW_API
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md`
- Task ids:
- `history-agent-state`: Edge별 실행/명령 결과와 domain agent capability/status/command summary가 운영 화면에서 구분된다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-multi-edge-operations/02+01_history_agent_client/`로 이동한다. 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] Delayed Operations Fetch Assertion | [x] |
| [REVIEW_API-2] Edge-Specific Command Summary Negative Assertions | [x] |
## 구현 체크리스트
- [x] Fake repository에 Edge별 operations 응답을 지연/completer로 제어할 수 있는 테스트 helper를 추가한다.
- [x] Runtime panel widget test가 Edge Beta operations 응답 완료 전 alpha operation text가 사라지는지 검증한다.
- [x] Runtime panel widget test가 Edge 전환 전/후 alpha-only와 beta-only active command summary 및 operations text의 presence/absence를 모두 검증한다.
- [x] `cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'`를 실행한다.
- [x] `cd apps/client && flutter test test/widget_test.dart`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G07.md`를 `code_review_local_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G07.md`를 `plan_local_G07_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_history_agent_client/`를 `agent-task/archive/2026/06/m-multi-edge-operations/02+01_history_agent_client/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-multi-edge-operations`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-multi-edge-operations/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.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. **지연 및 과도기 상태 검증 (Completer 기반)**:
`FakeControlPlaneStatusRepository` 내부에 `Completer<EdgeOperationsResponseView>` 필드를 도입하여 Edge Beta의 operations 데이터 fetching 시점을 테스트 코드에서 완전히 제어할 수 있도록 하였습니다. 이를 통해 `Edge Beta`를 선택한 프레임 직후(fetch 완료 전 과도기 상태)에서 로딩 인디케이터(`CircularProgressIndicator`) 노출 여부와 이전 Edge Alpha의 이력이 즉각적으로 소거(clear)되었는지를 단언(assertion)할 수 있게 검증 환경을 강화했습니다.
2. **Negative Assertions (비존재 검증) 보강**:
단순히 최종 상태에서 필요한 위젯이 노출되는지만 체크하는 기존의 취약성을 개선하여, Edge Alpha 상태에서는 Alpha 관련 항목만 보이고 Beta 관련 항목이 없는지, 그리고 Edge Beta 상태로 전환된 후에는 Alpha 관련 데이터(`cmd-alpha-active`, `oto-agent.sync`, `Synced 5 models`)가 화면상에서 완벽히 사라졌는지를 명시적으로 검증하는 `findsNothing` 단언을 앞뒤로 촘촘하게 추가하여 검증 공백을 메웠습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- edge-b operations fetch가 test에서 실제로 지연되고, beta response 완료 전 alpha operation text가 사라지는지 본다.
- Edge 전환 전/후 `Active Command: cmd-alpha-active`와 `Active Command: cmd-beta-active`의 presence/absence가 모두 검증되는지 본다.
- beta operation 표시 검증 and alpha operation 부재 검증이 같은 Runtime panel test 안에서 연결되어 있는지 본다.
- 후속 변경이 `apps/client/test/widget_test.dart` 중심으로 좁게 유지됐는지 본다.
## 검증 결과
### REVIEW_API-1 중간 검증
```bash
$ cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'
00:00 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:00 +0: Client App opens Operations & Domain Agents panel and verifies agents,
operations history, and command triggering
00:01 +1: All tests passed!
```
### REVIEW_API-2 중간 검증
```bash
$ cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'
00:00 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:00 +0: Client App opens Operations & Domain Agents panel and verifies agents,
operations history, and command triggering
00:01 +1: All tests passed!
```
### 최종 검증
```bash
$ cd apps/client && flutter test test/widget_test.dart
00:00 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:01 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:02 +0: loading /config/workspace/iop/apps/client/test/widget_test.dart
00:02 +0: Client App basic rendering and success handshake test
00:03 +0: Client App basic rendering and success handshake test
00:03 +1: Client App connection error state test
00:03 +2: Client App connection error state test
00:03 +2: Client App opens IOP agent panel from the left rail
00:03 +3: Client App opens IOP agent panel from the left rail
00:03 +3: Client App opens Edges panel and displays Edge details
00:04 +3: Client App opens Edges panel and displays Edge details
00:04 +4: Client App opens Edges panel and displays Edge details
00:04 +4: Client ... Nodes panel and displays active Nodes and configurations
00:04 +5: Client App opens Execution/Logs panel and displays lifecycle events
00:04 +6: Client App opens Execution/Logs panel and displays lifecycle events
00:04 +6: Client App refresh behavior when selected edge disappears
00:04 +7: Client App refresh behavior when selected edge disappears
00:04 +7: Client ... agents, operations history, and command triggering
00:05 +7: Client ... agents, operations history, and command triggering
00:05 +8: Client ... or error command responses and shows error banner
00:05 +9: Client ... or error command responses and shows error banner
00:05 +9: Client ... agent.status and agent.command without required inputs
00:05 +10: Client App mobile screen layout verification for layout and overflow
00:06 +10: Client App mobile screen layout verification for layout and overflow
00:06 +11: Client App mobile screen layout verification for layout and overflow
00:06 +11: All tests 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.
Sections and their ownership:
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation | Do not modify; code-review copies to `complete.log` on PASS |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` -> `[x]` only |
| 구현 체크리스트 | Implementing agent | Check `[ ]` -> `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholders |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless blocked |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
| 검증 결과 | Implementing agent | Paste actual output |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 종결 절차를 수행한다.

View file

@ -0,0 +1,43 @@
# Complete - m-multi-edge-operations/02+01_history_agent_client
## 완료 일시
2026-06-04
## 요약
History agent state client view review loop 2 completed with PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | Edge 전환 중 stale operations 제거와 alpha-only command/operation 부재를 테스트가 직접 검증하지 못해 follow-up이 생성되었다. |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | Runtime panel widget test가 delayed edge-b operations fetch, alpha/beta active command summary, operations presence/absence를 모두 검증하도록 보강되었다. |
## 구현/정리 내용
- Runtime panel operations fetch가 Edge 선택 변경 시 이전 operations를 즉시 비우고 stale async response를 무시하도록 유지되었다.
- `FakeControlPlaneStatusRepository`가 Edge별 domain agent state, active command id, operations history, command dispatch target Edge를 분리해 반환/기록한다.
- Runtime panel widget test가 Edge Beta operations fetch 완료 전 Alpha operations text가 사라지는지 검증하고, 전환 전후 alpha-only/beta-only command summary와 operations text의 presence/absence를 모두 확인한다.
## 최종 검증
- `cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'` - PASS; `All tests passed!`.
- `cd apps/client && flutter test test/widget_test.dart` - PASS; `All tests passed!`.
- `git diff --check -- apps/client/test/widget_test.dart apps/client/lib/control_plane_status_widgets.dart` - PASS; whitespace errors 없음.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md`
- Completed task ids:
- `history-agent-state`: PASS; evidence=`plan_local_G07_1.log`, `code_review_local_G07_1.log`; verification=`cd apps/client && flutter test test/widget_test.dart`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,159 @@
<!-- task=m-multi-edge-operations/02+01_history_agent_client plan=0 tag=API -->
# Plan - History Agent State Client View
## 이 파일을 읽는 구현 에이전트에게
구현 완료는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션까지 실제 구현 내용과 검증 출력으로 채운 뒤에만 성립한다. 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 붙인다. active 파일은 그대로 두고 리뷰 준비 상태를 보고하며, `complete.log` 작성, `.log` rename, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 충돌로 막히면 review stub의 `사용자 리뷰 요청`에 정확한 근거를 남기고 멈춘다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
Runtime panel은 domain agent summary와 operations history를 운영자가 보는 1차 화면이다. 현재 test fixture는 모든 Edge에 같은 domain agents와 operations를 돌려주므로, Edge 선택 변경 시 history/agent state가 실제로 분리 표시되는지 검증하지 못한다. 이 작업은 backend 계약 subtask가 통과한 뒤 Flutter 화면과 repository fixture를 Edge별로 분리해 `history-agent-state` 완료 근거를 만든다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 기준이며, 실제 `USER_REVIEW.md` 작성 여부 판단은 code-review가 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md`
- Task ids:
- `history-agent-state`: Edge별 실행/명령 결과와 domain agent capability/status/command summary가 운영 화면에서 구분된다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-roadmap/current.md`
- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
- `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md`
- `agent-test/local/rules.md`
- `agent-test/local/client-smoke.md`
- `agent-test/local/control-plane-smoke.md`
- `agent-test/local/edge-smoke.md`
- `agent-test/local/platform-common-smoke.md`
- `agent-ops/rules/project/domain/client/rules.md`
- `agent-ops/rules/project/domain/control-plane/rules.md`
- `agent-ops/rules/project/domain/edge/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `apps/client/lib/control_plane_status_client.dart`
- `apps/client/lib/control_plane_status_widgets.dart`
- `apps/client/lib/main.dart`
- `apps/client/test/widget_test.dart`
- `apps/control-plane/cmd/control-plane/main.go`
- `apps/control-plane/cmd/control-plane/main_test.go`
- `apps/control-plane/internal/wire/edge_registry.go`
- `apps/control-plane/internal/wire/edge_server.go`
- `apps/edge/internal/service/service.go`
- `apps/edge/internal/service/service_test.go`
- `proto/iop/control.proto`
### 테스트 환경 규칙
- test_env: `local`.
- `agent-test/local/rules.md` present/read. Matching profiles read: `client-smoke`, `control-plane-smoke`, `edge-smoke`, `platform-common-smoke`.
- 적용 명령: client 변경은 최소 `cd apps/client && flutter test`; analyzer 영향이 있으면 `cd apps/client && flutter analyze --no-fatal-infos`.
- 현재 작업 컨테이너에서 `cd apps/client && flutter test test/widget_test.dart`가 이미 성공했다. 원격 runtime evidence는 web/dev-server 또는 Docker compose 변경이 있을 때만 필요하다.
- `<확인 필요>` 값 없음.
### 테스트 커버리지 공백
- `apps/client/test/widget_test.dart:199`의 `fetchFleetStatus`는 모든 Edge에 동일한 domain agents를 반환한다.
- `apps/client/test/widget_test.dart:247`의 `fetchEdgeOperations`는 어떤 Edge ID에도 같은 operation을 반환한다.
- `apps/client/test/widget_test.dart:553`의 Runtime panel test는 기본 선택 Edge 하나만 검증한다. Edge 선택 변경 후 domain agent summary, active command, operations history가 바뀌는지 확인하지 않는다.
- `apps/client/lib/control_plane_status_widgets.dart:856`의 `_fetchOperations`는 새 Edge fetch 시작 시 기존 `_operations`를 유지한다. 느린 요청이나 선택 변경 중 stale operation이 보일 수 있는 위험이 있다.
### 심볼 참조
- none. Rename/remove 없음.
### 분할 판단
- split decision policy 적용 완료.
- shared task group: `agent-task/m-multi-edge-operations`.
- predecessor: `01_history_agent_contract` is required by directory name `02+01_history_agent_client`; active predecessor exists but `complete.log` is missing. Implementation must wait until `agent-task/m-multi-edge-operations/01_history_agent_contract/complete.log` or archived equivalent exists.
- 이 subtask는 backend contract 고정 후 client UI/state/test를 다룬다. Backend 수정은 predecessor 범위로 남긴다.
### 범위 결정 근거
- 포함: `apps/client/lib/control_plane_status_widgets.dart`, `apps/client/test/widget_test.dart`.
- 포함 가능: repository model parsing gap이 있으면 `apps/client/lib/control_plane_status_client.dart`; selected-edge state propagation gap이 있으면 `apps/client/lib/main.dart`.
- 제외: Go backend contract 수정, proto 변경, durable audit/history 저장, domain-agent artifact/log 상세 표시, NomadCode-specific shell UX.
- UI는 Control Plane HTTP 표면을 소비하며 Edge/Node TCP transport에 직접 붙지 않는다.
### 빌드 등급
- build lane/grade: `local-G06`. Flutter state/UI test가 핵심이고 predecessor contract에 의존하지만, bounded widget tests로 검증 가능하다.
## 구현 체크리스트
- [ ] `01_history_agent_contract` predecessor complete.log가 있는지 확인한다.
- [ ] Runtime panel이 Edge 선택 변경과 느린 fetch 중 stale operations/domain agent state를 섞지 않도록 보강한다.
- [ ] Fake repository가 Edge별로 서로 다른 domain agents, active command id, operations history, command target Edge를 반환/기록하도록 만든다.
- [ ] widget test에서 Runtime panel의 Edge 선택 변경 후 domain agents와 operations history가 Edge별로 구분되는지 검증한다.
- [ ] `cd apps/client && flutter test test/widget_test.dart`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 의존 관계 및 구현 순서
이 subtask 디렉터리명은 `02+01_history_agent_client`다. predecessor index `01`은 `agent-task/m-multi-edge-operations/01_history_agent_contract`이며, 구현 시작 전 active 또는 archived `complete.log`가 필요하다. 현재 작성 시점에는 predecessor complete.log가 없다.
### [API-1] Runtime Panel Clears Stale Edge Operations
문제: `apps/client/lib/control_plane_status_widgets.dart:849`는 selected Edge가 바뀌면 `_fetchOperations()`를 호출하지만, `control_plane_status_widgets.dart:856`은 fetch 시작 시 기존 `_operations`를 지우지 않는다. `_buildOperationsHistory`는 `control_plane_status_widgets.dart:1379`에서 `_operations`가 nil일 때만 loading을 보여주므로, 새 Edge 로딩 중 이전 Edge operations가 보일 수 있다.
해결 방법: `_RuntimePanelState`에 `_operationsEdgeId` 같은 owner field를 두거나, `_fetchOperations` 시작 시 `_operations = null`로 초기화한다. async completion 시 `mounted && widget.selectedEdgeId == edgeId`를 확인한 뒤에만 `_operations`를 set한다. 이 변경은 selected Edge가 빠르게 바뀌어도 stale result가 화면에 남지 않게 한다.
수정 파일 및 체크리스트:
- [ ] `apps/client/lib/control_plane_status_widgets.dart`: operations owner Edge ID tracking 또는 fetch-start clear 추가.
- [ ] `apps/client/lib/control_plane_status_widgets.dart`: async response guard 추가.
테스트 작성: widget test에서 Edge switch 후 이전 Edge operation 텍스트가 사라지는 assertion으로 검증한다.
중간 검증:
```bash
cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'
```
### [API-2] Edge-Specific Runtime Fixture And Assertions
문제: `apps/client/test/widget_test.dart:199`은 fleet status fixture를 `mockEdges.map`으로 만들며 모든 Edge에 같은 domain agents를 붙인다. `apps/client/test/widget_test.dart:247`은 edgeId를 받지만 operation 내용은 항상 `oto-agent.sync`/`Synced 5 models`다. 따라서 `history-agent-state`의 Edge별 구분 요구를 테스트가 보장하지 않는다.
해결 방법: `FakeControlPlaneStatusRepository`에 Edge별 fixture helpers를 추가한다. 예: edge-a는 `oto-agent READY`, `build-deploy READY`, `cmd-alpha-active`, `oto-agent.sync`, `Synced 5 models`; edge-b는 `oto-agent BUSY` 또는 `ERROR`, `cmd-beta-active`, `build-deploy.deploy`, `Deploy queued for edge beta`. Runtime panel test에서 dropdown으로 edge-b를 선택하고 alpha-only texts가 사라지고 beta-only texts가 나타나는지 검증한다. `sendEdgeCommand`에는 `lastCommandEdgeId`를 기록해 선택된 Edge로 command가 나가는지도 확인한다.
수정 파일 및 체크리스트:
- [ ] `apps/client/test/widget_test.dart`: Edge별 domain agent fixture 분리.
- [ ] `apps/client/test/widget_test.dart`: Edge별 operations fixture 분리.
- [ ] `apps/client/test/widget_test.dart`: Runtime panel Edge switch assertion 추가.
- [ ] `apps/client/test/widget_test.dart`: selected Edge command dispatch assertion 추가.
테스트 작성: 작성. 기존 Runtime panel widget test를 확장하거나 새 focused widget test를 추가한다.
중간 검증:
```bash
cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/client/lib/control_plane_status_widgets.dart` | API-1 |
| `apps/client/test/widget_test.dart` | API-1, API-2 |
| `apps/client/lib/control_plane_status_client.dart` | API-2 only if parsing/model gap appears |
| `apps/client/lib/main.dart` | API-1 only if selected-edge state propagation gap appears |
## 최종 검증
```bash
cd apps/client && flutter test test/widget_test.dart
```
기대 결과: all tests passed. Flutter widget test cache output is not applicable; record actual command output.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,97 @@
<!-- task=m-multi-edge-operations/02+01_history_agent_client plan=1 tag=REVIEW_API -->
# Plan - Runtime Panel Edge Switch Test Hardening
## 이 파일을 읽는 구현 에이전트에게
구현 완료는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션까지 실제 구현 내용과 검증 출력으로 채운 뒤에만 성립한다. 검증 명령을 실행하고 실제 stdout/stderr를 review stub에 붙인다. active 파일은 그대로 두고 리뷰 준비 상태를 보고하며, `complete.log` 작성, `.log` rename, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 충돌로 막히면 review stub의 `사용자 리뷰 요청`에 정확한 근거를 남기고 멈춘다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 증거 공백은 사용자 리뷰 요청이 아니다.
## 배경
첫 구현 루프는 Runtime panel의 Edge별 domain agent와 operations fixture를 분리하고 `flutter test test/widget_test.dart`를 통과했다. 그러나 리뷰에서 widget test가 빠른 fake response의 최종 상태만 확인해, 새 Edge 로딩 중 이전 Edge operation이 남는 회귀와 alpha-only command summary가 계속 보이는 회귀를 잡지 못하는 것이 확인됐다. 이 후속 작업은 구현 코드를 넓히지 않고 해당 검증 공백을 닫는다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 기준이며, 실제 `USER_REVIEW.md` 작성 여부 판단은 code-review가 소유한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/multi-edge-operations.md`
- Task ids:
- `history-agent-state`: Edge별 실행/명령 결과와 domain agent capability/status/command summary가 운영 화면에서 구분된다.
- Completion mode: check-on-pass
## 리뷰 결과 요약
- Required: Edge Beta 전환 테스트가 빠른 fake response 최종 상태만 확인해서 fetch-start clear 회귀를 잡지 못한다.
- Required: Edge 전환 후 alpha-only active command/operation text가 사라지는지 검증하지 않는다.
## 범위 결정 근거
- 포함: `apps/client/test/widget_test.dart`.
- 포함 가능: stale-loading assertion을 통과시키기 위해 꼭 필요한 경우에만 `apps/client/lib/control_plane_status_widgets.dart`.
- 제외: backend contract, proto, Control Plane/Edge Go 코드, roadmap 문서 수정.
- 후속은 검증 공백 보강이 목적이므로 UI 구조 변경이나 새 feature를 추가하지 않는다.
## 빌드 등급
- build lane/grade: `local-G07`. 실패 원인은 명시적이고 deterministic한 widget test coverage 공백이며, bounded Flutter test로 검증 가능하다.
## 구현 체크리스트
- [ ] Fake repository에 Edge별 operations 응답을 지연/completer로 제어할 수 있는 테스트 helper를 추가한다.
- [ ] Runtime panel widget test가 Edge Beta operations 응답 완료 전 alpha operation text가 사라지는지 검증한다.
- [ ] Runtime panel widget test가 Edge 전환 전/후 alpha-only와 beta-only active command summary 및 operations text의 presence/absence를 모두 검증한다.
- [ ] `cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'`를 실행한다.
- [ ] `cd apps/client && flutter test test/widget_test.dart`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_API-1] Delayed Operations Fetch Assertion
문제: `apps/client/test/widget_test.dart`의 Runtime panel test는 Edge Beta 선택 뒤 `pumpAndSettle()`로 최종 상태만 본다. fake `fetchEdgeOperations('edge-b')`가 즉시 완료되므로 `_fetchOperations()`의 `_operations = null`이 제거되어도 테스트가 통과할 수 있다.
해결 방법: `FakeControlPlaneStatusRepository`에 edge-b operations future를 test에서 지연시킬 수 있는 helper를 둔다. 예를 들어 `Completer<EdgeOperationsResponseView>? delayedEdgeBOperations` 또는 edgeId별 delay map을 사용한다. 테스트에서는 Edge Beta 선택 직후 beta operations future를 완료하기 전에 한 프레임을 pump하고, alpha operation인 `oto-agent.sync`와 `Synced 5 models`가 `findsNothing`인지 확인한다. 그 뒤 beta future를 완료하고 beta operation이 표시되는지 확인한다.
수정 파일 및 체크리스트:
- [ ] `apps/client/test/widget_test.dart`: edge-b operations fetch를 지연 제어하는 fixture helper 추가.
- [ ] `apps/client/test/widget_test.dart`: Edge Beta 선택 직후 alpha operation absence assertion 추가.
중간 검증:
```bash
cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'
```
### [REVIEW_API-2] Edge-Specific Command Summary Negative Assertions
문제: 현재 test는 Edge Beta 전환 후 `BUSY`, `READY`, `build-deploy.deploy`가 보이는지만 확인한다. `Active Command: cmd-alpha-active`, `oto-agent.sync`, `Synced 5 models`가 남아 있어도 테스트가 명확히 실패하지 않는다.
해결 방법: Edge Alpha 상태에서 `Active Command: cmd-alpha-active`, `oto-agent.sync`, `Synced 5 models`가 표시되는지 먼저 확인한다. Edge Beta 전환 후에는 `Active Command: cmd-beta-active`, `build-deploy.deploy`, `Deploy queued for edge beta`가 표시되고, alpha-only texts인 `Active Command: cmd-alpha-active`, `oto-agent.sync`, `Synced 5 models`가 모두 `findsNothing`인지 확인한다.
수정 파일 및 체크리스트:
- [ ] `apps/client/test/widget_test.dart`: alpha active command summary presence assertion 추가.
- [ ] `apps/client/test/widget_test.dart`: beta active command summary presence assertion 추가.
- [ ] `apps/client/test/widget_test.dart`: alpha-only active command/operation absence assertion 추가.
중간 검증:
```bash
cd apps/client && flutter test test/widget_test.dart -r expanded --plain-name 'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering'
```
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/client/test/widget_test.dart` | REVIEW_API-1, REVIEW_API-2 |
| `apps/client/lib/control_plane_status_widgets.dart` | REVIEW_API-1 only if assertion exposes an implementation gap |
## 최종 검증
```bash
cd apps/client && flutter test test/widget_test.dart
```
기대 결과: all tests passed. 검증 결과에는 실제 stdout/stderr를 기록한다.

View file

@ -859,17 +859,18 @@ class _RuntimePanelState extends State<RuntimePanel> {
setState(() {
_isLoading = true;
_error = null;
_operations = null;
});
try {
final ops = await widget.statusRepository.fetchEdgeOperations(edgeId);
if (mounted) {
if (mounted && widget.selectedEdgeId == edgeId) {
setState(() {
_operations = ops;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
if (mounted && widget.selectedEdgeId == edgeId) {
setState(() {
_error = e.toString();
_isLoading = false;

View file

@ -71,6 +71,8 @@ class FakeControlPlaneStatusRepository implements ControlPlaneStatusRepository {
String? lastCommandOperation;
String? lastCommandTargetSelector;
Map<String, String>? lastCommandParameters;
String? lastCommandEdgeId;
Completer<EdgeOperationsResponseView>? edgeBOperationsCompleter;
List<EdgeRegistryView> mockEdges = [
EdgeRegistryView(
@ -125,33 +127,58 @@ class FakeControlPlaneStatusRepository implements ControlPlaneStatusRepository {
label: 'CPU-only',
connected: false,
config: NodeConfigSummaryView(
adapters: [
AdapterSummaryView(type: 'python-cli', enabled: true),
],
adapters: [AdapterSummaryView(type: 'python-cli', enabled: true)],
concurrency: 2,
),
),
],
capabilities: [
EdgeCapabilitySummaryView(kind: 'Serving', available: true, status: 'ready', summary: 'Active'),
EdgeCapabilitySummaryView(kind: 'Automation', available: true, status: 'ready', summary: 'Active'),
],
domainAgents: [
EdgeDomainAgentSummaryView(
agentKind: 'oto-agent',
EdgeCapabilitySummaryView(
kind: 'Serving',
available: true,
lifecycleState: 'ready',
activeCommandId: '',
status: 'ready',
summary: 'Active',
),
EdgeDomainAgentSummaryView(
agentKind: 'build-deploy',
EdgeCapabilitySummaryView(
kind: 'Automation',
available: true,
lifecycleState: 'ready',
activeCommandId: '',
summary: 'Idle',
status: 'ready',
summary: 'Active',
),
],
domainAgents: edgeId == 'edge-a'
? [
EdgeDomainAgentSummaryView(
agentKind: 'oto-agent',
available: true,
lifecycleState: 'ready',
activeCommandId: 'cmd-alpha-active',
summary: 'Active',
),
EdgeDomainAgentSummaryView(
agentKind: 'build-deploy',
available: true,
lifecycleState: 'ready',
activeCommandId: '',
summary: 'Idle',
),
]
: [
EdgeDomainAgentSummaryView(
agentKind: 'oto-agent',
available: true,
lifecycleState: 'busy',
activeCommandId: 'cmd-beta-active',
summary: 'Processing',
),
EdgeDomainAgentSummaryView(
agentKind: 'build-deploy',
available: true,
lifecycleState: 'ready',
activeCommandId: '',
summary: 'Idle',
),
],
metadata: {'region': 'us-west'},
error: '',
);
@ -191,53 +218,103 @@ class FakeControlPlaneStatusRepository implements ControlPlaneStatusRepository {
Future<FleetStatusResponseView> fetchFleetStatus() async {
return FleetStatusResponseView(
generatedAt: DateTime.now(),
edges: mockEdges.map((e) => FleetEdgeView(
edgeId: e.edgeId,
edgeName: e.edgeName,
version: e.version,
protocol: e.protocol,
connected: e.connected,
health: e.connected ? 'healthy' : 'unhealthy',
lastSeen: e.lastSeen,
nodeCount: 2,
capabilities: e.capabilities.map((c) => EdgeCapabilitySummaryView(kind: c, available: true, status: 'ready', summary: '')).toList(),
domainAgents: [
EdgeDomainAgentSummaryView(
agentKind: 'oto-agent',
available: true,
lifecycleState: 'ready',
activeCommandId: '',
summary: 'Active',
),
EdgeDomainAgentSummaryView(
agentKind: 'build-deploy',
available: true,
lifecycleState: 'ready',
activeCommandId: '',
summary: 'Idle',
),
],
error: '',
)).toList(),
edges: mockEdges
.map(
(e) => FleetEdgeView(
edgeId: e.edgeId,
edgeName: e.edgeName,
version: e.version,
protocol: e.protocol,
connected: e.connected,
health: e.connected ? 'healthy' : 'unhealthy',
lastSeen: e.lastSeen,
nodeCount: 2,
capabilities: e.capabilities
.map(
(c) => EdgeCapabilitySummaryView(
kind: c,
available: true,
status: 'ready',
summary: '',
),
)
.toList(),
domainAgents: e.edgeId == 'edge-a'
? [
EdgeDomainAgentSummaryView(
agentKind: 'oto-agent',
available: true,
lifecycleState: 'ready',
activeCommandId: 'cmd-alpha-active',
summary: 'Active',
),
EdgeDomainAgentSummaryView(
agentKind: 'build-deploy',
available: true,
lifecycleState: 'ready',
activeCommandId: '',
summary: 'Idle',
),
]
: [
EdgeDomainAgentSummaryView(
agentKind: 'oto-agent',
available: true,
lifecycleState: 'busy',
activeCommandId: 'cmd-beta-active',
summary: 'Processing',
),
EdgeDomainAgentSummaryView(
agentKind: 'build-deploy',
available: true,
lifecycleState: 'ready',
activeCommandId: '',
summary: 'Idle',
),
],
error: '',
),
)
.toList(),
);
}
@override
Future<EdgeOperationsResponseView> fetchEdgeOperations(String edgeId) async {
return EdgeOperationsResponseView(
edgeId: edgeId,
operations: [
EdgeCommandRecordView(
edgeId: edgeId,
commandId: 'cmd-1',
operation: 'oto-agent.sync',
targetSelector: '',
status: 'success',
summary: 'Synced 5 models',
error: '',
),
],
);
if (edgeId == 'edge-a') {
return EdgeOperationsResponseView(
edgeId: edgeId,
operations: [
EdgeCommandRecordView(
edgeId: edgeId,
commandId: 'cmd-alpha-active',
operation: 'oto-agent.sync',
targetSelector: '',
status: 'success',
summary: 'Synced 5 models',
error: '',
),
],
);
} else {
if (edgeBOperationsCompleter != null) {
return edgeBOperationsCompleter!.future;
}
return EdgeOperationsResponseView(
edgeId: edgeId,
operations: [
EdgeCommandRecordView(
edgeId: edgeId,
commandId: 'cmd-beta-active',
operation: 'build-deploy.deploy',
targetSelector: '',
status: 'success',
summary: 'Deploy queued for edge beta',
error: '',
),
],
);
}
}
@override
@ -250,8 +327,10 @@ class FakeControlPlaneStatusRepository implements ControlPlaneStatusRepository {
lastCommandOperation = operation;
lastCommandTargetSelector = targetSelector;
lastCommandParameters = parameters;
lastCommandEdgeId = edgeId;
if (operation == 'agent.status' && (targetSelector == null || targetSelector.isEmpty)) {
if (operation == 'agent.status' &&
(targetSelector == null || targetSelector.isEmpty)) {
return EdgeCommandResponseView(
requestId: 'req-111',
commandId: 'cmd-failed',
@ -263,13 +342,17 @@ class FakeControlPlaneStatusRepository implements ControlPlaneStatusRepository {
}
if (operation == 'agent.command') {
final cmd = parameters?['command'];
if (targetSelector == null || targetSelector.isEmpty || cmd == null || cmd.isEmpty) {
if (targetSelector == null ||
targetSelector.isEmpty ||
cmd == null ||
cmd.isEmpty) {
return EdgeCommandResponseView(
requestId: 'req-111',
commandId: 'cmd-failed',
edgeId: edgeId,
status: 'error',
summary: 'Failed: agent.command requires target_selector and parameters.command',
summary:
'Failed: agent.command requires target_selector and parameters.command',
error: 'target_selector and parameters.command are required',
);
}
@ -293,10 +376,9 @@ void main() {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
@ -324,10 +406,9 @@ void main() {
);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
@ -340,10 +421,9 @@ void main() {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
@ -362,10 +442,9 @@ void main() {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
@ -382,63 +461,78 @@ void main() {
expect(find.text('1.2.0'), findsOneWidget);
expect(find.text('Serving'), findsOneWidget);
expect(find.text('Automation'), findsOneWidget);
// Verify selecting another Edge switches the detail pane instead of
// reusing the first Edge's status view.
await tester.tap(find.text('Edge Beta'));
await tester.pump();
expect(find.text('Edge Alpha'), findsOneWidget);
expect(find.text('Edge Beta'), findsNWidgets(2));
expect(find.text('edge-b'), findsOneWidget);
expect(find.text('1.2.1'), findsOneWidget);
expect(find.text('DISCONNECTED'), findsOneWidget);
expect(find.text('unhealthy'), findsOneWidget);
});
testWidgets('Client App opens Nodes panel and displays active Nodes and configurations', (
WidgetTester tester,
) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
testWidgets(
'Client App opens Nodes panel and displays active Nodes and configurations',
(WidgetTester tester) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byTooltip('Nodes'));
await tester.pump();
await tester.tap(find.byTooltip('Nodes'));
await tester.pump();
// Verify Nodes view header
expect(find.text('Nodes View'), findsOneWidget);
// Verify Nodes view header
expect(find.text('Nodes View'), findsOneWidget);
// Verify nodes listed
expect(find.text('Node One'), findsOneWidget);
expect(find.text('Node Two'), findsOneWidget);
// Verify nodes listed
expect(find.text('Node One'), findsOneWidget);
expect(find.text('Node Two'), findsOneWidget);
// Verify node properties and adapters
expect(find.text('Label: GPU-T4'), findsOneWidget);
expect(find.text('Concurrency Limit: 4'), findsOneWidget);
expect(find.text('ollama'), findsOneWidget);
expect(find.text('custom'), findsOneWidget);
});
// Verify node properties and adapters
expect(find.text('Label: GPU-T4'), findsOneWidget);
expect(find.text('Concurrency Limit: 4'), findsOneWidget);
expect(find.text('ollama'), findsOneWidget);
expect(find.text('custom'), findsOneWidget);
},
);
testWidgets('Client App opens Execution/Logs panel and displays lifecycle events', (
WidgetTester tester,
) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
testWidgets(
'Client App opens Execution/Logs panel and displays lifecycle events',
(WidgetTester tester) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byTooltip('Execution & Logs'));
await tester.pump();
await tester.tap(find.byTooltip('Execution & Logs'));
await tester.pump();
// Verify logs header
expect(find.text('Lifecycle Events & Logs'), findsOneWidget);
// Verify logs header
expect(find.text('Lifecycle Events & Logs'), findsOneWidget);
// Verify captured events
expect(find.text('ONLINE'), findsOneWidget);
expect(find.text('WARN'), findsOneWidget);
expect(find.text('Node connected and registered successfully'), findsOneWidget);
expect(find.text('Execution concurrency limit reached'), findsOneWidget);
});
// Verify captured events
expect(find.text('ONLINE'), findsOneWidget);
expect(find.text('WARN'), findsOneWidget);
expect(
find.text('Node connected and registered successfully'),
findsOneWidget,
);
expect(find.text('Execution concurrency limit reached'), findsOneWidget);
},
);
testWidgets('Client App refresh behavior when selected edge disappears', (
WidgetTester tester,
@ -446,10 +540,9 @@ void main() {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
@ -475,16 +568,16 @@ void main() {
await tester.tap(find.byTooltip('Overview'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Verify route has navigated back to Overview successfully
expect(find.text('Operations Overview'), findsOneWidget);
// Drag the ListView upward to scroll down and mount the bottom refresh button
final listViewFinder = find.byType(ListView);
await tester.drag(listViewFinder.first, const Offset(0.0, -400.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
// Tap the refresh button safely by its text label
await tester.tap(find.text('Refresh Connection'));
await tester.pump();
@ -495,7 +588,10 @@ void main() {
await tester.pump();
expect(find.text('Edge Alpha'), findsNothing);
expect(find.text('Edge Gamma'), findsNWidgets(2)); // list & details (auto-selected)
expect(
find.text('Edge Gamma'),
findsNWidgets(2),
); // list & details (auto-selected)
// 4. Switch to Nodes panel, verify dropdown Value works without assert crash
await tester.tap(find.byTooltip('Nodes'));
@ -511,193 +607,268 @@ void main() {
expect(find.text('Edge Gamma'), findsOneWidget);
});
testWidgets('Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering', (
WidgetTester tester,
) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
testWidgets(
'Client App opens Operations & Domain Agents panel and verifies agents, operations history, and command triggering',
(WidgetTester tester) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byTooltip('Runtime'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byTooltip('Runtime'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Verify panel header and sections
expect(find.text('Operations & Domain Agents'), findsOneWidget);
expect(find.text('Domain Agents'), findsOneWidget);
expect(find.text('Operations Execution History'), findsOneWidget);
expect(find.text('System Gated Operations'), findsOneWidget);
// Verify panel header and sections
expect(find.text('Operations & Domain Agents'), findsOneWidget);
expect(find.text('Domain Agents'), findsOneWidget);
expect(find.text('Operations Execution History'), findsOneWidget);
expect(find.text('System Gated Operations'), findsOneWidget);
// Verify domain agents summary listed
expect(find.text('OTO-AGENT'), findsOneWidget);
expect(find.text('BUILD-DEPLOY'), findsOneWidget);
expect(find.text('READY'), findsNWidgets(2)); // oto-agent and build-deploy are both ready
// Verify domain agents summary listed
expect(find.text('OTO-AGENT'), findsOneWidget);
expect(find.text('BUILD-DEPLOY'), findsOneWidget);
expect(
find.text('READY'),
findsNWidgets(2),
); // oto-agent and build-deploy are both ready
// Verify operations history item
expect(find.text('oto-agent.sync'), findsOneWidget);
expect(find.text('SUCCESS'), findsOneWidget);
expect(find.textContaining('Synced 5 models'), findsOneWidget);
// [REVIEW_API-2] Verify domain agents active command summary for Edge Alpha (presence)
expect(find.text('Active Command: cmd-alpha-active'), findsOneWidget);
// Verify Trigger Sync button is not present
expect(find.text('Trigger Sync'), findsNothing);
// [REVIEW_API-2] Verify beta active command summary is NOT showing for Edge Alpha
expect(find.text('Active Command: cmd-beta-active'), findsNothing);
expect(find.text('build-deploy.deploy'), findsNothing);
expect(find.textContaining('Deploy queued for edge beta'), findsNothing);
// Verify System Gated buttons are present
expect(find.text('Health Check'), findsOneWidget);
expect(find.text('Agent Status'), findsOneWidget);
expect(find.text('Agent Command'), findsOneWidget);
// Verify operations history item
expect(find.text('oto-agent.sync'), findsOneWidget);
expect(find.text('SUCCESS'), findsOneWidget);
expect(find.textContaining('Synced 5 models'), findsOneWidget);
// Verify Health Check button works and shows success banner
await tester.tap(find.text('Health Check'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Verify Trigger Sync button is not present
expect(find.text('Trigger Sync'), findsNothing);
// Check for success status banner message
expect(find.textContaining('Success: Command accepted'), findsOneWidget);
});
// Verify System Gated buttons are present
expect(find.text('Health Check'), findsOneWidget);
expect(find.text('Agent Status'), findsOneWidget);
expect(find.text('Agent Command'), findsOneWidget);
testWidgets('Client App handles unsupported or error command responses and shows error banner', (
WidgetTester tester,
) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
// Verify Health Check button works and shows success banner
await tester.tap(find.text('Health Check'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Configure repository to simulate a failed/unsupported command response
fakeStatusRepo.nextCommandStatus = 'unsupported';
fakeStatusRepo.nextCommandError = 'Operation not supported by Edge';
fakeStatusRepo.nextCommandSummary = 'Error summary';
// Check for success status banner message
expect(find.textContaining('Success: Command accepted'), findsOneWidget);
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Verify command target edge ID was edge-a
expect(fakeStatusRepo.lastCommandEdgeId, equals('edge-a'));
await tester.tap(find.byTooltip('Runtime'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Setup delayed fetch for edge-b
fakeStatusRepo.edgeBOperationsCompleter = Completer<EdgeOperationsResponseView>();
// Verify Agent Status button works, opens dialog, and then shows error banner
await tester.tap(find.text('Agent Status'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Switch to Edge Beta via Dropdown
await tester.tap(find.byType(DropdownButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Edge Beta').last);
// Pump one frame to trigger edge selection but wait before operations fetch resolves
await tester.pump();
expect(find.text('Get Agent Status'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'test-node');
await tester.tap(find.text('Send'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// [REVIEW_API-1] Verify that before the operations fetch completes:
// 1. The domain agents section has updated immediately (so cmd-alpha-active is gone, cmd-beta-active is present)
expect(find.text('Active Command: cmd-alpha-active'), findsNothing);
expect(find.text('Active Command: cmd-beta-active'), findsOneWidget);
// 2. The operations history of the previous edge (edge-a) is cleared immediately upon fetch start.
expect(find.text('oto-agent.sync'), findsNothing);
expect(find.textContaining('Synced 5 models'), findsNothing);
// 3. The loading indicator is shown since _operations is null and _isLoading is true
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Check for failure status banner message
expect(find.textContaining('Failed: unsupported - Operation not supported by Edge'), findsOneWidget);
});
// Now complete the operations fetch for edge-b
fakeStatusRepo.edgeBOperationsCompleter!.complete(EdgeOperationsResponseView(
edgeId: 'edge-b',
operations: [
EdgeCommandRecordView(
edgeId: 'edge-b',
commandId: 'cmd-beta-active',
operation: 'build-deploy.deploy',
targetSelector: '',
status: 'success',
summary: 'Deploy queued for edge beta',
error: '',
),
],
));
await tester.pumpAndSettle();
testWidgets('Client App gates agent.status and agent.command without required inputs', (
WidgetTester tester,
) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
// Verify domain agents for Edge Beta (oto-agent shows BUSY, build-deploy shows READY)
expect(find.text('BUSY'), findsOneWidget);
expect(find.text('READY'), findsOneWidget);
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// [REVIEW_API-2] Verify presence of beta active command and operations history
expect(find.text('Active Command: cmd-beta-active'), findsOneWidget);
expect(find.text('build-deploy.deploy'), findsOneWidget);
expect(find.textContaining('Deploy queued for edge beta'), findsOneWidget);
await tester.tap(find.byTooltip('Runtime'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// [REVIEW_API-2] Verify absence of alpha-only active command and operations history
expect(find.text('Active Command: cmd-alpha-active'), findsNothing);
expect(find.text('oto-agent.sync'), findsNothing);
expect(find.textContaining('Synced 5 models'), findsNothing);
// 1. Verify agent.status gating
await tester.tap(find.text('Agent Status'));
await tester.pump();
expect(find.text('Get Agent Status'), findsOneWidget);
// Verify command triggering on Edge Beta sends command to edge-b
await tester.tap(find.text('Health Check'));
await tester.pumpAndSettle();
expect(fakeStatusRepo.lastCommandEdgeId, equals('edge-b'));
},
);
// Try to send with empty target selector
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.text('Send'));
await tester.pump();
testWidgets(
'Client App handles unsupported or error command responses and shows error banner',
(WidgetTester tester) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
// The dialog should still be open (Send was gated/no-op)
expect(find.text('Get Agent Status'), findsOneWidget);
expect(fakeStatusRepo.lastCommandOperation, isNull);
// Configure repository to simulate a failed/unsupported command response
fakeStatusRepo.nextCommandStatus = 'unsupported';
fakeStatusRepo.nextCommandError = 'Operation not supported by Edge';
fakeStatusRepo.nextCommandSummary = 'Error summary';
// Cancel dialog
await tester.tap(find.text('Cancel'));
await tester.pump();
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// 2. Verify agent.command gating
await tester.tap(find.text('Agent Command'));
await tester.pump();
expect(find.text('Send Agent Command'), findsOneWidget);
await tester.tap(find.byTooltip('Runtime'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Try to send with empty command name and empty selector
await tester.tap(find.text('Send'));
await tester.pump();
expect(find.text('Send Agent Command'), findsOneWidget);
expect(fakeStatusRepo.lastCommandOperation, isNull);
// Verify Agent Status button works, opens dialog, and then shows error banner
await tester.tap(find.text('Agent Status'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Enter only selector
await tester.enterText(find.byType(TextField).at(0), 'node-1');
await tester.tap(find.text('Send'));
await tester.pump();
expect(find.text('Send Agent Command'), findsOneWidget);
expect(fakeStatusRepo.lastCommandOperation, isNull);
expect(find.text('Get Agent Status'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'test-node');
await tester.tap(find.text('Send'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Enter only command name (clear selector)
await tester.enterText(find.byType(TextField).at(0), '');
await tester.enterText(find.byType(TextField).at(1), 'sync');
await tester.tap(find.text('Send'));
await tester.pump();
expect(find.text('Send Agent Command'), findsOneWidget);
expect(fakeStatusRepo.lastCommandOperation, isNull);
// Check for failure status banner message
expect(
find.textContaining(
'Failed: unsupported - Operation not supported by Edge',
),
findsOneWidget,
);
},
);
// Cancel
await tester.tap(find.text('Cancel'));
await tester.pump();
});
testWidgets(
'Client App gates agent.status and agent.command without required inputs',
(WidgetTester tester) async {
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
testWidgets('Client App mobile screen layout verification for layout and overflow', (
WidgetTester tester,
) async {
// Set a small mobile screen size
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1.0;
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.tap(find.byTooltip('Runtime'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpWidget(IopClientApp(
testClient: fakeClient,
statusRepository: fakeStatusRepo,
));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// 1. Verify agent.status gating
await tester.tap(find.text('Agent Status'));
await tester.pump();
expect(find.text('Get Agent Status'), findsOneWidget);
// Overview
expect(find.text('Operations Overview'), findsOneWidget);
// Try to send with empty target selector
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.text('Send'));
await tester.pump();
// Switch to Edges panel
await tester.tap(find.byTooltip('Edges'));
await tester.pump();
expect(find.text('Edge Alpha'), findsNWidgets(2));
// The dialog should still be open (Send was gated/no-op)
expect(find.text('Get Agent Status'), findsOneWidget);
expect(fakeStatusRepo.lastCommandOperation, isNull);
// Switch to Runtime panel
await tester.tap(find.byTooltip('Runtime'));
await tester.pump();
expect(find.text('Operations & Domain Agents'), findsOneWidget);
// Cancel dialog
await tester.tap(find.text('Cancel'));
await tester.pump();
// Reset view size after test
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
});
// 2. Verify agent.command gating
await tester.tap(find.text('Agent Command'));
await tester.pump();
expect(find.text('Send Agent Command'), findsOneWidget);
// Try to send with empty command name and empty selector
await tester.tap(find.text('Send'));
await tester.pump();
expect(find.text('Send Agent Command'), findsOneWidget);
expect(fakeStatusRepo.lastCommandOperation, isNull);
// Enter only selector
await tester.enterText(find.byType(TextField).at(0), 'node-1');
await tester.tap(find.text('Send'));
await tester.pump();
expect(find.text('Send Agent Command'), findsOneWidget);
expect(fakeStatusRepo.lastCommandOperation, isNull);
// Enter only command name (clear selector)
await tester.enterText(find.byType(TextField).at(0), '');
await tester.enterText(find.byType(TextField).at(1), 'sync');
await tester.tap(find.text('Send'));
await tester.pump();
expect(find.text('Send Agent Command'), findsOneWidget);
expect(fakeStatusRepo.lastCommandOperation, isNull);
// Cancel
await tester.tap(find.text('Cancel'));
await tester.pump();
},
);
testWidgets(
'Client App mobile screen layout verification for layout and overflow',
(WidgetTester tester) async {
// Set a small mobile screen size
tester.view.physicalSize = const Size(360, 640);
tester.view.devicePixelRatio = 1.0;
final fakeClient = FakeClientWireClient(shouldSuccess: true);
final fakeStatusRepo = FakeControlPlaneStatusRepository();
await tester.pumpWidget(
IopClientApp(testClient: fakeClient, statusRepository: fakeStatusRepo),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// Overview
expect(find.text('Operations Overview'), findsOneWidget);
// Switch to Edges panel
await tester.tap(find.byTooltip('Edges'));
await tester.pump();
expect(find.text('Edge Alpha'), findsNWidgets(2));
// Switch to Runtime panel
await tester.tap(find.byTooltip('Runtime'));
await tester.pump();
expect(find.text('Operations & Domain Agents'), findsOneWidget);
// Reset view size after test
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
},
);
}

View file

@ -644,31 +644,61 @@ func TestEdgeOperationsHTTPHandlerListsAudit(t *testing.T) {
registry := wire.NewEdgeRegistry()
at := time.Unix(1780142200, 0).UTC()
registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-a", EdgeName: "Edge A"}, at)
registry.RecordCommandRequest("edge-a", &iop.EdgeCommandRequest{CommandId: "cmd-1", Operation: "restart-node", TargetSelector: "node-1"}, at)
registry.RecordCommandEvent("edge-a", &iop.EdgeCommandEvent{CommandId: "cmd-1", Phase: "running", Summary: "executing"}, at.Add(time.Second))
registry.RecordCommandResult("edge-a", &iop.EdgeCommandResponse{CommandId: "cmd-1", Status: "ok", Summary: "done"}, at.Add(2*time.Second))
registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-b", EdgeName: "Edge B"}, at)
// Edge A command records
registry.RecordCommandRequest("edge-a", &iop.EdgeCommandRequest{CommandId: "cmd-a-1", Operation: "restart-node", TargetSelector: "node-1"}, at)
registry.RecordCommandEvent("edge-a", &iop.EdgeCommandEvent{CommandId: "cmd-a-1", Phase: "running", Summary: "executing"}, at.Add(time.Second))
registry.RecordCommandResult("edge-a", &iop.EdgeCommandResponse{CommandId: "cmd-a-1", Status: "ok", Summary: "done"}, at.Add(2*time.Second))
// Edge B command records
registry.RecordCommandRequest("edge-b", &iop.EdgeCommandRequest{CommandId: "cmd-b-1", Operation: "drain-node", TargetSelector: "node-2"}, at)
registry.RecordCommandEvent("edge-b", &iop.EdgeCommandEvent{CommandId: "cmd-b-1", Phase: "draining", Summary: "evicting pods"}, at.Add(time.Second))
registry.RecordCommandResult("edge-b", &iop.EdgeCommandResponse{CommandId: "cmd-b-1", Status: "failed", Summary: "timeout"}, at.Add(2*time.Second))
mux := http.NewServeMux()
registerEdgeRegistryHandlers(mux, registry, nil, nil)
resp := httptest.NewRecorder()
mux.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "/edges/edge-a/operations", nil))
if resp.Code != http.StatusOK {
t.Fatalf("GET operations status=%d body=%s", resp.Code, resp.Body.String())
// Request Edge A operations
respA := httptest.NewRecorder()
mux.ServeHTTP(respA, httptest.NewRequest(http.MethodGet, "/edges/edge-a/operations", nil))
if respA.Code != http.StatusOK {
t.Fatalf("GET /edges/edge-a/operations status=%d body=%s", respA.Code, respA.Body.String())
}
var got edgeOperationsResponse
if err := json.Unmarshal(resp.Body.Bytes(), &got); err != nil {
t.Fatalf("decode operations: %v", err)
var gotA edgeOperationsResponse
if err := json.Unmarshal(respA.Body.Bytes(), &gotA); err != nil {
t.Fatalf("decode operations A: %v", err)
}
if got.EdgeID != "edge-a" || len(got.Operations) != 1 {
t.Fatalf("unexpected operations response: %+v", got)
if gotA.EdgeID != "edge-a" || len(gotA.Operations) != 1 {
t.Fatalf("unexpected operations A response: %+v", gotA)
}
op := got.Operations[0]
if op.CommandID != "cmd-1" || op.Operation != "restart-node" || op.Status != "ok" || op.Summary != "done" {
t.Fatalf("unexpected operation record: %+v", op)
opA := gotA.Operations[0]
if opA.CommandID != "cmd-a-1" || opA.Operation != "restart-node" || opA.Status != "ok" || opA.Summary != "done" {
t.Fatalf("unexpected operation A record: %+v", opA)
}
if len(op.Events) != 1 || op.Events[0].Phase != "running" {
t.Fatalf("unexpected operation events: %+v", op.Events)
if len(opA.Events) != 1 || opA.Events[0].Phase != "running" {
t.Fatalf("unexpected operation A events: %+v", opA.Events)
}
// Request Edge B operations
respB := httptest.NewRecorder()
mux.ServeHTTP(respB, httptest.NewRequest(http.MethodGet, "/edges/edge-b/operations", nil))
if respB.Code != http.StatusOK {
t.Fatalf("GET /edges/edge-b/operations status=%d body=%s", respB.Code, respB.Body.String())
}
var gotB edgeOperationsResponse
if err := json.Unmarshal(respB.Body.Bytes(), &gotB); err != nil {
t.Fatalf("decode operations B: %v", err)
}
if gotB.EdgeID != "edge-b" || len(gotB.Operations) != 1 {
t.Fatalf("unexpected operations B response: %+v", gotB)
}
opB := gotB.Operations[0]
if opB.CommandID != "cmd-b-1" || opB.Operation != "drain-node" || opB.Status != "failed" || opB.Summary != "timeout" {
t.Fatalf("unexpected operation B record: %+v", opB)
}
if len(opB.Events) != 1 || opB.Events[0].Phase != "draining" {
t.Fatalf("unexpected operation B events: %+v", opB.Events)
}
}
@ -676,9 +706,10 @@ func TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities(t *testing.T) {
registry := wire.NewEdgeRegistry()
at := time.Unix(1780142200, 0).UTC()
registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-online", EdgeName: "Online", Version: "1.0.0"}, at)
registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-other", EdgeName: "Other", Version: "1.1.0"}, at)
registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-degraded", EdgeName: "Degraded"}, at)
registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-offline", EdgeName: "Offline"}, at)
registry.MarkDisconnected("edge-offline", 3, at.Add(time.Second), proto_socket.DisconnectInfo{Reason: "closed"})
offlineToken := registry.MarkConnected(&iop.EdgeHelloRequest{EdgeId: "edge-offline", EdgeName: "Offline"}, at)
registry.MarkDisconnected("edge-offline", offlineToken, at.Add(time.Second), proto_socket.DisconnectInfo{Reason: "closed"})
requestStatus := func(edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error) {
switch edgeID {
@ -689,6 +720,20 @@ func TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities(t *testing.T) {
Capabilities: []*iop.EdgeCapabilitySummary{
{Kind: "run-dispatch", Available: true, Status: "ready"},
},
DomainAgents: []*iop.EdgeDomainAgentSummary{
{AgentKind: "oto", Available: true, LifecycleState: "ready", ActiveCommandId: "cmd-oto-1", Summary: "oto is ready"},
},
}, nil
case "edge-other":
return &iop.EdgeStatusResponse{
EdgeId: edgeID,
Nodes: []*iop.EdgeNodeSnapshot{{NodeId: "node-2", Alias: "beta", Connected: true}},
Capabilities: []*iop.EdgeCapabilitySummary{
{Kind: "run-dispatch", Available: true, Status: "ready"},
},
DomainAgents: []*iop.EdgeDomainAgentSummary{
{AgentKind: "deployer", Available: true, LifecycleState: "busy", ActiveCommandId: "cmd-dep-2", Summary: "deployer is busy"},
},
}, nil
case "edge-degraded":
return nil, fmt.Errorf("status timeout")
@ -709,19 +754,38 @@ func TestFleetStatusHTTPHandlerCombinesConnectionAndCapabilities(t *testing.T) {
if err := json.Unmarshal(resp.Body.Bytes(), &got); err != nil {
t.Fatalf("decode fleet status: %v", err)
}
if len(got.Edges) != 3 {
t.Fatalf("expected 3 fleet edges, got %d", len(got.Edges))
if len(got.Edges) != 4 {
t.Fatalf("expected 4 fleet edges, got %d", len(got.Edges))
}
health := map[string]fleetEdgeView{}
for _, e := range got.Edges {
health[e.EdgeID] = e
}
if e := health["edge-online"]; e.Health != "online" || e.NodeCount != 1 || len(e.Capabilities) != 1 {
t.Fatalf("unexpected online edge view: %+v", e)
// Verify edge-online
eOnline := health["edge-online"]
if eOnline.Health != "online" || eOnline.NodeCount != 1 || len(eOnline.Capabilities) != 1 {
t.Fatalf("unexpected online edge view: %+v", eOnline)
}
if len(eOnline.DomainAgents) != 1 || eOnline.DomainAgents[0].AgentKind != "oto" || eOnline.DomainAgents[0].LifecycleState != "ready" || eOnline.DomainAgents[0].ActiveCommandID != "cmd-oto-1" || eOnline.DomainAgents[0].Summary != "oto is ready" {
t.Fatalf("unexpected domain agents for edge-online: %+v", eOnline.DomainAgents)
}
// Verify edge-other
eOther := health["edge-other"]
if eOther.Health != "online" || eOther.NodeCount != 1 || len(eOther.Capabilities) != 1 {
t.Fatalf("unexpected edge-other view: %+v", eOther)
}
if len(eOther.DomainAgents) != 1 || eOther.DomainAgents[0].AgentKind != "deployer" || eOther.DomainAgents[0].LifecycleState != "busy" || eOther.DomainAgents[0].ActiveCommandID != "cmd-dep-2" || eOther.DomainAgents[0].Summary != "deployer is busy" {
t.Fatalf("unexpected domain agents for edge-other: %+v", eOther.DomainAgents)
}
// Verify edge-degraded
if e := health["edge-degraded"]; e.Health != "degraded" || e.Error == "" {
t.Fatalf("unexpected degraded edge view: %+v", e)
}
// Verify edge-offline
if e := health["edge-offline"]; e.Health != "offline" || e.Connected {
t.Fatalf("unexpected offline edge view: %+v", e)
}