From 002f9cd52168257c61270577e2c68965a433f951 Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 17 Jun 2026 13:50:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Control=20Plane=20API=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B0=94=EC=9D=B8=EB=94=A9=20=EB=B0=8F=20?= =?UTF-8?q?oto=5Fconsole=20=EB=9F=B0=EB=84=88urface=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Control Plane API 데이터 바인딩 마일스톤 문서 업데이트 - core_connection_client 및 oto_client_app 수정 - oto_console 런너.surface 모듈 추가 - 관련 테스트 파일 업데이트 --- .../control-plane-api-data-binding.md | 5 +- .../code_review_local_G05_0.log | 195 +++++++ .../01_runner_surface/complete.log | 46 ++ .../01_runner_surface/plan_local_G05_0.log | 192 +++++++ .../CODE_REVIEW-local-G06.md | 133 +++++ .../02+01_execution_surface/PLAN-local-G06.md | 201 +++++++ .../CODE_REVIEW-cloud-G07.md | 149 +++++ .../03+01,02_preview_smoke/PLAN-cloud-G07.md | 196 +++++++ .../lib/src/app/core_connection_client.dart | 516 ++++++++++++++++++ apps/client/lib/src/app/oto_client_app.dart | 123 ++++- apps/client/test/widget_test.dart | 298 ++++++++++ .../flutter/oto_console/lib/oto_console.dart | 1 + .../lib/src/oto_console_contract.dart | 47 ++ .../lib/src/oto_runners_surface.dart | 302 ++++++++++ .../oto_console/test/oto_console_test.dart | 83 +++ 15 files changed, 2482 insertions(+), 5 deletions(-) create mode 100644 agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/code_review_local_G05_0.log create mode 100644 agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log create mode 100644 agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/plan_local_G05_0.log create mode 100644 agent-task/m-control-plane-api-data-binding/02+01_execution_surface/CODE_REVIEW-local-G06.md create mode 100644 agent-task/m-control-plane-api-data-binding/02+01_execution_surface/PLAN-local-G06.md create mode 100644 agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md create mode 100644 packages/flutter/oto_console/lib/src/oto_runners_surface.dart diff --git a/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md b/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md index 9e40596..4f349a6 100644 --- a/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md +++ b/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md @@ -12,7 +12,7 @@ Flutter 콘솔 스켈레톤의 Overview, Runners, Pipelines/Jobs, Executions, Ar ## 상태 -[계획] +[진행중] ## 승격 조건 @@ -40,7 +40,7 @@ Flutter 콘솔 스켈레톤의 Overview, Runners, Pipelines/Jobs, Executions, Ar UI 스켈레톤을 실제 Core 응답을 소비하는 제품 표면으로 전환한다. -- [ ] [client-ports] Flutter client에 runners/jobs/executions/logs/artifacts 조회 adapter를 추가하고 `OtoConsoleConfig.serverHttpUrl`을 source of truth로 사용한다. 검증: `cd apps/client && flutter test`에서 adapter 상태 변환 테스트가 통과한다. +- [x] [client-ports] Flutter client에 runners/jobs/executions/logs/artifacts 조회 adapter를 추가하고 `OtoConsoleConfig.serverHttpUrl`을 source of truth로 사용한다. 검증: `cd apps/client && flutter test`에서 adapter 상태 변환 테스트가 통과한다. - [ ] [runner-surface] Runners 화면이 Core runner registry 응답을 loading/empty/error/data 상태로 표시한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 runners surface 테스트가 통과한다. - [ ] [execution-surface] Pipelines/Jobs와 Executions 화면이 job/execution read model을 표시하고 로그/아티팩트 진입 위치를 제공한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 jobs/executions surface 테스트가 통과한다. - [ ] [preview-data-smoke] 원격 preview `http://toki-labs.com:13020/` 기준으로 Core health/readiness와 최소 한 화면의 실제 API 상태가 렌더링된다. 검증: 원격 runner 또는 local smoke에서 browser/screenshot evidence와 `curl` evidence를 남긴다. @@ -66,6 +66,7 @@ UI 스켈레톤을 실제 Core 응답을 소비하는 제품 표면으로 전환 - 관련 경로: `apps/client/**`, `packages/flutter/oto_console/**`, `services/core/internal/httpserver/**` - 표준선(선택): `OtoConsoleConfig.serverHttpUrl`을 API base URL로 유지하고, UI 패키지는 transport 구현보다 상태/표현 contract에 집중한다. - 표준선(선택): Core HTTP route는 현재 CORS/preflight를 제공하므로 web preview는 cross-origin API smoke를 포함한다. +- 진행 메모: [client-ports] `apps/client`에 Core read adapter와 상태 변환 테스트를 추가했고 `cd apps/client && flutter test`, `cd apps/client && flutter analyze`가 통과했다. (2026-06-17) - 선행 작업: `Flutter 콘솔 스켈레톤` - 후속 작업: 쓰기 액션, 인증/권한, 운영 대시보드 고도화 - 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/code_review_local_G05_0.log b/agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/code_review_local_G05_0.log new file mode 100644 index 0000000..0afd9c3 --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/code_review_local_G05_0.log @@ -0,0 +1,195 @@ + + +# 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. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-17 +task=m-control-plane-api-data-binding/01_runner_surface, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- Task ids: + - `runner-surface`: Runners 화면이 Core runner registry 응답을 loading/empty/error/data 상태로 표시한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/01_runner_surface/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-control-plane-api-data-binding`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Console runner surface contract | [x] | +| [API-2] Client runner read wiring | [x] | + +## 구현 체크리스트 + +- [x] `packages/flutter/oto_console`에 runner surface용 상태 contract와 `OtoRunnersSurface`를 추가하고 loading/empty/error/data widget test를 작성한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 runners surface 테스트가 통과한다. +- [x] `apps/client`가 `OtoCoreReadClient.fetchRunner`와 `fetchRunnerStatus` 결과를 runner surface 상태로 변환해 `OtoConsoleShell.runners`에 주입하고 fake read client widget test를 작성한다. 검증: `cd apps/client && flutter test`가 통과한다. +- [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-control-plane-api-data-binding/01_runner_surface/`를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/01_runner_surface/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-control-plane-api-data-binding`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-api-data-binding/`를 제거하거나, 남은 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로 이동한다. + +## 계획 대비 변경 사항 + +- 없음 + +## 주요 설계 결정 + +- `OtoRunnersSurface` 위젯을 `packages/flutter/oto_console` 내부에 transport-free 위젯으로 구현하여, `OtoSurfaceSnapshot`의 상태(`loading`, `empty`, `error`, `data`)에 부합하는 화면을 그리도록 설계했습니다. +- `apps/client` 단에서 `OtoCoreReadClient`를 통해 `fetchRunner` 및 `fetchRunnerStatus` 병렬 조회를 처리하고, 받아온 `OtoRunnerRecord`와 `OtoRunnerStatus` 정보를 `OtoRunnerViewModel` 형태로 적절히 파싱/가공하여 콘솔 쉘(`OtoConsoleShell.runners`)에 주입하도록 wiring을 마무리했습니다. + +## 사용자 리뷰 요청 + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Core route/schema 변경 없이 기존 id 기반 runner read path만 소비했는지 확인한다. +- loading/empty/error/data 상태가 console package widget test로 검증됐는지 확인한다. +- app refresh가 overview와 runner snapshot을 함께 갱신하고 stale async result를 반영하지 않는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### API-1 중간 검증 +```bash +$ cd packages/flutter/oto_console && flutter test +00:00 +0: loading /config/workspace/oto/packages/flutter/oto_console/test/oto_console_test.dart +00:00 +0: exports console contract models without shell dependency +00:00 +1: renders embeddable OTO console shell +00:00 +2: renders section surfaces with empty states +00:01 +3: OtoRunnersSurface renders loading state +00:01 +4: OtoRunnersSurface renders empty state +00:01 +5: OtoRunnersSurface renders error state +00:01 +6: OtoRunnersSurface renders data state with runners list +00:01 +7: All tests passed! +``` + +### API-2 중간 검증 +```bash +$ cd apps/client && flutter test +Waiting for another flutter command to release the startup lock... +00:00 +0: loading /config/workspace/oto/apps/client/test/widget_test.dart +00:00 +0: OTO client hosts embeddable console widgets +00:01 +1: OtoHttpCoreReadClient maps Core read responses to data states +00:01 +2: OtoHttpCoreReadClient maps empty and error states +00:01 +3: OtoClientApp wires runners loading empty state when no runnerIDs +00:01 +4: OtoClientApp wires runners error state on API failure +00:01 +5: OtoClientApp wires runners data state on API success +00:01 +6: All tests passed! +``` + +### 최종 검증 +```bash +$ cd packages/flutter/oto_console && flutter test +00:00 +0: loading /config/workspace/oto/packages/flutter/oto_console/test/oto_console_test.dart +00:00 +0: exports console contract models without shell dependency +00:00 +1: renders embeddable OTO console shell +00:00 +2: renders section surfaces with empty states +00:01 +3: OtoRunnersSurface renders loading state +00:01 +4: OtoRunnersSurface renders empty state +00:01 +5: OtoRunnersSurface renders error state +00:01 +6: OtoRunnersSurface renders data state with runners list +00:01 +7: All tests passed! +$ cd apps/client && flutter test +Waiting for another flutter command to release the startup lock... +00:00 +0: loading /config/workspace/oto/apps/client/test/widget_test.dart +00:00 +0: OTO client hosts embeddable console widgets +00:01 +1: OtoHttpCoreReadClient maps Core read responses to data states +00:01 +2: OtoHttpCoreReadClient maps empty and error states +00:01 +3: OtoClientApp wires runners loading empty state when no runnerIDs +00:01 +4: OtoClientApp wires runners error state on API failure +00:01 +5: OtoClientApp wires runners data state on API success +00:01 +6: 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. + +## 코드리뷰 결과 + +- 리뷰 일시: 2026-06-17 +- 종합 판정: PASS + +### 차원별 평가 + +| 차원 | 평가 | 근거 | +|------|------|------| +| Correctness | Pass | `OtoRunnersSurface`가 loading/empty/error/data 분기를 모두 렌더링하고, `OtoClientApp`이 runner record/status 결과를 snapshot으로 변환한다. | +| Completeness | Pass | 계획의 API-1/API-2 구현 항목과 테스트가 모두 반영되었다. | +| Test coverage | Pass | console surface 상태별 widget test와 client read/wiring widget test가 추가되었다. | +| API contract | Pass | Core route/schema 변경 없이 기존 `/api/v1/runners/{id}`와 `/api/v1/runners/{id}/status` 응답 키를 소비한다. | +| Code quality | Pass | 리뷰 중 formatting/lint drift를 보정했고 `flutter analyze`, `dart format --set-exit-if-changed`, `git diff --check`가 통과했다. | +| Plan deviation | Pass | 범위 밖 Core API 변경은 없고, 계획된 read client/surface 연결 범위 안에 머문다. | +| Verification trust | Pass | 리뷰 에이전트가 지정된 Flutter test 명령을 재실행했고 실제 stdout을 검증 결과에 보강했다. | + +### 발견된 문제 + +없음 + +### 리뷰 중 보정 + +- `packages/flutter/oto_console/lib/src/oto_console_contract.dart`의 `OtoSurfaceSnapshot.data` 생성자 lint를 동작 변경 없이 수정했다. +- `dart format`을 적용해 `apps/client`와 `packages/flutter/oto_console` 변경 파일의 formatting drift를 정리했다. +- active review stub의 placeholder 검증 출력을 리뷰 중 재실행한 실제 stdout으로 보강했다. + +### 다음 단계 + +PASS: `complete.log`를 작성하고 task directory를 `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/`로 이동한다. `m-control-plane-api-data-binding` 완료 이벤트 메타데이터를 보고하되 roadmap 수정은 런타임 책임으로 남긴다. diff --git a/agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log b/agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log new file mode 100644 index 0000000..ef78699 --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log @@ -0,0 +1,46 @@ +# Complete - m-control-plane-api-data-binding/01_runner_surface + +## 완료 일시 + +2026-06-17 + +## 요약 + +API Runner Surface 구현 루프 1회차를 PASS로 종료했다. Runners 화면의 loading/empty/error/data 상태와 client read wiring이 테스트로 검증되었다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | runner surface contract, client runner read wiring, 검증 evidence 보강 완료 | + +## 구현/정리 내용 + +- `packages/flutter/oto_console`에 `OtoSurfaceSnapshot`, `OtoRunnerViewModel`, `OtoRunnersSurface`와 상태별 widget test를 추가했다. +- `apps/client`가 runner id seed를 받아 `OtoCoreReadClient.fetchRunner`와 `fetchRunnerStatus` 결과를 runners surface snapshot으로 변환해 `OtoConsoleShell.runners`에 주입하도록 연결했다. +- 리뷰 중 `OtoSurfaceSnapshot.data` 생성자 lint와 formatting drift를 동작 변경 없이 보정했다. + +## 최종 검증 + +- `cd packages/flutter/oto_console && flutter test` - PASS; 7 tests passed. +- `cd apps/client && flutter test` - PASS; 6 tests passed. +- `cd packages/flutter/oto_console && flutter analyze` - PASS; No issues found. +- `cd apps/client && flutter analyze` - PASS; No issues found. +- `cd packages/flutter/oto_console && dart format --output=none --set-exit-if-changed lib test` - PASS; 0 changed. +- `cd apps/client && dart format --output=none --set-exit-if-changed lib test` - PASS; 0 changed. +- `git diff --check` - PASS; no whitespace errors. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- Completed task ids: + - `runner-surface`: PASS; evidence=`agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/code_review_local_G05_0.log`; verification=`cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/plan_local_G05_0.log b/agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/plan_local_G05_0.log new file mode 100644 index 0000000..e3942df --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/plan_local_G05_0.log @@ -0,0 +1,192 @@ + + +# Plan - API Runner Surface + +## 이 파일을 읽는 구현 에이전트에게 + +구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 출력과 설계 결정을 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 종결 처리, log rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자만 결정할 항목, 사용자 소유 외부 환경, 범위 충돌이 발견되면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 남기고 멈춘다. 구현 중 채팅으로 직접 질문하거나 `request_user_input`을 호출하지 않는다. + +## 배경 + +현재 runner 화면은 `OtoConsoleShell`의 기본 empty surface만 보여준다. 작은 선행 작업에서 `OtoHttpCoreReadClient`가 `OtoConsoleConfig.serverHttpUrl`을 기준으로 `/api/v1/runners/{id}`와 `/api/v1/runners/{id}/status`를 읽도록 추가되었다. 이 계획은 Core API schema를 바꾸지 않고, 알려진 runner id가 있을 때 loading/empty/error/data 상태를 화면에 표시하는 runner surface를 만든다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사항은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 직접 사용자 프롬프트는 금지되며, code-review가 요청 타당성을 검토하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- Task ids: + - `runner-surface`: Runners 화면이 Core runner registry 응답을 loading/empty/error/data 상태로 표시한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/control-plane-product-surface/PHASE.md` +- `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- `agent-test/local/rules.md` +- `apps/client/lib/src/app/core_connection_client.dart` +- `apps/client/lib/src/app/oto_client_app.dart` +- `apps/client/test/widget_test.dart` +- `apps/client/pubspec.yaml` +- `apps/client/analysis_options.yaml` +- `packages/flutter/oto_console/lib/oto_console.dart` +- `packages/flutter/oto_console/lib/src/oto_console_contract.dart` +- `packages/flutter/oto_console/lib/src/oto_console_models.dart` +- `packages/flutter/oto_console/lib/src/oto_console_overview.dart` +- `packages/flutter/oto_console/lib/src/oto_console_shell.dart` +- `packages/flutter/oto_console/test/oto_console_test.dart` +- `packages/flutter/oto_console/pubspec.yaml` +- `packages/flutter/oto_console/analysis_options.yaml` +- `services/core/internal/httpserver/routes.go` +- `services/core/internal/httpserver/dto.go` +- `services/core/internal/httpserver/runner_handlers.go` +- `services/core/internal/httpserver/job_handlers.go` +- `services/core/internal/httpserver/execution_handlers.go` +- `services/core/internal/cicdstate/store.go` +- `services/core/internal/runnerregistry/registry.go` + +### 테스트 환경 규칙 + +- `test_env=local`; `agent-test/local/rules.md`를 읽었다. +- matched agent-test profile: 없음. `apps/client/**`, `packages/flutter/oto_console/**`, `services/core/internal/**`에는 전용 domain rule/test profile이 아직 없다. +- 적용할 fallback source: `agent-test/local/rules.md`의 Monorepo Targets. +- 검증 명령: `cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test`. +- 도구 확인: `command -v flutter`, `command -v dart`, `command -v go` 성공. + +### 테스트 커버리지 공백 + +- runner surface contract와 loading/empty/error/data 렌더링은 기존 `packages/flutter/oto_console/test/oto_console_test.dart`에 없다. 새 widget test가 필요하다. +- app이 runner read 상태를 만들고 shell에 주입하는 흐름은 기존 `apps/client/test/widget_test.dart`에 없다. fake read client 기반 테스트가 필요하다. + +### 심볼 참조 + +- renamed/removed symbols: none. + +### 분할 판단 + +- split decision policy를 먼저 평가했다. +- shared task group: `agent-task/m-control-plane-api-data-binding/`. +- `01_runner_surface`: 독립 실행. runner 화면 contract와 client runner 상태 주입을 먼저 만든다. +- `02+01_execution_surface`: `01_runner_surface`의 공통 surface state contract 완료 후 진행한다. predecessor `01`: missing active/archive `complete.log`. +- `03+01,02_preview_smoke`: runner/execution surface 완료 후 원격 preview smoke를 수집한다. predecessor `01`: missing, predecessor `02`: missing. + +### 범위 결정 근거 + +- Core HTTP API route/schema는 수정하지 않는다. `routes.go:63-127`은 id 기반 read path만 노출하며, 이 계획은 그 제약을 화면 empty/data 상태에 반영한다. +- jobs/executions/logs/artifacts 화면은 `02+01_execution_surface`에서 다룬다. +- remote preview screenshot/curl evidence는 `03+01,02_preview_smoke`에서 다룬다. + +### 빌드 등급 + +- build: `local-G05`, review: `local-G05`. public widget contract와 app state wiring이 있지만 범위가 Flutter client/console로 제한되고 widget tests로 검증 가능하다. + +## 구현 체크리스트 + +- [ ] `packages/flutter/oto_console`에 runner surface용 상태 contract와 `OtoRunnersSurface`를 추가하고 loading/empty/error/data widget test를 작성한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 runners surface 테스트가 통과한다. +- [ ] `apps/client`가 `OtoCoreReadClient.fetchRunner`와 `fetchRunnerStatus` 결과를 runner surface 상태로 변환해 `OtoConsoleShell.runners`에 주입하고 fake read client widget test를 작성한다. 검증: `cd apps/client && flutter test`가 통과한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [API-1] Console runner surface contract + +문제: `OtoConsoleShell`은 runners section에서 기본 empty surface만 렌더링한다. + +Before: + +```dart +// packages/flutter/oto_console/lib/src/oto_console_shell.dart:74 +OtoConsoleSection.runners => + widget.runners ?? + OtoConsoleSectionSurface( + icon: Icons.precision_manufacturing_outlined, + title: 'Runners', + emptyTitle: 'No runners connected', + emptyMessage: 'Runner registry is empty.', + theme: theme, + ), +``` + +해결 방법: `oto_console_contract.dart`에 transport-free `OtoSurfaceLoadState`, `OtoSurfaceSnapshot`, `OtoRunnerViewModel`을 추가한다. 새 `OtoRunnersSurface` 위젯은 `snapshot.state`에 따라 progress, empty panel, error panel, runner rows를 렌더링한다. `oto_console.dart`에서 새 surface를 export한다. + +수정 파일 및 체크리스트: + +- [ ] `packages/flutter/oto_console/lib/src/oto_console_contract.dart`에 상태 contract와 runner view model 추가. +- [ ] `packages/flutter/oto_console/lib/src/oto_runners_surface.dart` 생성. +- [ ] `packages/flutter/oto_console/lib/oto_console.dart` export 추가. +- [ ] `packages/flutter/oto_console/test/oto_console_test.dart`에 runner loading/empty/error/data 테스트 추가. + +테스트 작성: 작성. `OtoRunnersSurface`를 직접 pump하고 상태별 텍스트, runner id/status/current execution 노출을 검증한다. + +중간 검증: + +```bash +cd packages/flutter/oto_console && flutter test +``` + +기대 결과: runners surface 테스트 포함 전체 통과. + +### [API-2] Client runner read wiring + +문제: `OtoClientApp`은 overview만 shell에 제공하고 runners slot은 비어 있다. + +Before: + +```dart +// apps/client/lib/src/app/oto_client_app.dart:111 +home: OtoConsoleShell( + config: widget.config, + capabilities: widget.capabilities, + themeAdapter: themeAdapter, + overview: OtoConsoleOverview( + config: widget.config, + connection: _connection, + onRefresh: _refreshCoreStatus, + themeAdapter: themeAdapter, + ), +), +``` + +해결 방법: `OtoClientApp`에 optional known runner id list와 `OtoCoreReadClient` 주입점을 추가한다. runner id가 없으면 empty snapshot을 만든다. id가 있으면 `fetchRunner`와 `fetchRunnerStatus`를 병렬 호출하고, adapter의 loading/empty/error/data 결과를 console surface snapshot으로 변환한다. `Refresh`는 Core status와 runner snapshot을 함께 갱신한다. + +수정 파일 및 체크리스트: + +- [ ] `apps/client/lib/src/app/oto_client_app.dart`에 read client, runner id seed, runner snapshot state 추가. +- [ ] `apps/client/lib/src/app/core_connection_client.dart`의 `OtoCoreReadClient`를 그대로 사용하고 Core API route를 추가하지 않는다. +- [ ] `apps/client/test/widget_test.dart`에 fake read client로 runner data/error/empty 주입 테스트 추가. + +테스트 작성: 작성. fake read client가 `config.serverHttpUrl`을 받는지, runner 화면이 data/error/empty를 표시하는지 검증한다. + +중간 검증: + +```bash +cd apps/client && flutter test +``` + +기대 결과: runner wiring 테스트 포함 전체 통과. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `packages/flutter/oto_console/lib/src/oto_console_contract.dart` | API-1 | +| `packages/flutter/oto_console/lib/src/oto_runners_surface.dart` | API-1 | +| `packages/flutter/oto_console/lib/oto_console.dart` | API-1 | +| `packages/flutter/oto_console/test/oto_console_test.dart` | API-1 | +| `apps/client/lib/src/app/oto_client_app.dart` | API-2 | +| `apps/client/lib/src/app/core_connection_client.dart` | API-2 | +| `apps/client/test/widget_test.dart` | API-2 | + +## 최종 검증 + +```bash +cd packages/flutter/oto_console && flutter test +cd apps/client && flutter test +``` + +기대 결과: 두 명령 모두 통과한다. Flutter test cache 여부는 Dart/Flutter test runner 기본 동작을 따른다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/CODE_REVIEW-local-G06.md b/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/CODE_REVIEW-local-G06.md new file mode 100644 index 0000000..dafd19a --- /dev/null +++ b/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/CODE_REVIEW-local-G06.md @@ -0,0 +1,133 @@ + + +# 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. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-17 +task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- Task ids: + - `execution-surface`: Pipelines/Jobs와 Executions 화면이 job/execution read model을 표시하고 로그/아티팩트 진입 위치를 제공한다. +- 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-control-plane-api-data-binding/02+01_execution_surface/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-control-plane-api-data-binding`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Console jobs/executions/artifacts surfaces | [ ] | +| [API-2] Client job/execution read wiring | [ ] | + +## 구현 체크리스트 + +- [ ] `packages/flutter/oto_console`에 jobs/executions/logs/artifacts surface용 view model과 widgets를 추가하고 상태별 widget test를 작성한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 jobs/executions surface 테스트가 통과한다. +- [ ] `apps/client`가 `OtoCoreReadClient.fetchJob`, `fetchExecution`, `fetchLogs`, `fetchArtifacts` 결과를 surface snapshot으로 변환해 `OtoConsoleShell.pipelines`, `executions`, `artifacts`에 주입하고 fake read client test를 작성한다. 검증: `cd apps/client && flutter test`가 통과한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/`를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/02+01_execution_surface/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-control-plane-api-data-binding`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-api-data-binding/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `01_runner_surface`의 `complete.log`가 존재하고 공통 state contract를 실제로 재사용했는지 확인한다. +- Core route/schema 변경 없이 id 기반 job/execution/log/artifact read path만 소비했는지 확인한다. +- logs/artifacts 진입 위치가 execution 화면에서 사용자가 찾을 수 있게 노출되는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### API-1 중간 검증 +```bash +$ cd packages/flutter/oto_console && flutter test +(output) +``` + +### API-2 중간 검증 +```bash +$ cd apps/client && flutter test +(output) +``` + +### 최종 검증 +```bash +$ cd packages/flutter/oto_console && flutter test +(output) +$ cd apps/client && flutter test +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/PLAN-local-G06.md b/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/PLAN-local-G06.md new file mode 100644 index 0000000..773dc93 --- /dev/null +++ b/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/PLAN-local-G06.md @@ -0,0 +1,201 @@ + + +# Plan - API Execution Surface + +## 이 파일을 읽는 구현 에이전트에게 + +구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 출력과 설계 결정을 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 종결 처리, log rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자만 결정할 항목, 사용자 소유 외부 환경, 범위 충돌이 발견되면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 남기고 멈춘다. 구현 중 채팅으로 직접 질문하거나 `request_user_input`을 호출하지 않는다. + +## 배경 + +현재 Pipelines/Jobs, Executions, Artifacts section은 모두 shell의 기본 empty surface다. 선행 runner surface 계획은 console package의 공통 loading/empty/error/data state contract를 만든다. 이 계획은 그 contract를 재사용해 id 기반 Core read path에서 job, execution, logs, artifacts를 읽고 화면에 표시한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사항은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 직접 사용자 프롬프트는 금지되며, code-review가 요청 타당성을 검토하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- Task ids: + - `execution-surface`: Pipelines/Jobs와 Executions 화면이 job/execution read model을 표시하고 로그/아티팩트 진입 위치를 제공한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/control-plane-product-surface/PHASE.md` +- `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- `agent-test/local/rules.md` +- `apps/client/lib/src/app/core_connection_client.dart` +- `apps/client/lib/src/app/oto_client_app.dart` +- `apps/client/test/widget_test.dart` +- `apps/client/pubspec.yaml` +- `apps/client/analysis_options.yaml` +- `packages/flutter/oto_console/lib/oto_console.dart` +- `packages/flutter/oto_console/lib/src/oto_console_contract.dart` +- `packages/flutter/oto_console/lib/src/oto_console_models.dart` +- `packages/flutter/oto_console/lib/src/oto_console_overview.dart` +- `packages/flutter/oto_console/lib/src/oto_console_shell.dart` +- `packages/flutter/oto_console/test/oto_console_test.dart` +- `packages/flutter/oto_console/pubspec.yaml` +- `packages/flutter/oto_console/analysis_options.yaml` +- `services/core/internal/httpserver/routes.go` +- `services/core/internal/httpserver/dto.go` +- `services/core/internal/httpserver/job_handlers.go` +- `services/core/internal/httpserver/execution_handlers.go` +- `services/core/internal/cicdstate/store.go` +- `services/core/internal/runnerregistry/registry.go` + +### 테스트 환경 규칙 + +- `test_env=local`; `agent-test/local/rules.md`를 읽었다. +- matched agent-test profile: 없음. Flutter client/console 구현 경로에 전용 profile이 없다. +- 적용할 fallback source: `agent-test/local/rules.md`의 Monorepo Targets. +- 검증 명령: `cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test`. +- 도구 확인: `command -v flutter`, `command -v dart`, `command -v go` 성공. + +### 테스트 커버리지 공백 + +- jobs/executions/logs/artifacts 화면 contract와 상태 렌더링은 기존 console test에 없다. 새 widget tests가 필요하다. +- app이 `OtoCoreReadClient.fetchJob`, `fetchExecution`, `fetchLogs`, `fetchArtifacts`를 조합하는 흐름은 기존 client test에 없다. fake read client tests가 필요하다. + +### 심볼 참조 + +- renamed/removed symbols: none. + +### 분할 판단 + +- split decision policy를 먼저 평가했다. +- shared task group: `agent-task/m-control-plane-api-data-binding/`. +- current subtask: `02+01_execution_surface`. +- predecessor `01`: missing active/archive `complete.log`. 구현은 `agent-task/m-control-plane-api-data-binding/01_runner_surface/complete.log` 또는 대응 archive `complete.log`가 생긴 뒤 시작한다. +- `03+01,02_preview_smoke`는 이 subtask와 `01_runner_surface` 완료 뒤 실행한다. + +### 범위 결정 근거 + +- Core list endpoint는 추가하지 않는다. `routes.go:63-96`은 job/execution/log/artifact id 기반 read path를 제공하므로, 화면은 known id가 없을 때 empty state를 표시한다. +- runner registry 화면은 `01_runner_surface` 범위다. +- 원격 preview screenshot/curl evidence는 `03+01,02_preview_smoke` 범위다. + +### 빌드 등급 + +- build: `local-G06`, review: `local-G06`. console package public model과 client state wiring이 늘지만, 선행 state contract를 재사용하고 widget/unit tests로 검증 가능하다. + +## 의존 관계 및 구현 순서 + +1. `01_runner_surface`가 PASS되어 `complete.log`를 남긴 뒤 시작한다. +2. 이 subtask는 `02+01_execution_surface` 디렉터리명 그대로 predecessor `01`만 요구한다. +3. 선행 `complete.log`가 없으면 구현하지 말고 review stub의 `사용자 리뷰 요청`이 아니라 계획 대기 상태로 보고한다. + +## 구현 체크리스트 + +- [ ] `packages/flutter/oto_console`에 jobs/executions/logs/artifacts surface용 view model과 widgets를 추가하고 상태별 widget test를 작성한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 jobs/executions surface 테스트가 통과한다. +- [ ] `apps/client`가 `OtoCoreReadClient.fetchJob`, `fetchExecution`, `fetchLogs`, `fetchArtifacts` 결과를 surface snapshot으로 변환해 `OtoConsoleShell.pipelines`, `executions`, `artifacts`에 주입하고 fake read client test를 작성한다. 검증: `cd apps/client && flutter test`가 통과한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [API-1] Console jobs/executions/artifacts surfaces + +문제: shell 기본 section들은 Jobs, Executions, Artifacts를 모두 empty surface로 렌더링한다. + +Before: + +```dart +// packages/flutter/oto_console/lib/src/oto_console_shell.dart:83 +OtoConsoleSection.pipelines => + widget.pipelines ?? + OtoConsoleSectionSurface( + icon: Icons.account_tree_outlined, + title: 'Pipelines / Jobs', + emptyTitle: 'No pipeline jobs', + emptyMessage: 'Job queue is empty.', + theme: theme, + ), +``` + +해결 방법: `01_runner_surface`에서 추가된 공통 state contract를 사용한다. `OtoJobsSurface`, `OtoExecutionsSurface`, `OtoArtifactsSurface`를 새 파일로 분리하고, job id/name/state/execution id, execution id/job id/state, log preview, artifact name/path를 표시한다. logs/artifacts 진입 위치는 execution row/detail 안에 "Logs"와 "Artifacts" action/section으로 제공한다. + +수정 파일 및 체크리스트: + +- [ ] `packages/flutter/oto_console/lib/src/oto_console_contract.dart`에 job/execution/log/artifact view model 추가. +- [ ] `packages/flutter/oto_console/lib/src/oto_jobs_surface.dart` 생성. +- [ ] `packages/flutter/oto_console/lib/src/oto_executions_surface.dart` 생성. +- [ ] `packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart` 생성. +- [ ] `packages/flutter/oto_console/lib/oto_console.dart` export 추가. +- [ ] `packages/flutter/oto_console/test/oto_console_test.dart`에 상태별 widget tests 추가. + +테스트 작성: 작성. 각 surface의 loading/empty/error/data 상태, job/execution 핵심 필드, log/artifact entry label을 검증한다. + +중간 검증: + +```bash +cd packages/flutter/oto_console && flutter test +``` + +기대 결과: jobs/executions/artifacts surface 테스트 포함 전체 통과. + +### [API-2] Client job/execution read wiring + +문제: 작은 선행 작업에서 read adapter는 생겼지만 app은 아직 overview만 shell에 주입한다. + +Before: + +```dart +// apps/client/lib/src/app/oto_client_app.dart:111 +home: OtoConsoleShell( + config: widget.config, + capabilities: widget.capabilities, + themeAdapter: themeAdapter, + overview: OtoConsoleOverview( + config: widget.config, + connection: _connection, + onRefresh: _refreshCoreStatus, + themeAdapter: themeAdapter, + ), +), +``` + +해결 방법: app에 known job/execution id seed를 optional parameter로 추가한다. id가 없으면 empty snapshot을 표시한다. id가 있으면 `core_connection_client.dart:234-339`의 fetch methods를 호출해 job, execution, logs, artifacts snapshot을 구성한다. refresh generation guard를 overview와 같은 방식으로 적용해 stale async result를 막는다. + +수정 파일 및 체크리스트: + +- [ ] `apps/client/lib/src/app/oto_client_app.dart`에 job/execution/artifact snapshot state와 refresh flow 추가. +- [ ] `apps/client/lib/src/app/core_connection_client.dart`의 기존 adapter를 사용하고 endpoint를 추가하지 않는다. +- [ ] `apps/client/test/widget_test.dart`에 fake read client로 data/error/empty 상태 테스트 추가. + +테스트 작성: 작성. fake read client가 `OtoConsoleConfig.serverHttpUrl`을 기준으로 호출되고, 화면에 job/execution/log/artifact data와 empty/error 상태가 표시되는지 검증한다. + +중간 검증: + +```bash +cd apps/client && flutter test +``` + +기대 결과: job/execution wiring 테스트 포함 전체 통과. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `packages/flutter/oto_console/lib/src/oto_console_contract.dart` | API-1 | +| `packages/flutter/oto_console/lib/src/oto_jobs_surface.dart` | API-1 | +| `packages/flutter/oto_console/lib/src/oto_executions_surface.dart` | API-1 | +| `packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart` | API-1 | +| `packages/flutter/oto_console/lib/oto_console.dart` | API-1 | +| `packages/flutter/oto_console/test/oto_console_test.dart` | API-1 | +| `apps/client/lib/src/app/oto_client_app.dart` | API-2 | +| `apps/client/lib/src/app/core_connection_client.dart` | API-2 | +| `apps/client/test/widget_test.dart` | API-2 | + +## 최종 검증 + +```bash +cd packages/flutter/oto_console && flutter test +cd apps/client && flutter test +``` + +기대 결과: 두 명령 모두 통과한다. Flutter test cache 여부는 Dart/Flutter test runner 기본 동작을 따른다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/CODE_REVIEW-cloud-G07.md b/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..75efc0c --- /dev/null +++ b/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,149 @@ + + +# Code Review Reference - TEST + +> **[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. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-17 +task=m-control-plane-api-data-binding/03+01,02_preview_smoke, plan=0, tag=TEST + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- Task ids: + - `preview-data-smoke`: 원격 preview `http://toki-labs.com:13020/` 기준으로 Core health/readiness와 최소 한 화면의 실제 API 상태가 렌더링된다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/03+01,02_preview_smoke/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-control-plane-api-data-binding`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [TEST-1] Dependency gate and Core curl smoke | [ ] | +| [TEST-2] Browser screenshot smoke | [ ] | + +## 구현 체크리스트 + +- [ ] 선행 `01_runner_surface`와 `02+01_execution_surface`의 `complete.log`를 확인하고 없으면 실행하지 않는다. +- [ ] 원격 Core health/readiness와 preview HTTP 응답을 curl로 수집한다. 검증: `curl` 출력이 review stub에 남는다. +- [ ] browser screenshot과 최소 한 화면의 API-backed 상태 렌더링 evidence를 `/tmp/oto-api-binding-smoke`에 남긴다. 검증: screenshot path와 browser stdout이 review stub에 남는다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/`를 `agent-task/archive/YYYY/MM/m-control-plane-api-data-binding/03+01,02_preview_smoke/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-control-plane-api-data-binding`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-api-data-binding/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-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로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `01_runner_surface`와 `02+01_execution_surface` 선행 `complete.log`가 확인되었는지 검토한다. +- curl 출력이 `healthz=OK`, `readyz=OK`, preview HTTP 200 계열을 실제로 보여주는지 확인한다. +- screenshot path가 존재하고, browser stdout 또는 screenshot evidence가 API-backed 화면 상태를 판단하기에 충분한지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### TEST-1 중간 검증 +```bash +$ mkdir -p /tmp/oto-api-binding-smoke +$ curl -fsS --max-time 10 http://toki-labs.com:18020/healthz | tee /tmp/oto-api-binding-smoke/healthz.txt +(output) +$ curl -fsS --max-time 10 http://toki-labs.com:18020/readyz | tee /tmp/oto-api-binding-smoke/readyz.txt +(output) +$ curl -fsSI --max-time 10 http://toki-labs.com:13020/ | tee /tmp/oto-api-binding-smoke/preview-head.txt +(output) +``` + +### TEST-2 중간 검증 +```bash +$ PLAYWRIGHT_BROWSERS_PATH=/tmp/oto-api-binding-smoke/ms-playwright npm_config_cache=/tmp/oto-api-binding-smoke/npm-cache npx --yes playwright@1.45.3 install chromium +(output) +$ PLAYWRIGHT_BROWSERS_PATH=/tmp/oto-api-binding-smoke/ms-playwright npm_config_cache=/tmp/oto-api-binding-smoke/npm-cache npx --yes -p playwright@1.45.3 node /tmp/oto-api-binding-smoke/preview-smoke.mjs +(output) +$ test -s /tmp/oto-api-binding-smoke/preview.png +(output) +``` + +### 최종 검증 +```bash +$ mkdir -p /tmp/oto-api-binding-smoke +(output) +$ curl -fsS --max-time 10 http://toki-labs.com:18020/healthz | tee /tmp/oto-api-binding-smoke/healthz.txt +(output) +$ curl -fsS --max-time 10 http://toki-labs.com:18020/readyz | tee /tmp/oto-api-binding-smoke/readyz.txt +(output) +$ curl -fsSI --max-time 10 http://toki-labs.com:13020/ | tee /tmp/oto-api-binding-smoke/preview-head.txt +(output) +$ test -s /tmp/oto-api-binding-smoke/preview.png +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md b/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md new file mode 100644 index 0000000..fa8a1e6 --- /dev/null +++ b/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md @@ -0,0 +1,196 @@ + + +# Plan - Preview Data Smoke + +## 이 파일을 읽는 구현 에이전트에게 + +구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 검증 출력과 evidence path로 채우는 것이다. 이 작업은 코드 변경보다 원격 preview/browser evidence 수집이 중심이다. 검증을 실행하고, 실제 출력과 스크린샷 경로를 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 종결 처리, log rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자만 결정할 항목, 사용자 소유 외부 환경, 범위 충돌이 발견되면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 남기고 멈춘다. 구현 중 채팅으로 직접 질문하거나 `request_user_input`을 호출하지 않는다. + +## 배경 + +`preview-data-smoke`는 Core health/readiness와 실제 preview 화면 증거가 함께 있어야 닫을 수 있다. 2026-06-17 preflight에서 `http://toki-labs.com:18020/healthz`, `readyz`는 `OK`였고 `http://toki-labs.com:13020/`은 HTTP 200을 반환했다. 그러나 이 Task는 runner/execution surface가 구현된 후 최소 한 화면의 API-backed 상태가 렌더링되는지 확인해야 하므로 선행 split 완료가 필요하다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사항은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 직접 사용자 프롬프트는 금지되며, code-review가 요청 타당성을 검토하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- Task ids: + - `preview-data-smoke`: 원격 preview `http://toki-labs.com:13020/` 기준으로 Core health/readiness와 최소 한 화면의 실제 API 상태가 렌더링된다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/control-plane-product-surface/PHASE.md` +- `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md` +- `agent-test/local/rules.md` +- `apps/client/lib/src/app/core_connection_client.dart` +- `apps/client/lib/src/app/oto_client_app.dart` +- `apps/client/test/widget_test.dart` +- `packages/flutter/oto_console/lib/oto_console.dart` +- `packages/flutter/oto_console/lib/src/oto_console_contract.dart` +- `packages/flutter/oto_console/lib/src/oto_console_shell.dart` +- `packages/flutter/oto_console/test/oto_console_test.dart` +- `services/core/internal/httpserver/routes.go` +- `services/core/internal/httpserver/dto.go` +- `services/core/internal/httpserver/runner_handlers.go` +- `services/core/internal/httpserver/job_handlers.go` +- `services/core/internal/httpserver/execution_handlers.go` + +### 테스트 환경 규칙 + +- `test_env=local`; `agent-test/local/rules.md`를 읽었다. +- matched agent-test profile: 없음. 이 작업은 원격 preview smoke evidence 수집이며 전용 profile이 없다. +- 적용할 fallback source: `agent-test/local/rules.md`의 Workspace 포트/환경 체크리스트와 Monorepo Targets. +- 도구 확인: `command -v curl`, `command -v npx`, `command -v node`, `command -v flutter` 성공. +- 현재 preflight: `curl -fsS --max-time 10 http://toki-labs.com:18020/healthz` -> `OK`, `readyz` -> `OK`, `curl -fsSI --max-time 10 http://toki-labs.com:13020/` -> HTTP 200. + +### 테스트 커버리지 공백 + +- unit/widget test는 remote preview 배포 상태와 browser rendering을 검증하지 않는다. +- screenshot evidence와 curl output을 `CODE_REVIEW-cloud-G07.md`에 실제 출력으로 남겨야 한다. + +### 심볼 참조 + +- renamed/removed symbols: none. + +### 분할 판단 + +- split decision policy를 먼저 평가했다. +- current subtask: `03+01,02_preview_smoke`. +- predecessor `01`: missing active/archive `complete.log`. +- predecessor `02`: missing active/archive `complete.log`. +- 이 smoke는 `01_runner_surface`와 `02+01_execution_surface` PASS 이후만 실행한다. + +### 범위 결정 근거 + +- source code 변경은 기본 범위가 아니다. smoke 중 명백한 preview wiring bug가 발견되면 이 task에서 고치지 말고 review stub에 evidence를 남기고 후속 plan으로 분리한다. +- private token, credential, private release host 원문은 어떤 tracked 파일에도 남기지 않는다. +- Playwright/npm cache와 screenshot은 `/tmp/oto-api-binding-smoke` 아래에만 둔다. + +### 빌드 등급 + +- build: `cloud-G07`, review: `cloud-G07`. 원격 preview, browser screenshot, 외부 API 상태 증거가 성공 조건의 중심이라 local deterministic review만으로 충분하지 않다. + +## 의존 관계 및 구현 순서 + +1. `01_runner_surface`와 `02+01_execution_surface`가 PASS되어 각각 `complete.log`를 남겼는지 확인한다. +2. 선행 `complete.log`가 없으면 smoke를 실행하지 않고 active files를 그대로 둔다. +3. 선행 충족 후 원격 Core curl evidence를 수집한다. +4. browser screenshot과 body text evidence를 수집한다. +5. 모든 실제 stdout/stderr와 screenshot path를 review stub에 붙인다. + +## 구현 체크리스트 + +- [ ] 선행 `01_runner_surface`와 `02+01_execution_surface`의 `complete.log`를 확인하고 없으면 실행하지 않는다. +- [ ] 원격 Core health/readiness와 preview HTTP 응답을 curl로 수집한다. 검증: `curl` 출력이 review stub에 남는다. +- [ ] browser screenshot과 최소 한 화면의 API-backed 상태 렌더링 evidence를 `/tmp/oto-api-binding-smoke`에 남긴다. 검증: screenshot path와 browser stdout이 review stub에 남는다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [TEST-1] Dependency gate and Core curl smoke + +문제: 이 smoke는 UI surface 구현 완료 전에는 의미 있는 completion evidence가 될 수 없다. + +해결 방법: 선행 `complete.log`를 확인한 뒤 curl evidence를 수집한다. + +수정 파일 및 체크리스트: + +- [ ] source file 변경 없음. +- [ ] `agent-task/m-control-plane-api-data-binding/01_runner_surface/complete.log` 또는 대응 archive path 확인. +- [ ] `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/complete.log` 또는 대응 archive path 확인. +- [ ] `/tmp/oto-api-binding-smoke`에 curl output 저장. + +테스트 작성: 생략. 이 항목은 원격 smoke evidence 수집이다. + +중간 검증: + +```bash +mkdir -p /tmp/oto-api-binding-smoke +curl -fsS --max-time 10 http://toki-labs.com:18020/healthz | tee /tmp/oto-api-binding-smoke/healthz.txt +curl -fsS --max-time 10 http://toki-labs.com:18020/readyz | tee /tmp/oto-api-binding-smoke/readyz.txt +curl -fsSI --max-time 10 http://toki-labs.com:13020/ | tee /tmp/oto-api-binding-smoke/preview-head.txt +``` + +기대 결과: `healthz.txt`와 `readyz.txt`에 `OK`, `preview-head.txt` 첫 줄에 HTTP 200 계열 상태가 남는다. + +### [TEST-2] Browser screenshot smoke + +문제: curl만으로는 Flutter preview가 API-backed 상태를 실제 화면에 렌더링했는지 확인할 수 없다. + +해결 방법: Playwright를 `/tmp` cache로만 사용해 screenshot과 body text evidence를 남긴다. Browser setup 또는 rendering이 실패하면 stdout/stderr를 그대로 review stub에 남긴다. + +수정 파일 및 체크리스트: + +- [ ] source file 변경 없음. +- [ ] `/tmp/oto-api-binding-smoke/preview-smoke.mjs` 작성. +- [ ] `/tmp/oto-api-binding-smoke/preview.png` 생성. +- [ ] browser stdout에 preview URL, screenshot path, body text excerpt 또는 capture 불가 사유 기록. + +테스트 작성: 생략. screenshot smoke evidence가 검증 산출물이다. + +중간 검증: + +```bash +mkdir -p /tmp/oto-api-binding-smoke +cat >/tmp/oto-api-binding-smoke/preview-smoke.mjs <<'NODE' +import { chromium } from 'playwright'; + +const screenshotPath = '/tmp/oto-api-binding-smoke/preview.png'; +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +await page.goto('http://toki-labs.com:13020/', { + waitUntil: 'networkidle', + timeout: 60000, +}); +await page.screenshot({ path: screenshotPath, fullPage: true }); +const bodyText = await page.locator('body').innerText({ timeout: 5000 }).catch((error) => { + return `BODY_TEXT_UNAVAILABLE: ${error.message}`; +}); +console.log(`preview_url=http://toki-labs.com:13020/`); +console.log(`screenshot=${screenshotPath}`); +console.log('body_text_start'); +console.log(bodyText.slice(0, 2000)); +console.log('body_text_end'); +await browser.close(); + +if (!bodyText.includes('Build Operations Overview') && !bodyText.includes('BODY_TEXT_UNAVAILABLE')) { + throw new Error('preview body text did not include the overview heading'); +} +NODE +PLAYWRIGHT_BROWSERS_PATH=/tmp/oto-api-binding-smoke/ms-playwright \ + npm_config_cache=/tmp/oto-api-binding-smoke/npm-cache \ + npx --yes playwright@1.45.3 install chromium +PLAYWRIGHT_BROWSERS_PATH=/tmp/oto-api-binding-smoke/ms-playwright \ + npm_config_cache=/tmp/oto-api-binding-smoke/npm-cache \ + npx --yes -p playwright@1.45.3 node /tmp/oto-api-binding-smoke/preview-smoke.mjs +test -s /tmp/oto-api-binding-smoke/preview.png +``` + +기대 결과: `preview.png`가 생성되고 stdout에 preview URL, screenshot path, body text excerpt 또는 body text unavailable 사유가 남는다. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| source 변경 없음 | TEST-1, TEST-2 | +| `/tmp/oto-api-binding-smoke/*` | TEST-1, TEST-2 | +| `agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/CODE_REVIEW-cloud-G07.md` | TEST-1, TEST-2 | + +## 최종 검증 + +```bash +mkdir -p /tmp/oto-api-binding-smoke +curl -fsS --max-time 10 http://toki-labs.com:18020/healthz | tee /tmp/oto-api-binding-smoke/healthz.txt +curl -fsS --max-time 10 http://toki-labs.com:18020/readyz | tee /tmp/oto-api-binding-smoke/readyz.txt +curl -fsSI --max-time 10 http://toki-labs.com:13020/ | tee /tmp/oto-api-binding-smoke/preview-head.txt +test -s /tmp/oto-api-binding-smoke/preview.png +``` + +기대 결과: health/readiness OK, preview HTTP 200 계열, screenshot file exists. Browser stdout과 screenshot path는 반드시 review stub에 붙인다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/client/lib/src/app/core_connection_client.dart b/apps/client/lib/src/app/core_connection_client.dart index 0622ccb..c9836ec 100644 --- a/apps/client/lib/src/app/core_connection_client.dart +++ b/apps/client/lib/src/app/core_connection_client.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:oto_console/oto_console.dart'; @@ -95,3 +96,518 @@ class OtoHttpCoreConnectionClient implements OtoCoreConnectionClient { return OtoCoreConnectionState.offline; } } + +enum OtoCoreReadState { loading, empty, data, error } + +class OtoCoreReadResult { + final OtoCoreReadState state; + final T? data; + final String message; + final int? statusCode; + + const OtoCoreReadResult._({ + required this.state, + this.data, + required this.message, + this.statusCode, + }); + + const OtoCoreReadResult.loading({String message = 'Loading'}) + : this._(state: OtoCoreReadState.loading, message: message); + + const OtoCoreReadResult.empty({ + T? data, + String message = 'No data', + int? statusCode, + }) : this._( + state: OtoCoreReadState.empty, + data: data, + message: message, + statusCode: statusCode, + ); + + const OtoCoreReadResult.data(T data, {String message = 'OK', int? statusCode}) + : this._( + state: OtoCoreReadState.data, + data: data, + message: message, + statusCode: statusCode, + ); + + const OtoCoreReadResult.error(String message, {int? statusCode}) + : this._( + state: OtoCoreReadState.error, + message: message, + statusCode: statusCode, + ); + + bool get hasData => state == OtoCoreReadState.data && data != null; +} + +abstract class OtoCoreReadClient { + Future> fetchRunner( + OtoConsoleConfig config, + String runnerID, + ); + + Future> fetchRunnerStatus( + OtoConsoleConfig config, + String runnerID, + ); + + Future> fetchJob( + OtoConsoleConfig config, + String jobID, + ); + + Future> fetchExecution( + OtoConsoleConfig config, + String executionID, + ); + + Future>> fetchLogs( + OtoConsoleConfig config, + String executionID, + ); + + Future>> fetchArtifacts( + OtoConsoleConfig config, + String executionID, + ); +} + +class OtoHttpCoreReadClient implements OtoCoreReadClient { + final http.Client _httpClient; + final Duration timeout; + + OtoHttpCoreReadClient({ + http.Client? httpClient, + this.timeout = const Duration(seconds: 2), + }) : _httpClient = httpClient ?? http.Client(); + + @override + Future> fetchRunner( + OtoConsoleConfig config, + String runnerID, + ) async { + final result = await _getObject( + config, + '/api/v1/runners/$runnerID', + emptyMessage: 'Runner not found', + ); + if (!result.hasData) { + return _forwardState(result); + } + try { + return OtoCoreReadResult.data( + OtoRunnerRecord.fromJson(result.data!), + statusCode: result.statusCode, + ); + } on FormatException catch (error) { + return OtoCoreReadResult.error(error.message); + } + } + + @override + Future> fetchRunnerStatus( + OtoConsoleConfig config, + String runnerID, + ) async { + final result = await _getObject( + config, + '/api/v1/runners/$runnerID/status', + emptyMessage: 'Runner status not found', + ); + if (!result.hasData) { + return _forwardState(result); + } + try { + return OtoCoreReadResult.data( + OtoRunnerStatus.fromJson(result.data!), + statusCode: result.statusCode, + ); + } on FormatException catch (error) { + return OtoCoreReadResult.error(error.message); + } + } + + @override + Future> fetchJob( + OtoConsoleConfig config, + String jobID, + ) async { + final result = await _getObject( + config, + '/api/v1/jobs/$jobID', + emptyMessage: 'Job not found', + ); + if (!result.hasData) { + return _forwardState(result); + } + try { + return OtoCoreReadResult.data( + OtoJobRecord.fromJson(result.data!), + statusCode: result.statusCode, + ); + } on FormatException catch (error) { + return OtoCoreReadResult.error(error.message); + } + } + + @override + Future> fetchExecution( + OtoConsoleConfig config, + String executionID, + ) async { + final result = await _getObject( + config, + '/api/v1/executions/$executionID', + emptyMessage: 'Execution not found', + ); + if (!result.hasData) { + return _forwardState(result); + } + try { + return OtoCoreReadResult.data( + OtoExecutionRecord.fromJson(result.data!), + statusCode: result.statusCode, + ); + } on FormatException catch (error) { + return OtoCoreReadResult.error(error.message); + } + } + + @override + Future>> fetchLogs( + OtoConsoleConfig config, + String executionID, + ) async { + final result = await _getObject( + config, + '/api/v1/executions/$executionID/logs', + emptyMessage: 'Execution logs not found', + ); + if (!result.hasData) { + return _forwardState(result); + } + try { + final logs = _jsonList( + result.data!, + 'logs', + ).map(OtoExecutionLogEntry.fromJson).toList(growable: false); + if (logs.isEmpty) { + return OtoCoreReadResult.empty( + data: logs, + message: 'No logs', + statusCode: result.statusCode, + ); + } + return OtoCoreReadResult.data(logs, statusCode: result.statusCode); + } on FormatException catch (error) { + return OtoCoreReadResult.error(error.message); + } + } + + @override + Future>> fetchArtifacts( + OtoConsoleConfig config, + String executionID, + ) async { + final result = await _getObject( + config, + '/api/v1/executions/$executionID/artifacts', + emptyMessage: 'Execution artifacts not found', + ); + if (!result.hasData) { + return _forwardState(result); + } + try { + final artifacts = _jsonList( + result.data!, + 'artifacts', + ).map(OtoArtifactRecord.fromJson).toList(growable: false); + if (artifacts.isEmpty) { + return OtoCoreReadResult.empty( + data: artifacts, + message: 'No artifacts', + statusCode: result.statusCode, + ); + } + return OtoCoreReadResult.data(artifacts, statusCode: result.statusCode); + } on FormatException catch (error) { + return OtoCoreReadResult.error(error.message); + } + } + + Future>> _getObject( + OtoConsoleConfig config, + String path, { + required String emptyMessage, + }) async { + try { + final response = await _httpClient + .get(_endpointUri(config.serverHttpUrl, path)) + .timeout(timeout); + if (response.statusCode == 404) { + return OtoCoreReadResult.empty( + message: emptyMessage, + statusCode: response.statusCode, + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + return OtoCoreReadResult.error( + 'HTTP ${response.statusCode}: ${response.body.trim()}', + statusCode: response.statusCode, + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + return const OtoCoreReadResult.error('Expected JSON object'); + } + return OtoCoreReadResult.data( + Map.from(decoded), + statusCode: response.statusCode, + ); + } on TimeoutException { + return const OtoCoreReadResult.error('Timed out'); + } on FormatException catch (error) { + return OtoCoreReadResult.error(error.message); + } on Exception catch (error) { + return OtoCoreReadResult.error(error.toString()); + } + } +} + +class OtoRunnerRecord { + final String runnerID; + final String alias; + final String protocolVersion; + final String status; + final DateTime? acceptedAt; + final DateTime? firstHeartbeatAt; + final DateTime? lastHeartbeatAt; + final String failureReason; + + const OtoRunnerRecord({ + required this.runnerID, + required this.alias, + required this.protocolVersion, + required this.status, + required this.acceptedAt, + required this.firstHeartbeatAt, + required this.lastHeartbeatAt, + required this.failureReason, + }); + + factory OtoRunnerRecord.fromJson(Map json) { + final runnerID = _requiredString(json, 'runner_id'); + return OtoRunnerRecord( + runnerID: runnerID, + alias: _string(json, 'alias'), + protocolVersion: _string(json, 'protocol_version'), + status: _string(json, 'status'), + acceptedAt: _dateTime(json, 'accepted_at'), + firstHeartbeatAt: _dateTime(json, 'first_heartbeat_at'), + lastHeartbeatAt: _dateTime(json, 'last_heartbeat_at'), + failureReason: _string(json, 'failure_reason'), + ); + } +} + +class OtoRunnerStatus { + final bool accepted; + final String runnerID; + final String status; + final String currentJobID; + final String currentExecutionID; + final String message; + + const OtoRunnerStatus({ + required this.accepted, + required this.runnerID, + required this.status, + required this.currentJobID, + required this.currentExecutionID, + required this.message, + }); + + factory OtoRunnerStatus.fromJson(Map json) { + return OtoRunnerStatus( + accepted: json['accepted'] == true, + runnerID: _string(json, 'runner_id'), + status: _string(json, 'status'), + currentJobID: _string(json, 'current_job_id'), + currentExecutionID: _string(json, 'current_execution_id'), + message: _string(json, 'message'), + ); + } +} + +class OtoJobRecord { + final String id; + final String name; + final String state; + final DateTime? createdAt; + final DateTime? updatedAt; + final String executionID; + final Map? runRequest; + + const OtoJobRecord({ + required this.id, + required this.name, + required this.state, + required this.createdAt, + required this.updatedAt, + required this.executionID, + required this.runRequest, + }); + + factory OtoJobRecord.fromJson(Map json) { + return OtoJobRecord( + id: _requiredString(json, 'id'), + name: _string(json, 'name'), + state: _string(json, 'state'), + createdAt: _dateTime(json, 'created_at'), + updatedAt: _dateTime(json, 'updated_at'), + executionID: _string(json, 'execution_id'), + runRequest: _optionalObject(json, 'run_request'), + ); + } +} + +class OtoExecutionRecord { + final String id; + final String jobID; + final String state; + final DateTime? createdAt; + final DateTime? updatedAt; + final String executionID; + + const OtoExecutionRecord({ + required this.id, + required this.jobID, + required this.state, + required this.createdAt, + required this.updatedAt, + required this.executionID, + }); + + factory OtoExecutionRecord.fromJson(Map json) { + return OtoExecutionRecord( + id: _requiredString(json, 'id'), + jobID: _string(json, 'job_id'), + state: _string(json, 'state'), + createdAt: _dateTime(json, 'created_at'), + updatedAt: _dateTime(json, 'updated_at'), + executionID: _string(json, 'execution_id'), + ); + } +} + +class OtoExecutionLogEntry { + final DateTime? timestamp; + final String line; + + const OtoExecutionLogEntry({required this.timestamp, required this.line}); + + factory OtoExecutionLogEntry.fromJson(Map json) { + return OtoExecutionLogEntry( + timestamp: _dateTime(json, 'Timestamp') ?? _dateTime(json, 'timestamp'), + line: _string(json, 'Line').isNotEmpty + ? _string(json, 'Line') + : _string(json, 'line'), + ); + } +} + +class OtoArtifactRecord { + final String name; + final String path; + + const OtoArtifactRecord({required this.name, required this.path}); + + factory OtoArtifactRecord.fromJson(Map json) { + return OtoArtifactRecord( + name: _string(json, 'Name').isNotEmpty + ? _string(json, 'Name') + : _string(json, 'name'), + path: _string(json, 'Path').isNotEmpty + ? _string(json, 'Path') + : _string(json, 'path'), + ); + } +} + +OtoCoreReadResult _forwardState( + OtoCoreReadResult> result, +) { + return switch (result.state) { + OtoCoreReadState.loading => OtoCoreReadResult.loading( + message: result.message, + ), + OtoCoreReadState.empty => OtoCoreReadResult.empty( + message: result.message, + statusCode: result.statusCode, + ), + OtoCoreReadState.error => OtoCoreReadResult.error( + result.message, + statusCode: result.statusCode, + ), + OtoCoreReadState.data => OtoCoreReadResult.error(result.message), + }; +} + +Uri _endpointUri(String baseUrl, String path) { + final base = Uri.parse(baseUrl); + if (!base.hasScheme || base.host.isEmpty) { + throw const FormatException('Invalid OTO server HTTP URL'); + } + return base.replace(path: path, query: null, fragment: null); +} + +String _requiredString(Map json, String key) { + final value = _string(json, key); + if (value.isEmpty) { + throw FormatException('Missing required field: $key'); + } + return value; +} + +String _string(Map json, String key) { + final value = json[key]; + return value == null ? '' : value.toString(); +} + +DateTime? _dateTime(Map json, String key) { + final value = _string(json, key); + if (value.isEmpty) { + return null; + } + return DateTime.tryParse(value); +} + +Map? _optionalObject(Map json, String key) { + final value = json[key]; + if (value == null) { + return null; + } + if (value is! Map) { + throw FormatException('Expected object field: $key'); + } + return Map.from(value); +} + +List> _jsonList(Map json, String key) { + final value = json[key]; + if (value is! List) { + throw FormatException('Expected array field: $key'); + } + return value + .map((item) { + if (item is! Map) { + throw FormatException('Expected object item in: $key'); + } + return Map.from(item); + }) + .toList(growable: false); +} diff --git a/apps/client/lib/src/app/oto_client_app.dart b/apps/client/lib/src/app/oto_client_app.dart index 0cdd39d..1e1ad4c 100644 --- a/apps/client/lib/src/app/oto_client_app.dart +++ b/apps/client/lib/src/app/oto_client_app.dart @@ -32,6 +32,8 @@ class OtoClientApp extends StatefulWidget { final OtoConsoleConfig config; final OtoCoreConnectionSnapshot initialConnection; final OtoCoreConnectionClient? coreClient; + final OtoCoreReadClient? readClient; + final List runnerIDs; final OtoCapabilityPack capabilities; const OtoClientApp({ @@ -48,6 +50,8 @@ class OtoClientApp extends StatefulWidget { ), this.initialConnection = const OtoCoreConnectionSnapshot.checking(), this.coreClient, + this.readClient, + this.runnerIDs = const [], this.capabilities = otoDefaultCapabilityPack, }); @@ -58,7 +62,10 @@ class OtoClientApp extends StatefulWidget { class _OtoClientAppState extends State { late final OtoCoreConnectionClient _defaultCoreClient = OtoHttpCoreConnectionClient(); + late final OtoCoreReadClient _defaultReadClient = OtoHttpCoreReadClient(); late OtoCoreConnectionSnapshot _connection = widget.initialConnection; + OtoSurfaceSnapshot> _runnersSnapshot = + const OtoSurfaceSnapshot.loading(); int _refreshGeneration = 0; @override @@ -71,28 +78,126 @@ class _OtoClientAppState extends State { void didUpdateWidget(covariant OtoClientApp oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.config.serverHttpUrl != widget.config.serverHttpUrl || - oldWidget.coreClient != widget.coreClient) { + oldWidget.coreClient != widget.coreClient || + oldWidget.readClient != widget.readClient || + !_listEquals(oldWidget.runnerIDs, widget.runnerIDs)) { _refreshCoreStatus(); } } + bool _listEquals(List? a, List? b) { + if (a == b) return true; + if (a == null || b == null) return false; + if (a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + Future _refreshCoreStatus() async { final generation = ++_refreshGeneration; setState(() { _connection = const OtoCoreConnectionSnapshot.checking(); + _runnersSnapshot = const OtoSurfaceSnapshot.loading(); }); - final client = widget.coreClient ?? _defaultCoreClient; - final connection = await client.check(widget.config); + final coreClient = widget.coreClient ?? _defaultCoreClient; + final readClient = widget.readClient ?? _defaultReadClient; + + final Future connectionFuture = coreClient.check( + widget.config, + ); + + final Future>> runnersFuture = + _fetchRunners(readClient); + + final results = await Future.wait([connectionFuture, runnersFuture]); + final connection = results[0] as OtoCoreConnectionSnapshot; + final runnersSnapshot = + results[1] as OtoSurfaceSnapshot>; + if (!mounted || generation != _refreshGeneration) { return; } setState(() { _connection = connection; + _runnersSnapshot = runnersSnapshot; }); } + Future>> _fetchRunners( + OtoCoreReadClient readClient, + ) async { + if (widget.runnerIDs.isEmpty) { + return const OtoSurfaceSnapshot.empty(); + } + + try { + final results = await Future.wait( + widget.runnerIDs.map((id) async { + final recordResult = await readClient.fetchRunner(widget.config, id); + final statusResult = await readClient.fetchRunnerStatus( + widget.config, + id, + ); + return _RunnerFetchResult(id, recordResult, statusResult); + }), + ); + + final viewModels = []; + String? errorMessage; + bool isLoading = false; + bool isEmpty = true; + + for (final result in results) { + if (result.recordResult.state == OtoCoreReadState.error) { + errorMessage = result.recordResult.message; + break; + } + if (result.statusResult.state == OtoCoreReadState.error) { + errorMessage = result.statusResult.message; + break; + } + if (result.recordResult.state == OtoCoreReadState.loading || + result.statusResult.state == OtoCoreReadState.loading) { + isLoading = true; + break; + } + if (result.recordResult.state == OtoCoreReadState.data) { + isEmpty = false; + final record = result.recordResult.data!; + final status = result.statusResult.state == OtoCoreReadState.data + ? result.statusResult.data + : null; + viewModels.add( + OtoRunnerViewModel( + runnerID: record.runnerID, + alias: record.alias, + status: status?.status ?? record.status, + currentJobID: status?.currentJobID ?? '', + currentExecutionID: status?.currentExecutionID ?? '', + message: status?.message ?? record.failureReason, + ), + ); + } + } + + if (errorMessage != null) { + return OtoSurfaceSnapshot.error(errorMessage); + } else if (isLoading) { + return const OtoSurfaceSnapshot.loading(); + } else if (isEmpty) { + return const OtoSurfaceSnapshot.empty(); + } else { + return OtoSurfaceSnapshot.data(viewModels); + } + } on Exception catch (e) { + return OtoSurfaceSnapshot.error(e.toString()); + } + } + @override Widget build(BuildContext context) { const themeAdapter = OtoConsoleThemeAdapter(); @@ -118,7 +223,19 @@ class _OtoClientAppState extends State { onRefresh: _refreshCoreStatus, themeAdapter: themeAdapter, ), + runners: OtoRunnersSurface( + snapshot: _runnersSnapshot, + themeAdapter: themeAdapter, + ), ), ); } } + +class _RunnerFetchResult { + final String id; + final OtoCoreReadResult recordResult; + final OtoCoreReadResult statusResult; + + _RunnerFetchResult(this.id, this.recordResult, this.statusResult); +} diff --git a/apps/client/test/widget_test.dart b/apps/client/test/widget_test.dart index 1faa398..cafe31d 100644 --- a/apps/client/test/widget_test.dart +++ b/apps/client/test/widget_test.dart @@ -1,6 +1,11 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:oto_console/oto_console.dart'; +import 'package:oto_client/src/app/core_connection_client.dart'; import 'package:oto_client/src/app/oto_client_app.dart'; void main() { @@ -20,6 +25,233 @@ void main() { expect(find.textContaining('OTO agent surface is ready'), findsOneWidget); expect(find.textContaining('Runner Registry'), findsOneWidget); }); + + test( + 'OtoHttpCoreReadClient maps Core read responses to data states', + () async { + final requestedPaths = []; + final client = OtoHttpCoreReadClient( + httpClient: MockClient((request) async { + requestedPaths.add(request.url.path); + return switch (request.url.path) { + '/api/v1/runners/runner-1' => _jsonResponse({ + 'runner_id': 'runner-1', + 'alias': 'mac-mini', + 'protocol_version': 'oto.runner.v1', + 'status': 'online', + 'accepted_at': '2026-06-17T01:00:00Z', + 'first_heartbeat_at': '2026-06-17T01:01:00Z', + 'last_heartbeat_at': '2026-06-17T01:02:00Z', + 'failure_reason': '', + }), + '/api/v1/runners/runner-1/status' => _jsonResponse({ + 'accepted': true, + 'runner_id': 'runner-1', + 'status': 'online', + 'current_job_id': 'job-1', + 'current_execution_id': 'exec-1', + 'message': '', + }), + '/api/v1/jobs/job-1' => _jsonResponse({ + 'id': 'job-1', + 'name': 'build', + 'state': 'running', + 'created_at': '2026-06-17T01:00:00Z', + 'updated_at': '2026-06-17T01:02:00Z', + 'execution_id': 'exec-1', + 'run_request': {'pipeline_yaml_path': '/pipelines/build.yaml'}, + }), + '/api/v1/executions/exec-1' => _jsonResponse({ + 'id': 'exec-1', + 'job_id': 'job-1', + 'state': 'running', + 'created_at': '2026-06-17T01:00:00Z', + 'updated_at': '2026-06-17T01:02:00Z', + 'execution_id': 'exec-1', + }), + '/api/v1/executions/exec-1/logs' => _jsonResponse({ + 'logs': [ + {'Timestamp': '2026-06-17T01:01:00Z', 'Line': 'build started'}, + ], + }), + '/api/v1/executions/exec-1/artifacts' => _jsonResponse({ + 'artifacts': [ + {'Name': 'app.zip', 'Path': '/artifacts/app.zip'}, + ], + }), + _ => http.Response('not found', 404), + }; + }), + ); + const config = OtoConsoleConfig( + serverHttpUrl: 'http://core.example.test:18020', + serverWireUrl: 'ws://core.example.test:18080/runner', + ); + + final runner = await client.fetchRunner(config, 'runner-1'); + final status = await client.fetchRunnerStatus(config, 'runner-1'); + final job = await client.fetchJob(config, 'job-1'); + final execution = await client.fetchExecution(config, 'exec-1'); + final logs = await client.fetchLogs(config, 'exec-1'); + final artifacts = await client.fetchArtifacts(config, 'exec-1'); + + expect(runner.state, OtoCoreReadState.data); + expect(runner.data!.runnerID, 'runner-1'); + expect(status.data!.currentExecutionID, 'exec-1'); + expect( + job.data!.runRequest!['pipeline_yaml_path'], + '/pipelines/build.yaml', + ); + expect(execution.data!.jobID, 'job-1'); + expect(logs.data!.single.line, 'build started'); + expect(artifacts.data!.single.name, 'app.zip'); + expect(requestedPaths, [ + '/api/v1/runners/runner-1', + '/api/v1/runners/runner-1/status', + '/api/v1/jobs/job-1', + '/api/v1/executions/exec-1', + '/api/v1/executions/exec-1/logs', + '/api/v1/executions/exec-1/artifacts', + ]); + }, + ); + + test('OtoHttpCoreReadClient maps empty and error states', () async { + final client = OtoHttpCoreReadClient( + httpClient: MockClient((request) async { + return switch (request.url.path) { + '/api/v1/runners/missing' => http.Response('not found', 404), + '/api/v1/jobs/failing' => http.Response('boom', 500), + '/api/v1/executions/exec-empty/logs' => _jsonResponse({'logs': []}), + _ => http.Response('not found', 404), + }; + }), + ); + const config = OtoConsoleConfig( + serverHttpUrl: 'http://core.example.test:18020', + serverWireUrl: 'ws://core.example.test:18080/runner', + ); + + final missingRunner = await client.fetchRunner(config, 'missing'); + final failingJob = await client.fetchJob(config, 'failing'); + final emptyLogs = await client.fetchLogs(config, 'exec-empty'); + final invalidConfig = await client.fetchExecution( + const OtoConsoleConfig( + serverHttpUrl: 'not-a-url', + serverWireUrl: 'ws://core.example.test:18080/runner', + ), + 'exec-1', + ); + + expect(missingRunner.state, OtoCoreReadState.empty); + expect(missingRunner.statusCode, 404); + expect(failingJob.state, OtoCoreReadState.error); + expect(failingJob.statusCode, 500); + expect(emptyLogs.state, OtoCoreReadState.empty); + expect(emptyLogs.data, isEmpty); + expect(invalidConfig.state, OtoCoreReadState.error); + expect(invalidConfig.message, 'Invalid OTO server HTTP URL'); + }); + + testWidgets( + 'OtoClientApp wires runners loading empty state when no runnerIDs', + (tester) async { + await tester.pumpWidget( + OtoClientApp(coreClient: _FakeCoreClient(), runnerIDs: const []), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Runners')); + await tester.pumpAndSettle(); + + expect(find.text('No runners connected'), findsOneWidget); + expect(find.text('Runner registry is empty.'), findsOneWidget); + }, + ); + + testWidgets('OtoClientApp wires runners error state on API failure', ( + tester, + ) async { + final fakeReadClient = _FakeCoreReadClient( + onFetchRunner: (id) => const OtoCoreReadResult.error( + 'Internal Server Error', + statusCode: 500, + ), + onFetchRunnerStatus: (id) => const OtoCoreReadResult.empty(), + ); + + await tester.pumpWidget( + OtoClientApp( + coreClient: _FakeCoreClient(), + readClient: fakeReadClient, + runnerIDs: const ['runner-fail'], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Runners')); + await tester.pumpAndSettle(); + + expect(find.text('Error loading runners'), findsOneWidget); + expect(find.text('Internal Server Error'), findsOneWidget); + expect(fakeReadClient.lastUsedConfig, isNotNull); + }); + + testWidgets('OtoClientApp wires runners data state on API success', ( + tester, + ) async { + final fakeReadClient = _FakeCoreReadClient( + onFetchRunner: (id) => OtoCoreReadResult.data( + OtoRunnerRecord( + runnerID: id, + alias: 'Agent Mini', + protocolVersion: 'v1', + status: 'active', + acceptedAt: DateTime.now(), + firstHeartbeatAt: DateTime.now(), + lastHeartbeatAt: DateTime.now(), + failureReason: '', + ), + ), + onFetchRunnerStatus: (id) => OtoCoreReadResult.data( + OtoRunnerStatus( + accepted: true, + runnerID: id, + status: 'active', + currentJobID: 'job-999', + currentExecutionID: 'exec-888', + message: 'All good', + ), + ), + ); + + await tester.pumpWidget( + OtoClientApp( + coreClient: _FakeCoreClient(), + readClient: fakeReadClient, + runnerIDs: const ['runner-ok'], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Runners')); + await tester.pumpAndSettle(); + + expect(find.text('ID: runner-ok'), findsOneWidget); + expect(find.text('Alias: Agent Mini'), findsOneWidget); + expect(find.text('ACTIVE'), findsOneWidget); + expect(find.text('Message: All good'), findsOneWidget); + expect(find.textContaining('Job: job-999'), findsOneWidget); + expect(find.textContaining('Execution: exec-888'), findsOneWidget); + }); +} + +http.Response _jsonResponse(Map body) { + return http.Response( + jsonEncode(body), + 200, + headers: {'content-type': 'application/json'}, + ); } class _FakeCoreClient implements OtoCoreConnectionClient { @@ -45,3 +277,69 @@ class _FakeCoreClient implements OtoCoreConnectionClient { ); } } + +class _FakeCoreReadClient implements OtoCoreReadClient { + final OtoCoreReadResult Function(String runnerID)? + onFetchRunner; + final OtoCoreReadResult Function(String runnerID)? + onFetchRunnerStatus; + OtoConsoleConfig? lastUsedConfig; + + _FakeCoreReadClient({this.onFetchRunner, this.onFetchRunnerStatus}); + + @override + Future> fetchRunner( + OtoConsoleConfig config, + String runnerID, + ) async { + lastUsedConfig = config; + if (onFetchRunner != null) { + return onFetchRunner!(runnerID); + } + return const OtoCoreReadResult.empty(); + } + + @override + Future> fetchRunnerStatus( + OtoConsoleConfig config, + String runnerID, + ) async { + lastUsedConfig = config; + if (onFetchRunnerStatus != null) { + return onFetchRunnerStatus!(runnerID); + } + return const OtoCoreReadResult.empty(); + } + + @override + Future> fetchJob( + OtoConsoleConfig config, + String jobID, + ) async { + return const OtoCoreReadResult.empty(); + } + + @override + Future> fetchExecution( + OtoConsoleConfig config, + String executionID, + ) async { + return const OtoCoreReadResult.empty(); + } + + @override + Future>> fetchLogs( + OtoConsoleConfig config, + String executionID, + ) async { + return const OtoCoreReadResult.empty(); + } + + @override + Future>> fetchArtifacts( + OtoConsoleConfig config, + String executionID, + ) async { + return const OtoCoreReadResult.empty(); + } +} diff --git a/packages/flutter/oto_console/lib/oto_console.dart b/packages/flutter/oto_console/lib/oto_console.dart index c487f49..b85b1e8 100644 --- a/packages/flutter/oto_console/lib/oto_console.dart +++ b/packages/flutter/oto_console/lib/oto_console.dart @@ -2,3 +2,4 @@ export 'src/oto_agent_panel.dart'; export 'src/oto_console_contract.dart'; export 'src/oto_console_overview.dart'; export 'src/oto_console_shell.dart'; +export 'src/oto_runners_surface.dart'; diff --git a/packages/flutter/oto_console/lib/src/oto_console_contract.dart b/packages/flutter/oto_console/lib/src/oto_console_contract.dart index 9ab7451..8704668 100644 --- a/packages/flutter/oto_console/lib/src/oto_console_contract.dart +++ b/packages/flutter/oto_console/lib/src/oto_console_contract.dart @@ -132,3 +132,50 @@ class OtoCoreConnectionSnapshot { abstract class OtoCoreConnectionClient { Future check(OtoConsoleConfig config); } + +enum OtoSurfaceLoadState { loading, empty, error, data } + +class OtoSurfaceSnapshot { + final OtoSurfaceLoadState state; + final T? data; + final String? errorMessage; + + const OtoSurfaceSnapshot({required this.state, this.data, this.errorMessage}); + + const OtoSurfaceSnapshot.loading() + : state = OtoSurfaceLoadState.loading, + data = null, + errorMessage = null; + + const OtoSurfaceSnapshot.empty() + : state = OtoSurfaceLoadState.empty, + data = null, + errorMessage = null; + + const OtoSurfaceSnapshot.error(String message) + : state = OtoSurfaceLoadState.error, + data = null, + errorMessage = message; + + const OtoSurfaceSnapshot.data(this.data) + : state = OtoSurfaceLoadState.data, + errorMessage = null; +} + +class OtoRunnerViewModel { + final String runnerID; + final String alias; + final String status; + final String currentJobID; + final String currentExecutionID; + final String message; + + const OtoRunnerViewModel({ + required this.runnerID, + required this.alias, + required this.status, + required this.currentJobID, + required this.currentExecutionID, + required this.message, + }); +} diff --git a/packages/flutter/oto_console/lib/src/oto_runners_surface.dart b/packages/flutter/oto_console/lib/src/oto_runners_surface.dart new file mode 100644 index 0000000..fe9900e --- /dev/null +++ b/packages/flutter/oto_console/lib/src/oto_runners_surface.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import 'oto_console_contract.dart'; + +class OtoRunnersSurface extends StatelessWidget { + final OtoSurfaceSnapshot> snapshot; + final OtoConsoleThemeAdapter? themeAdapter; + + const OtoRunnersSurface({ + super.key, + required this.snapshot, + this.themeAdapter, + }); + + @override + Widget build(BuildContext context) { + final theme = themeAdapter ?? const OtoConsoleThemeAdapter(); + return Container( + color: theme.backgroundColor, + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 820), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.precision_manufacturing_outlined, + color: theme.primaryColor, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Runners', + style: TextStyle( + color: theme.textColor, + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + Expanded(child: _buildContent(context, theme)), + ], + ), + ), + ), + ); + } + + Widget _buildContent(BuildContext context, OtoConsoleThemeAdapter theme) { + switch (snapshot.state) { + case OtoSurfaceLoadState.loading: + return const Center(child: CircularProgressIndicator()); + case OtoSurfaceLoadState.empty: + return _buildEmptyState(theme); + case OtoSurfaceLoadState.error: + return _buildErrorState(theme); + case OtoSurfaceLoadState.data: + final runners = snapshot.data ?? []; + if (runners.isEmpty) { + return _buildEmptyState(theme); + } + return _buildRunnersList(context, theme, runners); + } + } + + Widget _buildEmptyState(OtoConsoleThemeAdapter theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: theme.railBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.borderColor), + ), + child: Row( + children: [ + Icon( + Icons.precision_manufacturing_outlined, + color: theme.primaryColor, + size: 32, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No runners connected', + style: TextStyle( + color: theme.textColor, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + 'Runner registry is empty.', + style: TextStyle(color: theme.textSecondaryColor), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildErrorState(OtoConsoleThemeAdapter theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 32), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Error loading runners', + style: TextStyle( + color: theme.textColor, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + snapshot.errorMessage ?? 'An unknown error occurred.', + style: TextStyle(color: theme.textSecondaryColor), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildRunnersList( + BuildContext context, + OtoConsoleThemeAdapter theme, + List runners, + ) { + return ListView.separated( + itemCount: runners.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final runner = runners[index]; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.railBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + 'ID: ${runner.runnerID}', + style: TextStyle( + color: theme.textColor, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + _StatusBadge(status: runner.status, theme: theme), + ], + ), + if (runner.alias.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Alias: ${runner.alias}', + style: TextStyle(color: theme.textColor, fontSize: 14), + ), + ], + if (runner.message.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Message: ${runner.message}', + style: TextStyle( + color: theme.textSecondaryColor, + fontSize: 14, + ), + ), + ], + if (runner.currentJobID.isNotEmpty || + runner.currentExecutionID.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: theme.primaryColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: theme.primaryColor.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Icon( + Icons.play_circle_outline, + color: theme.primaryColor, + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Job: ${runner.currentJobID.isNotEmpty ? runner.currentJobID : "None"} | Execution: ${runner.currentExecutionID.isNotEmpty ? runner.currentExecutionID : "None"}', + style: TextStyle( + color: theme.textColor, + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ], + ), + ); + }, + ); + } +} + +class _StatusBadge extends StatelessWidget { + final String status; + final OtoConsoleThemeAdapter theme; + + const _StatusBadge({required this.status, required this.theme}); + + @override + Widget build(BuildContext context) { + Color color; + switch (status.toLowerCase()) { + case 'active': + case 'online': + case 'idle': + color = theme.primaryColor; + break; + case 'busy': + case 'running': + color = theme.accentColor; + break; + case 'offline': + default: + color = theme.textSecondaryColor; + break; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Text( + status.toUpperCase(), + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/packages/flutter/oto_console/test/oto_console_test.dart b/packages/flutter/oto_console/test/oto_console_test.dart index 77feaf5..f05a9df 100644 --- a/packages/flutter/oto_console/test/oto_console_test.dart +++ b/packages/flutter/oto_console/test/oto_console_test.dart @@ -127,4 +127,87 @@ void main() { expect(find.text('Browser API policy'), findsOneWidget); expect(find.textContaining('same-origin proxy'), findsOneWidget); }); + + testWidgets('OtoRunnersSurface renders loading state', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: OtoRunnersSurface(snapshot: OtoSurfaceSnapshot.loading()), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('OtoRunnersSurface renders empty state', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: OtoRunnersSurface(snapshot: OtoSurfaceSnapshot.empty()), + ), + ), + ); + + expect(find.text('No runners connected'), findsOneWidget); + expect(find.text('Runner registry is empty.'), findsOneWidget); + }); + + testWidgets('OtoRunnersSurface renders error state', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: OtoRunnersSurface( + snapshot: OtoSurfaceSnapshot.error('Failed to fetch runners'), + ), + ), + ), + ); + + expect(find.text('Error loading runners'), findsOneWidget); + expect(find.text('Failed to fetch runners'), findsOneWidget); + }); + + testWidgets('OtoRunnersSurface renders data state with runners list', ( + tester, + ) async { + final runners = [ + const OtoRunnerViewModel( + runnerID: 'runner-1', + alias: 'Main Runner', + status: 'active', + currentJobID: 'job-101', + currentExecutionID: 'exec-202', + message: 'Running jobs', + ), + const OtoRunnerViewModel( + runnerID: 'runner-2', + alias: 'Backup Runner', + status: 'offline', + currentJobID: '', + currentExecutionID: '', + message: 'Connection lost', + ), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OtoRunnersSurface(snapshot: OtoSurfaceSnapshot.data(runners)), + ), + ), + ); + + expect(find.text('ID: runner-1'), findsOneWidget); + expect(find.text('Alias: Main Runner'), findsOneWidget); + expect(find.text('ACTIVE'), findsOneWidget); + expect(find.text('Message: Running jobs'), findsOneWidget); + expect(find.textContaining('Job: job-101'), findsOneWidget); + expect(find.textContaining('Execution: exec-202'), findsOneWidget); + + expect(find.text('ID: runner-2'), findsOneWidget); + expect(find.text('Alias: Backup Runner'), findsOneWidget); + expect(find.text('OFFLINE'), findsOneWidget); + expect(find.text('Message: Connection lost'), findsOneWidget); + }); }