feat: Control Plane API 데이터 바인딩 및 oto_console 런너urface 추가

- Control Plane API 데이터 바인딩 마일스톤 문서 업데이트
- core_connection_client 및 oto_client_app 수정
- oto_console 런너.surface 모듈 추가
- 관련 테스트 파일 업데이트
This commit is contained in:
toki 2026-06-17 13:50:29 +09:00
parent d6b0194ae1
commit 002f9cd521
15 changed files with 2482 additions and 5 deletions

View file

@ -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 콘솔 스켈레톤`
- 후속 작업: 쓰기 액션, 인증/권한, 운영 대시보드 고도화
- 확인 필요: 없음

View file

@ -0,0 +1,195 @@
<!-- task=m-control-plane-api-data-binding/01_runner_surface plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> 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 수정은 런타임 책임으로 남긴다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,192 @@
<!-- task=m-control-plane-api-data-binding/01_runner_surface plan=0 tag=API -->
# 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<T>`, `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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,133 @@
<!-- task=m-control-plane-api-data-binding/02+01_execution_surface plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> 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.

View file

@ -0,0 +1,201 @@
<!-- task=m-control-plane-api-data-binding/02+01_execution_surface plan=0 tag=API -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,149 @@
<!-- task=m-control-plane-api-data-binding/03+01,02_preview_smoke plan=0 tag=TEST -->
# 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.

View file

@ -0,0 +1,196 @@
<!-- task=m-control-plane-api-data-binding/03+01,02_preview_smoke plan=0 tag=TEST -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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<T> {
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<OtoCoreReadResult<OtoRunnerRecord>> fetchRunner(
OtoConsoleConfig config,
String runnerID,
);
Future<OtoCoreReadResult<OtoRunnerStatus>> fetchRunnerStatus(
OtoConsoleConfig config,
String runnerID,
);
Future<OtoCoreReadResult<OtoJobRecord>> fetchJob(
OtoConsoleConfig config,
String jobID,
);
Future<OtoCoreReadResult<OtoExecutionRecord>> fetchExecution(
OtoConsoleConfig config,
String executionID,
);
Future<OtoCoreReadResult<List<OtoExecutionLogEntry>>> fetchLogs(
OtoConsoleConfig config,
String executionID,
);
Future<OtoCoreReadResult<List<OtoArtifactRecord>>> 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<OtoCoreReadResult<OtoRunnerRecord>> 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<OtoCoreReadResult<OtoRunnerStatus>> 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<OtoCoreReadResult<OtoJobRecord>> 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<OtoCoreReadResult<OtoExecutionRecord>> 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<OtoCoreReadResult<List<OtoExecutionLogEntry>>> 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<OtoCoreReadResult<List<OtoArtifactRecord>>> 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<OtoCoreReadResult<Map<String, dynamic>>> _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<String, dynamic>.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<String, dynamic> 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<String, dynamic> 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<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<T> _forwardState<T>(
OtoCoreReadResult<Map<String, dynamic>> 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<String, dynamic> json, String key) {
final value = _string(json, key);
if (value.isEmpty) {
throw FormatException('Missing required field: $key');
}
return value;
}
String _string(Map<String, dynamic> json, String key) {
final value = json[key];
return value == null ? '' : value.toString();
}
DateTime? _dateTime(Map<String, dynamic> json, String key) {
final value = _string(json, key);
if (value.isEmpty) {
return null;
}
return DateTime.tryParse(value);
}
Map<String, dynamic>? _optionalObject(Map<String, dynamic> json, String key) {
final value = json[key];
if (value == null) {
return null;
}
if (value is! Map) {
throw FormatException('Expected object field: $key');
}
return Map<String, dynamic>.from(value);
}
List<Map<String, dynamic>> _jsonList(Map<String, dynamic> 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<String, dynamic>.from(item);
})
.toList(growable: false);
}

View file

@ -32,6 +32,8 @@ class OtoClientApp extends StatefulWidget {
final OtoConsoleConfig config;
final OtoCoreConnectionSnapshot initialConnection;
final OtoCoreConnectionClient? coreClient;
final OtoCoreReadClient? readClient;
final List<String> 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<OtoClientApp> {
late final OtoCoreConnectionClient _defaultCoreClient =
OtoHttpCoreConnectionClient();
late final OtoCoreReadClient _defaultReadClient = OtoHttpCoreReadClient();
late OtoCoreConnectionSnapshot _connection = widget.initialConnection;
OtoSurfaceSnapshot<List<OtoRunnerViewModel>> _runnersSnapshot =
const OtoSurfaceSnapshot.loading();
int _refreshGeneration = 0;
@override
@ -71,28 +78,126 @@ class _OtoClientAppState extends State<OtoClientApp> {
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<String>? a, List<String>? 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<void> _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<OtoCoreConnectionSnapshot> connectionFuture = coreClient.check(
widget.config,
);
final Future<OtoSurfaceSnapshot<List<OtoRunnerViewModel>>> runnersFuture =
_fetchRunners(readClient);
final results = await Future.wait([connectionFuture, runnersFuture]);
final connection = results[0] as OtoCoreConnectionSnapshot;
final runnersSnapshot =
results[1] as OtoSurfaceSnapshot<List<OtoRunnerViewModel>>;
if (!mounted || generation != _refreshGeneration) {
return;
}
setState(() {
_connection = connection;
_runnersSnapshot = runnersSnapshot;
});
}
Future<OtoSurfaceSnapshot<List<OtoRunnerViewModel>>> _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 = <OtoRunnerViewModel>[];
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<OtoClientApp> {
onRefresh: _refreshCoreStatus,
themeAdapter: themeAdapter,
),
runners: OtoRunnersSurface(
snapshot: _runnersSnapshot,
themeAdapter: themeAdapter,
),
),
);
}
}
class _RunnerFetchResult {
final String id;
final OtoCoreReadResult<OtoRunnerRecord> recordResult;
final OtoCoreReadResult<OtoRunnerStatus> statusResult;
_RunnerFetchResult(this.id, this.recordResult, this.statusResult);
}

View file

@ -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 = <String>[];
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<String, Object?> 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<OtoRunnerRecord> Function(String runnerID)?
onFetchRunner;
final OtoCoreReadResult<OtoRunnerStatus> Function(String runnerID)?
onFetchRunnerStatus;
OtoConsoleConfig? lastUsedConfig;
_FakeCoreReadClient({this.onFetchRunner, this.onFetchRunnerStatus});
@override
Future<OtoCoreReadResult<OtoRunnerRecord>> fetchRunner(
OtoConsoleConfig config,
String runnerID,
) async {
lastUsedConfig = config;
if (onFetchRunner != null) {
return onFetchRunner!(runnerID);
}
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<OtoRunnerStatus>> fetchRunnerStatus(
OtoConsoleConfig config,
String runnerID,
) async {
lastUsedConfig = config;
if (onFetchRunnerStatus != null) {
return onFetchRunnerStatus!(runnerID);
}
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<OtoJobRecord>> fetchJob(
OtoConsoleConfig config,
String jobID,
) async {
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<OtoExecutionRecord>> fetchExecution(
OtoConsoleConfig config,
String executionID,
) async {
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<List<OtoExecutionLogEntry>>> fetchLogs(
OtoConsoleConfig config,
String executionID,
) async {
return const OtoCoreReadResult.empty();
}
@override
Future<OtoCoreReadResult<List<OtoArtifactRecord>>> fetchArtifacts(
OtoConsoleConfig config,
String executionID,
) async {
return const OtoCoreReadResult.empty();
}
}

View file

@ -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';

View file

@ -132,3 +132,50 @@ class OtoCoreConnectionSnapshot {
abstract class OtoCoreConnectionClient {
Future<OtoCoreConnectionSnapshot> check(OtoConsoleConfig config);
}
enum OtoSurfaceLoadState { loading, empty, error, data }
class OtoSurfaceSnapshot<T> {
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,
});
}

View file

@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'oto_console_contract.dart';
class OtoRunnersSurface extends StatelessWidget {
final OtoSurfaceSnapshot<List<OtoRunnerViewModel>> 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<OtoRunnerViewModel> 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,
),
),
);
}
}

View file

@ -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);
});
}