feat: control plane API data binding 구현 및 oto_console surface 추가

- execution surface 및 preview smoke 테스트 문서 업데이트
- client 앱 및 oto_console UI surface (artifacts, executions, jobs) 추가
- 관련 테스트 및 계약 문서 업데이트
- archive 구조 정리
This commit is contained in:
toki 2026-06-17 16:11:34 +09:00
parent 002f9cd521
commit f76b7fa850
18 changed files with 2233 additions and 52 deletions

View file

@ -21,7 +21,7 @@
- [완료] Flutter 콘솔 스켈레톤
- 경로: `agent-roadmap/archive/phase/control-plane-product-surface/milestones/flutter-console-skeleton.md`
- 요약: bootstrap과 runner/node 연결 기준을 바탕으로 `apps/client``packages/flutter/oto_console`에 Control Plane 주요 화면, 상태 표시, 빈 상태를 갖춘 UI 스켈레톤을 만든다.
- [계획] Control Plane API 데이터 바인딩
- [진행중] Control Plane API 데이터 바인딩
- 경로: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md`
- 요약: UI 스켈레톤에 기존 OTO Core runner/job/execution/log/artifact API의 read path를 연결하고, 실제 데이터가 비어 있거나 실패할 때의 상태 표현을 검증한다.

View file

@ -41,7 +41,7 @@ Flutter 콘솔 스켈레톤의 Overview, Runners, Pipelines/Jobs, Executions, Ar
UI 스켈레톤을 실제 Core 응답을 소비하는 제품 표면으로 전환한다.
- [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 테스트가 통과한다.
- [x] [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를 남긴다.
@ -67,6 +67,9 @@ UI 스켈레톤을 실제 Core 응답을 소비하는 제품 표면으로 전환
- 표준선(선택): `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)
- 진행 메모: [runner-surface] `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log` 기준 PASS. `cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test`, 양쪽 `flutter analyze`가 통과했다. (2026-06-17)
- 진행 메모: [execution-surface] 큰 작업으로 `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/PLAN-local-G06.md` active plan이 준비되어 있고, predecessor `01`은 archive `complete.log`로 충족됐다. (2026-06-17)
- 진행 메모: [preview-data-smoke] 큰/원격 evidence 작업으로 `agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md` active plan이 준비되어 있으며, predecessor `02` 완료 대기 중이다. (2026-06-17)
- 선행 작업: `Flutter 콘솔 스켈레톤`
- 후속 작업: 쓰기 액션, 인증/권한, 운영 대시보드 고도화
- 확인 필요: 없음

View file

@ -0,0 +1,183 @@
<!-- 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 | [x] |
| [API-2] Client job/execution read wiring | [x] |
## 구현 체크리스트
- [x] `packages/flutter/oto_console`에 jobs/executions/logs/artifacts surface용 view model과 widgets를 추가하고 상태별 widget test를 작성한다. 검증: `cd packages/flutter/oto_console && flutter test`에서 jobs/executions surface 테스트가 통과한다.
- [x] `apps/client`가 `OtoCoreReadClient.fetchJob`, `fetchExecution`, `fetchLogs`, `fetchArtifacts` 결과를 surface snapshot으로 변환해 `OtoConsoleShell.pipelines`, `executions`, `artifacts`에 주입하고 fake read client 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_G06_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-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이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- `OtoConsoleShell`에 `didUpdateWidget`을 구현하여 `initialSection` 프로퍼티의 업데이트를 감지해 내부 `_section`을 동기화하도록 보강했습니다. 이를 통해 parent client app(OtoClientApp)의 탭 조작(예: Job ID 클릭 시 Execution 탭으로 이동 및 자동 펼침)이 자연스럽게 연동되도록 개선했습니다. 그 외에는 계획 대비 변경 사항이 없습니다.
## 주요 설계 결정
1. **상태 모델 확장**: `packages/flutter/oto_console` 패키지에 `OtoJobViewModel`, `OtoExecutionViewModel`, `OtoLogEntryViewModel`, `OtoArtifactViewModel`을 정의하여 snapshot 기반의 declarative한 상태 UI 렌더링에 적합하도록 구축했습니다.
2. **Expandable Executions Row**: `OtoExecutionsSurface`는 리스트의 각 아이템 클릭 시 펼쳐지는(expandable) 구조로 설계하고, 펼쳐진 세부 정보 영역에서 해당 execution의 logs preview 및 artifacts 리스트를 비동기로 lazy loading하여 표시하도록 설계했습니다.
3. **양방향 네비게이션 동기화**: `OtoConsoleShell`의 `initialSection` 업데이트 감지 기능을 보완하여 parent(ClientApp)의 상태에 따른 active tab 제어를 매끄럽게 지원합니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `01_runner_surface`의 archive `complete.log`(`agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`)가 존재하고 공통 state contract를 실제로 재사용했는지 확인한다.
- Core route/schema 변경 없이 id 기반 job/execution/log/artifact read path만 소비했는지 확인한다.
- logs/artifacts 진입 위치가 execution 화면에서 사용자가 찾을 수 있게 노출되는지 확인한다.
## 검증 결과
### API-1 중간 검증
```bash
$ cd packages/flutter/oto_console && flutter test
00:00 +0: ...space/oto/packages/flutter/oto_console/test/oto_console_test.dart
00:01 +0: ...space/oto/packages/flutter/oto_console/test/oto_console_test.dart
00:02 +0: ...space/oto/packages/flutter/oto_console/test/oto_console_test.dart
00:02 +0: exports console contract models without shell dependency
00:02 +1: exports console contract models without shell dependency
00:02 +1: renders embeddable OTO console shell
00:03 +1: renders embeddable OTO console shell
00:03 +2: renders embeddable OTO console shell
00:03 +2: renders section surfaces with empty states
00:03 +3: renders section surfaces with empty states
00:03 +3: OtoRunnersSurface renders loading state
00:03 +4: OtoRunnersSurface renders loading state
00:03 +4: OtoRunnersSurface renders empty state
00:03 +5: OtoRunnersSurface renders empty state
00:03 +5: OtoRunnersSurface renders error state
00:03 +6: OtoRunnersSurface renders error state
00:03 +6: OtoRunnersSurface renders data state with runners list
00:03 +7: OtoRunnersSurface renders data state with runners list
00:03 +7: OtoJobsSurface renders loading/empty/error/data states
00:03 +8: OtoJobsSurface renders loading/empty/error/data states
00:03 +8: ... renders loading/empty/error/data states and expanded detail
00:03 +9: ... renders loading/empty/error/data states and expanded detail
00:03 +9: OtoArtifactsSurface renders loading/empty/error/data states
00:04 +9: OtoArtifactsSurface renders loading/empty/error/data states
00:04 +10: OtoArtifactsSurface renders loading/empty/error/data states
00:04 +10: All tests passed!
```
### API-2 중간 검증
```bash
$ cd apps/client && flutter test
00:00 +0: loading /config/workspace/oto/apps/client/test/widget_test.dart
00:01 +0: loading /config/workspace/oto/apps/client/test/widget_test.dart
00:02 +0: loading /config/workspace/oto/apps/client/test/widget_test.dart
00:02 +0: OTO client hosts embeddable console widgets
00:03 +0: OTO client hosts embeddable console widgets
00:03 +1: OTO client hosts embeddable console widgets
00:03 +1: OtoHttpCoreReadClient maps Core read responses to data states
00:03 +2: OtoHttpCoreReadClient maps Core read responses to data states
00:03 +2: OtoHttpCoreReadClient maps empty and error states
00:03 +3: OtoHttpCoreReadClient maps empty and error states
00:03 +3: OtoClientApp wires runners loading empty state when no runnerIDs
00:03 +4: OtoClientApp wires runners loading empty state when no runnerIDs
00:03 +4: OtoClientApp wires runners error state on API failure
00:03 +5: OtoClientApp wires runners error state on API failure
00:03 +5: OtoClientApp wires runners data state on API success
00:03 +6: OtoClientApp wires runners data state on API success
00:03 +6: OtoClientApp ... executions and artifacts data state on API success
00:04 +6: OtoClientApp ... executions and artifacts data state on API success
00:04 +7: OtoClientApp ... executions and artifacts data state on API success
00:04 +7: All tests passed!
```
### 최종 검증
```bash
$ cd packages/flutter/oto_console && flutter test
All tests passed!
$ cd apps/client && flutter test
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.
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- 발견된 문제:
- Required: `apps/client/test/widget_test.dart:381`의 client wiring 테스트가 jobs/executions/artifacts data path만 검증한다. 계획의 API-2는 fake read client로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 검증하라고 명시했지만, 현재 테스트는 job fetch error, execution fetch error, artifacts aggregate error/empty, expanded logs/artifacts empty/error 렌더링을 검증하지 않는다. 같은 파일의 `_registerJobsExecutionsTests()`에 missing empty/error widget tests를 추가하고, 각 test가 실제 `OtoClientApp` 화면 문구를 assert하도록 보강한다.
- 다음 단계: FAIL follow-up으로 `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성한다.

View file

@ -1,6 +1,6 @@
<!-- task=m-control-plane-api-data-binding/02+01_execution_surface plan=0 tag=API -->
<!-- task=m-control-plane-api-data-binding/02+01_execution_surface plan=1 tag=REVIEW_API -->
# Code Review Reference - API
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
@ -14,7 +14,7 @@
## 개요
date=2026-06-17
task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API
task=m-control-plane-api-data-binding/02+01_execution_surface, plan=1, tag=REVIEW_API
## Roadmap Targets
@ -23,6 +23,23 @@ task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API
- `execution-surface`: Pipelines/Jobs와 Executions 화면이 job/execution read model을 표시하고 로그/아티팩트 진입 위치를 제공한다.
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G06_0.log`
- Archived review: `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G06_0.log`
- Verdict: FAIL
- Required summary:
- `apps/client/test/widget_test.dart:381`의 client wiring 테스트가 jobs/executions/artifacts data path만 검증한다. API-2 계획은 fake read client로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 검증하라고 요구했다.
- Affected files:
- `apps/client/test/widget_test.dart`
- 필요 시 `apps/client/lib/src/app/oto_client_app.dart`
- Verification evidence from archived loop:
- `cd packages/flutter/oto_console && flutter test`: 통과
- `cd apps/client && flutter test`: 통과
- `git diff --check`: EOF 공백 수정 후 통과
- Allowed archive reread:
- 위 archived plan/review log 두 파일만 좁게 읽는다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
@ -31,7 +48,7 @@ task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G06.md` -> `code_review_local_G06_N.log`, `PLAN-local-G06.md` -> `plan_local_G06_M.log`로 아카이브한다.
2. `CODE_REVIEW-local-G07.md` -> `code_review_local_G07_N.log`, `PLAN-local-G07.md` -> `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-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` 위치에서 체크한 뒤 보고한다.
@ -42,40 +59,40 @@ task=m-control-plane-api-data-binding/02+01_execution_surface, plan=0, tag=API
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Console jobs/executions/artifacts surfaces | [ ] |
| [API-2] Client job/execution read wiring | [ ] |
| [REVIEW_API-1] Client empty/error wiring tests | [x] |
## 구현 체크리스트
- [ ] `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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `apps/client/test/widget_test.dart`에 `OtoClientApp` fake read client 기반 jobs/executions/artifacts empty/error wiring tests를 추가한다. 검증: `cd apps/client && flutter test`에서 새 empty/error tests가 함께 통과한다.
- [x] `git diff --check`를 실행해 task artifact와 Dart test 변경에 whitespace error가 없는지 확인한다.
- [x] 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`를 작성하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-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 경로에서 이 체크리스트를 갱신한다.
- [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-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 계획 대비 변경 사항 없음. 테스트가 실제 product behavior gap을 드러내지 않았으므로 `OtoClientApp` 제품 코드는 수정하지 않고, `widget_test.dart` 내의 integration test suite만 정교하게 보강했습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
1. **종합적 Widget integration Test 작성**: `_registerJobsExecutionsTests()` 내에 `testWidgets`들을 추가 정의하여 UI 레이어와 Mock API 간의 wiring을 온전히 검증하도록 설계했습니다.
2. **Detail Expanded State 에러/비통제 케이스 검증**: Expanded detail panel의 토글(Collapse/Expand)을 모방하고, 런타임에 mock client의 내부 flag(`shouldError`)를 동적으로 스위칭하여 empty/error UI 상태 변화와 해당 시점의 assert가 정상 동작하도록 구현했습니다.
## 사용자 리뷰 요청
@ -91,9 +108,9 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 리뷰어를 위한 체크포인트
- `01_runner_surface`의 `complete.log`가 존재하고 공통 state contract를 실제로 재사용했는지 확인한다.
- Core route/schema 변경 없이 id 기반 job/execution/log/artifact read path만 소비했는지 확인한다.
- logs/artifacts 진입 위치가 execution 화면에서 사용자가 찾을 수 있게 노출되는지 확인한다.
- `apps/client/test/widget_test.dart`가 job, execution, logs, artifacts의 empty/error 화면 상태를 fake read client로 검증하는지 확인한다.
- 새 tests가 단순 adapter unit test가 아니라 실제 `OtoClientApp` widget에서 tab/detail 문구를 assert하는지 확인한다.
- 제품 코드 변경이 있다면 테스트가 드러낸 최소 결함만 수정했는지 확인한다.
## 검증 결과
@ -106,24 +123,27 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- 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 중간 검증
### REVIEW_API-1 중간 검증
```bash
$ cd apps/client && flutter test
(output)
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: OtoClientApp wires jobs, executions and artifacts data state on API success
00:02 +7: OtoClientApp wires jobs, executions and artifacts empty states when no IDs
00:02 +8: OtoClientApp wires jobs, executions and artifacts error states on API failure
00:02 +9: OtoClientApp wires expanded execution detail logs and artifacts empty/error states
00:03 +10: All tests passed!
```
### 최종 검증
```bash
$ cd packages/flutter/oto_console && flutter test
(output)
$ cd apps/client && flutter test
(output)
$ git diff --check
(no output; exit code 0)
```
---
@ -131,3 +151,17 @@ $ cd apps/client && flutter test
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 종결. `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,44 @@
# Complete - m-control-plane-api-data-binding/02+01_execution_surface
## 완료 일시
2026-06-17
## 요약
Control Plane API 데이터 바인딩 execution surface 작업을 2회 리뷰 루프로 완료했다. G06은 client wiring empty/error 테스트 커버리지 부족으로 FAIL, G07에서 테스트를 보강해 최종 PASS 처리했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | 제품 코드와 기본 테스트는 동작했지만 `apps/client`의 job/execution/log/artifact empty/error widget wiring 테스트가 부족했다. |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | `apps/client/test/widget_test.dart`에 jobs/executions/artifacts empty/error 및 expanded detail logs/artifacts empty/error 테스트를 추가했고 검증이 통과했다. |
## 구현/정리 내용
- `packages/flutter/oto_console`에 jobs, executions, artifacts surface view model과 widgets를 추가하고 loading/empty/error/data 상태 테스트를 작성했다.
- `apps/client`가 job/execution/log/artifact read results를 console surface snapshot으로 변환해 `OtoConsoleShell`에 주입하도록 연결했다.
- `apps/client/test/widget_test.dart`에 fake read client 기반 data, empty, error, expanded detail 테스트를 보강했다.
- review 중 `packages/flutter/oto_console/lib/oto_console.dart`의 EOF whitespace nit를 정리했다.
## 최종 검증
- `cd packages/flutter/oto_console && flutter test` - PASS; 10 tests passed.
- `cd apps/client && flutter test` - PASS; 10 tests passed.
- `git diff --check` - PASS; no output.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md`
- Completed task ids:
- `execution-surface`: PASS; evidence=`plan_local_G06_0.log`, `code_review_local_G06_0.log`, `plan_local_G07_1.log`, `code_review_local_G07_1.log`; verification=`cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test`, `git diff --check`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -71,7 +71,7 @@
- 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`가 생긴 뒤 시작한다.
- predecessor `01`: satisfied via `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`. 구현을 시작할 수 있다.
- `03+01,02_preview_smoke`는 이 subtask와 `01_runner_surface` 완료 뒤 실행한다.
### 범위 결정 근거
@ -86,9 +86,9 @@
## 의존 관계 및 구현 순서
1. `01_runner_surface`가 PASS되어 `complete.log`를 남긴 뒤 시작한다.
2. 이 subtask는 `02+01_execution_surface` 디렉터리명 그대로 predecessor `01`만 요구다.
3. 선행 `complete.log`가 없으면 구현하지 말고 review stub의 `사용자 리뷰 요청`이 아니라 계획 대기 상태로 보고한다.
1. `01_runner_surface`는 PASS되어 `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`를 남겼다.
2. 이 subtask는 `02+01_execution_surface` 디렉터리명 그대로 predecessor `01`만 요구하며, 현재 선행 조건은 충족됐다.
3. 구현 중 선행 산출물 재확인이 필요하면 위 archive `complete.log`만 좁게 확인한다.
## 구현 체크리스트

View file

@ -0,0 +1,97 @@
<!-- task=m-control-plane-api-data-binding/02+01_execution_surface plan=1 tag=REVIEW_API -->
# Plan - REVIEW_API Client Wiring Test Coverage
## 이 파일을 읽는 구현 에이전트에게
구현 완료의 마지막 단계는 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 출력과 설계 결정을 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 종결 처리, log rename, `complete.log` 작성, archive 이동은 code-review 스킬 전용이다. 구현 중 사용자만 결정할 항목, 사용자 소유 외부 환경, 범위 충돌이 발견되면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 근거를 남기고 멈춘다. 구현 중 채팅으로 직접 질문하거나 `request_user_input`을 호출하지 않는다.
## 배경
이 후속 루프는 `code_review_local_G06_0.log`의 Required 이슈만 다룬다. 제품 코드의 기본 jobs/executions/artifacts wiring과 console surface tests는 통과했지만, 원 계획의 API-2는 fake read client 기반으로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 검증하라고 요구했다. 현재 `apps/client/test/widget_test.dart`는 새 client wiring의 data path만 검증하므로 empty/error 화면 검증을 보강해야 한다.
## 사용자 리뷰 요청 흐름
구현 중 차단 사항은 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
## Archive Evidence Snapshot
- Archived plan: `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G06_0.log`
- Archived review: `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G06_0.log`
- Verdict: FAIL
- Required summary:
- `apps/client/test/widget_test.dart:381`의 client wiring 테스트가 jobs/executions/artifacts data path만 검증한다. API-2 계획은 fake read client로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 검증하라고 요구했다.
- Affected files:
- `apps/client/test/widget_test.dart`
- 필요 시 `apps/client/lib/src/app/oto_client_app.dart`
- Verification evidence from archived loop:
- `cd packages/flutter/oto_console && flutter test`: 통과
- `cd apps/client && flutter test`: 통과
- `git diff --check`: EOF 공백 수정 후 통과
- Allowed archive reread:
- 위 archived plan/review log 두 파일만 좁게 읽는다.
## 범위 결정 근거
- 이번 후속은 missing test coverage 보강이다. 제품 코드는 테스트가 실제 결함을 드러낼 때만 최소 수정한다.
- Core route/schema, roadmap 문서, 다른 split task(`03+01,02_preview_smoke`)는 수정하지 않는다.
- follow-up route는 `local-G07`이다. 이슈가 명시적이고 deterministic widget tests로 검증 가능하므로 cloud escalation은 필요하지 않다.
## 구현 체크리스트
- [ ] `apps/client/test/widget_test.dart`에 `OtoClientApp` fake read client 기반 jobs/executions/artifacts empty/error wiring tests를 추가한다. 검증: `cd apps/client && flutter test`에서 새 empty/error tests가 함께 통과한다.
- [ ] `git diff --check`를 실행해 task artifact와 Dart test 변경에 whitespace error가 없는지 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [REVIEW_API-1] Client empty/error wiring tests
문제: `apps/client/test/widget_test.dart`의 `OtoClientApp wires jobs, executions and artifacts data state on API success`는 data path만 검증한다. 원 계획은 fake read client로 job/execution/log/artifact data와 empty/error 상태 표시를 모두 확인하라고 요구했으므로, 현재 테스트는 plan completion evidence로 부족하다.
해결 방법: `_registerJobsExecutionsTests()`에 아래 상태를 실제 `OtoClientApp` widget으로 검증하는 테스트를 추가한다.
- no `jobIDs`/`executionIDs`일 때 Pipelines, Executions, Artifacts tab의 empty 문구가 표시된다.
- `fetchJob` error가 Pipelines tab의 `Error loading jobs`와 error message로 표시된다.
- `fetchExecution` error가 Executions tab의 `Error loading executions`와 error message로 표시된다.
- `fetchArtifacts` error가 Artifacts tab의 `Error loading artifacts`와 error message로 표시된다.
- expanded execution detail에서 `fetchLogs` empty/error와 `fetchArtifacts` empty/error가 각각 `Log is empty.`, `Error loading logs: ...`, `No artifacts produced.`, `Error loading artifacts: ...`로 표시된다.
수정 파일 및 체크리스트:
- [ ] `apps/client/test/widget_test.dart`에 중복을 줄이는 test helper를 추가하거나 기존 `_FakeCoreReadClient`를 재사용한다.
- [ ] 새 tests가 `OtoConsoleConfig.serverHttpUrl` 기반 fake client 호출을 유지하는지 확인한다.
- [ ] 테스트가 product behavior gap을 드러내면 `apps/client/lib/src/app/oto_client_app.dart`만 최소 수정하고, 수정 사유를 review stub에 기록한다.
테스트 작성: 작성. 각 상태별로 화면 문구를 assert한다.
중간 검증:
```bash
cd apps/client && flutter test
```
기대 결과: 새 empty/error wiring tests 포함 전체 통과.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/client/test/widget_test.dart` | REVIEW_API-1 |
| `apps/client/lib/src/app/oto_client_app.dart` | REVIEW_API-1, 테스트가 실제 product gap을 드러낼 때만 |
## 최종 검증
```bash
cd apps/client && flutter test
git diff --check
```
기대 결과: 두 명령 모두 통과한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -47,7 +47,7 @@ task=m-control-plane-api-data-binding/03+01,02_preview_smoke, plan=0, tag=TEST
## 구현 체크리스트
- [ ] 선행 `01_runner_surface``02+01_execution_surface``complete.log`를 확인하고 없으면 실행하지 않는다.
- [ ] 선행 `01_runner_surface` archive `complete.log``02+01_execution_surface``complete.log`를 확인하고, `02+01_execution_surface` 없으면 실행하지 않는다.
- [ ] 원격 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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
@ -92,7 +92,7 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 리뷰어를 위한 체크포인트
- `01_runner_surface``02+01_execution_surface` 선행 `complete.log`가 확인되었는지 검토한다.
- `01_runner_surface` archive `complete.log`(`agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`)`02+01_execution_surface` 선행 `complete.log`가 확인되었는지 검토한다.
- curl 출력이 `healthz=OK`, `readyz=OK`, preview HTTP 200 계열을 실제로 보여주는지 확인한다.
- screenshot path가 존재하고, browser stdout 또는 screenshot evidence가 API-backed 화면 상태를 판단하기에 충분한지 확인한다.

View file

@ -63,7 +63,7 @@
- split decision policy를 먼저 평가했다.
- current subtask: `03+01,02_preview_smoke`.
- predecessor `01`: missing active/archive `complete.log`.
- predecessor `01`: satisfied via `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`.
- predecessor `02`: missing active/archive `complete.log`.
- 이 smoke는 `01_runner_surface``02+01_execution_surface` PASS 이후만 실행한다.
@ -79,15 +79,15 @@
## 의존 관계 및 구현 순서
1. `01_runner_surface``02+01_execution_surface`가 PASS되어 각각 `complete.log`를 남겼는지 확인한다.
2. 선행 `complete.log` 없으면 smoke를 실행하지 않고 active files를 그대로 둔다.
1. `01_runner_surface`는 PASS되어 `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log`를 남겼다.
2. `02+01_execution_surface`가 PASS되어 `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`를 확인하고 없으면 실행하지 않는다.
- [ ] 선행 `01_runner_surface` archive `complete.log``02+01_execution_surface``complete.log`를 확인하고, `02+01_execution_surface` 없으면 실행하지 않는다.
- [ ] 원격 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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
@ -101,7 +101,7 @@
수정 파일 및 체크리스트:
- [ ] source file 변경 없음.
- [ ] `agent-task/m-control-plane-api-data-binding/01_runner_surface/complete.log` 또는 대응 archive path 확인.
- [ ] `agent-task/archive/2026/06/m-control-plane-api-data-binding/01_runner_surface/complete.log` 확인.
- [ ] `agent-task/m-control-plane-api-data-binding/02+01_execution_surface/complete.log` 또는 대응 archive path 확인.
- [ ] `/tmp/oto-api-binding-smoke`에 curl output 저장.

View file

@ -34,6 +34,8 @@ class OtoClientApp extends StatefulWidget {
final OtoCoreConnectionClient? coreClient;
final OtoCoreReadClient? readClient;
final List<String> runnerIDs;
final List<String> jobIDs;
final List<String> executionIDs;
final OtoCapabilityPack capabilities;
const OtoClientApp({
@ -52,6 +54,8 @@ class OtoClientApp extends StatefulWidget {
this.coreClient,
this.readClient,
this.runnerIDs = const [],
this.jobIDs = const [],
this.executionIDs = const [],
this.capabilities = otoDefaultCapabilityPack,
});
@ -66,6 +70,18 @@ class _OtoClientAppState extends State<OtoClientApp> {
late OtoCoreConnectionSnapshot _connection = widget.initialConnection;
OtoSurfaceSnapshot<List<OtoRunnerViewModel>> _runnersSnapshot =
const OtoSurfaceSnapshot.loading();
OtoSurfaceSnapshot<List<OtoJobViewModel>> _jobsSnapshot =
const OtoSurfaceSnapshot.loading();
OtoSurfaceSnapshot<List<OtoExecutionViewModel>> _executionsSnapshot =
const OtoSurfaceSnapshot.loading();
OtoSurfaceSnapshot<List<OtoArtifactViewModel>> _artifactsSnapshot =
const OtoSurfaceSnapshot.loading();
String? _expandedExecutionID;
OtoSurfaceSnapshot<List<OtoLogEntryViewModel>>? _logsSnapshot;
OtoSurfaceSnapshot<List<OtoArtifactViewModel>>? _expandedArtifactsSnapshot;
OtoConsoleSection _activeSection = OtoConsoleSection.overview;
int _refreshGeneration = 0;
@override
@ -80,7 +96,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
if (oldWidget.config.serverHttpUrl != widget.config.serverHttpUrl ||
oldWidget.coreClient != widget.coreClient ||
oldWidget.readClient != widget.readClient ||
!_listEquals(oldWidget.runnerIDs, widget.runnerIDs)) {
!_listEquals(oldWidget.runnerIDs, widget.runnerIDs) ||
!_listEquals(oldWidget.jobIDs, widget.jobIDs) ||
!_listEquals(oldWidget.executionIDs, widget.executionIDs)) {
_refreshCoreStatus();
}
}
@ -100,6 +118,9 @@ class _OtoClientAppState extends State<OtoClientApp> {
setState(() {
_connection = const OtoCoreConnectionSnapshot.checking();
_runnersSnapshot = const OtoSurfaceSnapshot.loading();
_jobsSnapshot = const OtoSurfaceSnapshot.loading();
_executionsSnapshot = const OtoSurfaceSnapshot.loading();
_artifactsSnapshot = const OtoSurfaceSnapshot.loading();
});
final coreClient = widget.coreClient ?? _defaultCoreClient;
@ -112,10 +133,31 @@ class _OtoClientAppState extends State<OtoClientApp> {
final Future<OtoSurfaceSnapshot<List<OtoRunnerViewModel>>> runnersFuture =
_fetchRunners(readClient);
final results = await Future.wait([connectionFuture, runnersFuture]);
final Future<OtoSurfaceSnapshot<List<OtoJobViewModel>>> jobsFuture =
_fetchJobs(readClient);
final Future<OtoSurfaceSnapshot<List<OtoExecutionViewModel>>> executionsFuture =
_fetchExecutions(readClient);
final Future<OtoSurfaceSnapshot<List<OtoArtifactViewModel>>> artifactsFuture =
_fetchArtifacts(readClient);
final results = await Future.wait([
connectionFuture,
runnersFuture,
jobsFuture,
executionsFuture,
artifactsFuture,
]);
final connection = results[0] as OtoCoreConnectionSnapshot;
final runnersSnapshot =
results[1] as OtoSurfaceSnapshot<List<OtoRunnerViewModel>>;
final jobsSnapshot =
results[2] as OtoSurfaceSnapshot<List<OtoJobViewModel>>;
final executionsSnapshot =
results[3] as OtoSurfaceSnapshot<List<OtoExecutionViewModel>>;
final artifactsSnapshot =
results[4] as OtoSurfaceSnapshot<List<OtoArtifactViewModel>>;
if (!mounted || generation != _refreshGeneration) {
return;
@ -124,7 +166,14 @@ class _OtoClientAppState extends State<OtoClientApp> {
setState(() {
_connection = connection;
_runnersSnapshot = runnersSnapshot;
_jobsSnapshot = jobsSnapshot;
_executionsSnapshot = executionsSnapshot;
_artifactsSnapshot = artifactsSnapshot;
});
if (_expandedExecutionID != null) {
_loadExecutionDetail(_expandedExecutionID);
}
}
Future<OtoSurfaceSnapshot<List<OtoRunnerViewModel>>> _fetchRunners(
@ -198,6 +247,253 @@ class _OtoClientAppState extends State<OtoClientApp> {
}
}
Future<OtoSurfaceSnapshot<List<OtoJobViewModel>>> _fetchJobs(
OtoCoreReadClient readClient,
) async {
if (widget.jobIDs.isEmpty) {
return const OtoSurfaceSnapshot.empty();
}
try {
final results = await Future.wait(
widget.jobIDs.map((id) => readClient.fetchJob(widget.config, id)),
);
final viewModels = <OtoJobViewModel>[];
String? errorMessage;
bool isLoading = false;
bool isEmpty = true;
for (final result in results) {
if (result.state == OtoCoreReadState.error) {
errorMessage = result.message;
break;
}
if (result.state == OtoCoreReadState.loading) {
isLoading = true;
break;
}
if (result.state == OtoCoreReadState.data) {
isEmpty = false;
final record = result.data!;
viewModels.add(
OtoJobViewModel(
jobID: record.id,
name: record.name,
state: record.state,
executionID: record.executionID,
createdAt: record.createdAt?.toIso8601String() ?? '',
updatedAt: record.updatedAt?.toIso8601String() ?? '',
),
);
}
}
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());
}
}
Future<OtoSurfaceSnapshot<List<OtoExecutionViewModel>>> _fetchExecutions(
OtoCoreReadClient readClient,
) async {
if (widget.executionIDs.isEmpty) {
return const OtoSurfaceSnapshot.empty();
}
try {
final results = await Future.wait(
widget.executionIDs.map((id) => readClient.fetchExecution(widget.config, id)),
);
final viewModels = <OtoExecutionViewModel>[];
String? errorMessage;
bool isLoading = false;
bool isEmpty = true;
for (final result in results) {
if (result.state == OtoCoreReadState.error) {
errorMessage = result.message;
break;
}
if (result.state == OtoCoreReadState.loading) {
isLoading = true;
break;
}
if (result.state == OtoCoreReadState.data) {
isEmpty = false;
final record = result.data!;
viewModels.add(
OtoExecutionViewModel(
executionID: record.id,
jobID: record.jobID,
state: record.state,
runnerID: '',
createdAt: record.createdAt?.toIso8601String() ?? '',
updatedAt: record.updatedAt?.toIso8601String() ?? '',
),
);
}
}
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());
}
}
Future<OtoSurfaceSnapshot<List<OtoArtifactViewModel>>> _fetchArtifacts(
OtoCoreReadClient readClient,
) async {
if (widget.executionIDs.isEmpty) {
return const OtoSurfaceSnapshot.empty();
}
try {
final results = await Future.wait(
widget.executionIDs.map((id) => readClient.fetchArtifacts(widget.config, id)),
);
final viewModels = <OtoArtifactViewModel>[];
String? errorMessage;
bool isLoading = false;
bool isEmpty = true;
for (final result in results) {
if (result.state == OtoCoreReadState.error) {
errorMessage = result.message;
break;
}
if (result.state == OtoCoreReadState.loading) {
isLoading = true;
break;
}
if (result.state == OtoCoreReadState.data || result.state == OtoCoreReadState.empty) {
if (result.state == OtoCoreReadState.data) {
isEmpty = false;
final records = result.data ?? [];
for (final record in records) {
viewModels.add(
OtoArtifactViewModel(
name: record.name,
path: record.path,
),
);
}
}
}
}
if (errorMessage != null) {
return OtoSurfaceSnapshot.error(errorMessage);
} else if (isLoading) {
return const OtoSurfaceSnapshot.loading();
} else if (isEmpty && viewModels.isEmpty) {
return const OtoSurfaceSnapshot.empty();
} else {
return OtoSurfaceSnapshot.data(viewModels);
}
} on Exception catch (e) {
return OtoSurfaceSnapshot.error(e.toString());
}
}
Future<void> _loadExecutionDetail(String? execID) async {
if (execID == null) {
setState(() {
_expandedExecutionID = null;
_logsSnapshot = null;
_expandedArtifactsSnapshot = null;
});
return;
}
setState(() {
_expandedExecutionID = execID;
_logsSnapshot = const OtoSurfaceSnapshot.loading();
_expandedArtifactsSnapshot = const OtoSurfaceSnapshot.loading();
});
final readClient = widget.readClient ?? _defaultReadClient;
try {
final Future<OtoCoreReadResult<List<OtoExecutionLogEntry>>> logsFuture =
readClient.fetchLogs(widget.config, execID);
final Future<OtoCoreReadResult<List<OtoArtifactRecord>>> artifactsFuture =
readClient.fetchArtifacts(widget.config, execID);
final results = await Future.wait([logsFuture, artifactsFuture]);
final logsResult = results[0] as OtoCoreReadResult<List<OtoExecutionLogEntry>>;
final artifactsResult = results[1] as OtoCoreReadResult<List<OtoArtifactRecord>>;
if (!mounted || _expandedExecutionID != execID) {
return;
}
setState(() {
if (logsResult.state == OtoCoreReadState.error) {
_logsSnapshot = OtoSurfaceSnapshot.error(logsResult.message);
} else if (logsResult.state == OtoCoreReadState.loading) {
_logsSnapshot = const OtoSurfaceSnapshot.loading();
} else if (logsResult.state == OtoCoreReadState.empty || logsResult.data == null) {
_logsSnapshot = const OtoSurfaceSnapshot.empty();
} else {
_logsSnapshot = OtoSurfaceSnapshot.data(
logsResult.data!
.map((e) => OtoLogEntryViewModel(
timestamp: e.timestamp?.toIso8601String() ?? '',
line: e.line,
))
.toList(),
);
}
if (artifactsResult.state == OtoCoreReadState.error) {
_expandedArtifactsSnapshot =
OtoSurfaceSnapshot.error(artifactsResult.message);
} else if (artifactsResult.state == OtoCoreReadState.loading) {
_expandedArtifactsSnapshot = const OtoSurfaceSnapshot.loading();
} else if (artifactsResult.state == OtoCoreReadState.empty || artifactsResult.data == null) {
_expandedArtifactsSnapshot = const OtoSurfaceSnapshot.empty();
} else {
_expandedArtifactsSnapshot = OtoSurfaceSnapshot.data(
artifactsResult.data!
.map((e) => OtoArtifactViewModel(
name: e.name,
path: e.path,
))
.toList(),
);
}
});
} on Exception catch (e) {
if (mounted && _expandedExecutionID == execID) {
setState(() {
_logsSnapshot = OtoSurfaceSnapshot.error(e.toString());
_expandedArtifactsSnapshot = OtoSurfaceSnapshot.error(e.toString());
});
}
}
}
@override
Widget build(BuildContext context) {
const themeAdapter = OtoConsoleThemeAdapter();
@ -217,6 +513,12 @@ class _OtoClientAppState extends State<OtoClientApp> {
config: widget.config,
capabilities: widget.capabilities,
themeAdapter: themeAdapter,
initialSection: _activeSection,
onNavigate: (section) {
setState(() {
_activeSection = section;
});
},
overview: OtoConsoleOverview(
config: widget.config,
connection: _connection,
@ -227,6 +529,33 @@ class _OtoClientAppState extends State<OtoClientApp> {
snapshot: _runnersSnapshot,
themeAdapter: themeAdapter,
),
pipelines: OtoJobsSurface(
snapshot: _jobsSnapshot,
themeAdapter: themeAdapter,
onExecutionTap: (execID) {
_loadExecutionDetail(execID);
setState(() {
_activeSection = OtoConsoleSection.executions;
});
},
),
executions: OtoExecutionsSurface(
snapshot: _executionsSnapshot,
expandedExecutionID: _expandedExecutionID,
logsSnapshot: _logsSnapshot,
artifactsSnapshot: _expandedArtifactsSnapshot,
onExpandExecution: _loadExecutionDetail,
onViewArtifactsSection: () {
setState(() {
_activeSection = OtoConsoleSection.artifacts;
});
},
themeAdapter: themeAdapter,
),
artifacts: OtoArtifactsSurface(
snapshot: _artifactsSnapshot,
themeAdapter: themeAdapter,
),
),
);
}

View file

@ -244,6 +244,8 @@ void main() {
expect(find.textContaining('Job: job-999'), findsOneWidget);
expect(find.textContaining('Execution: exec-888'), findsOneWidget);
});
_registerJobsExecutionsTests();
}
http.Response _jsonResponse(Map<String, Object?> body) {
@ -283,9 +285,24 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
onFetchRunner;
final OtoCoreReadResult<OtoRunnerStatus> Function(String runnerID)?
onFetchRunnerStatus;
final OtoCoreReadResult<OtoJobRecord> Function(String jobID)?
onFetchJob;
final OtoCoreReadResult<OtoExecutionRecord> Function(String executionID)?
onFetchExecution;
final OtoCoreReadResult<List<OtoExecutionLogEntry>> Function(String executionID)?
onFetchLogs;
final OtoCoreReadResult<List<OtoArtifactRecord>> Function(String executionID)?
onFetchArtifacts;
OtoConsoleConfig? lastUsedConfig;
_FakeCoreReadClient({this.onFetchRunner, this.onFetchRunnerStatus});
_FakeCoreReadClient({
this.onFetchRunner,
this.onFetchRunnerStatus,
this.onFetchJob,
this.onFetchExecution,
this.onFetchLogs,
this.onFetchArtifacts,
});
@override
Future<OtoCoreReadResult<OtoRunnerRecord>> fetchRunner(
@ -316,6 +333,10 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
OtoConsoleConfig config,
String jobID,
) async {
lastUsedConfig = config;
if (onFetchJob != null) {
return onFetchJob!(jobID);
}
return const OtoCoreReadResult.empty();
}
@ -324,6 +345,10 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
OtoConsoleConfig config,
String executionID,
) async {
lastUsedConfig = config;
if (onFetchExecution != null) {
return onFetchExecution!(executionID);
}
return const OtoCoreReadResult.empty();
}
@ -332,6 +357,10 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
OtoConsoleConfig config,
String executionID,
) async {
lastUsedConfig = config;
if (onFetchLogs != null) {
return onFetchLogs!(executionID);
}
return const OtoCoreReadResult.empty();
}
@ -340,6 +369,218 @@ class _FakeCoreReadClient implements OtoCoreReadClient {
OtoConsoleConfig config,
String executionID,
) async {
lastUsedConfig = config;
if (onFetchArtifacts != null) {
return onFetchArtifacts!(executionID);
}
return const OtoCoreReadResult.empty();
}
}
void _registerJobsExecutionsTests() {
testWidgets('OtoClientApp wires jobs, executions and artifacts data state on API success', (
tester,
) async {
final fakeReadClient = _FakeCoreReadClient(
onFetchJob: (id) => OtoCoreReadResult.data(
OtoJobRecord(
id: id,
name: 'Deploy Pipeline',
state: 'running',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
executionID: 'exec-99',
runRequest: null,
),
),
onFetchExecution: (id) => OtoCoreReadResult.data(
OtoExecutionRecord(
id: id,
jobID: 'job-77',
state: 'succeeded',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
executionID: id,
),
),
onFetchLogs: (id) => const OtoCoreReadResult.data([
OtoExecutionLogEntry(timestamp: null, line: 'Task 1 done'),
]),
onFetchArtifacts: (id) => const OtoCoreReadResult.data([
OtoArtifactRecord(name: 'release.tar.gz', path: '/out/release.tar.gz'),
]),
);
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: fakeReadClient,
jobIDs: const ['job-77'],
executionIDs: const ['exec-99'],
),
);
await tester.pumpAndSettle();
// 1. Pipelines / Jobs Tab
await tester.tap(find.byTooltip('Pipelines'));
await tester.pumpAndSettle();
expect(find.text('Deploy Pipeline'), findsOneWidget);
expect(find.text('RUNNING'), findsOneWidget);
expect(find.text('Job ID: job-77'), findsOneWidget);
// 2. Executions Tab
await tester.tap(find.byTooltip('Executions'));
await tester.pumpAndSettle();
expect(find.text('Exec ID: exec-99'), findsOneWidget);
expect(find.text('SUCCEEDED'), findsOneWidget);
expect(find.text('Job ID: job-77'), findsOneWidget);
// Expand to see logs/artifacts
await tester.tap(find.text('Exec ID: exec-99'));
await tester.pumpAndSettle();
expect(find.text('Logs Preview'), findsOneWidget);
expect(find.textContaining('Task 1 done'), findsOneWidget);
expect(find.text('release.tar.gz'), findsOneWidget);
// 3. Artifacts Tab
await tester.tap(find.byTooltip('Artifacts'));
await tester.pumpAndSettle();
expect(find.text('release.tar.gz'), findsOneWidget);
expect(find.text('Path: /out/release.tar.gz'), findsOneWidget);
});
testWidgets('OtoClientApp wires jobs, executions and artifacts empty states when no IDs', (
tester,
) async {
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: _FakeCoreReadClient(),
jobIDs: const [],
executionIDs: const [],
),
);
await tester.pumpAndSettle();
// Pipelines Tab empty check
await tester.tap(find.byTooltip('Pipelines'));
await tester.pumpAndSettle();
expect(find.text('No pipeline jobs'), findsOneWidget);
expect(find.text('Job queue is empty.'), findsOneWidget);
// Executions Tab empty check
await tester.tap(find.byTooltip('Executions'));
await tester.pumpAndSettle();
expect(find.text('No executions'), findsOneWidget);
expect(find.text('Execution history is empty.'), findsOneWidget);
// Artifacts Tab empty check
await tester.tap(find.byTooltip('Artifacts'));
await tester.pumpAndSettle();
expect(find.text('No artifacts'), findsOneWidget);
expect(find.text('Artifact events are empty.'), findsOneWidget);
});
testWidgets('OtoClientApp wires jobs, executions and artifacts error states on API failure', (
tester,
) async {
final fakeReadClient = _FakeCoreReadClient(
onFetchJob: (id) => const OtoCoreReadResult.error('Failed to fetch job 1'),
onFetchExecution: (id) => const OtoCoreReadResult.error('Failed to fetch exec 1'),
onFetchArtifacts: (id) => const OtoCoreReadResult.error('Failed to fetch artifacts 1'),
);
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: fakeReadClient,
jobIDs: const ['job-err'],
executionIDs: const ['exec-err'],
),
);
await tester.pumpAndSettle();
// Pipelines Tab error check
await tester.tap(find.byTooltip('Pipelines'));
await tester.pumpAndSettle();
expect(find.text('Error loading jobs'), findsOneWidget);
expect(find.text('Failed to fetch job 1'), findsOneWidget);
// Executions Tab error check
await tester.tap(find.byTooltip('Executions'));
await tester.pumpAndSettle();
expect(find.text('Error loading executions'), findsOneWidget);
expect(find.text('Failed to fetch exec 1'), findsOneWidget);
// Artifacts Tab error check
await tester.tap(find.byTooltip('Artifacts'));
await tester.pumpAndSettle();
expect(find.text('Error loading artifacts'), findsOneWidget);
expect(find.text('Failed to fetch artifacts 1'), findsOneWidget);
});
testWidgets('OtoClientApp wires expanded execution detail logs and artifacts empty/error states', (
tester,
) async {
final executions = [
OtoExecutionRecord(
id: 'exec-detail',
jobID: 'job-1',
state: 'running',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
executionID: 'exec-detail',
),
];
bool shouldError = false;
final fakeReadClient = _FakeCoreReadClient(
onFetchExecution: (id) => OtoCoreReadResult.data(executions.first),
onFetchLogs: (id) => shouldError
? const OtoCoreReadResult.error('Failed to load logs')
: const OtoCoreReadResult.empty(data: []),
onFetchArtifacts: (id) => shouldError
? const OtoCoreReadResult.error('Failed to load artifacts')
: const OtoCoreReadResult.empty(data: []),
);
// 1. Render App and Expand to verify Empty State
await tester.pumpWidget(
OtoClientApp(
coreClient: _FakeCoreClient(),
readClient: fakeReadClient,
executionIDs: const ['exec-detail'],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Executions'));
await tester.pumpAndSettle();
// Expand
await tester.tap(find.text('Exec ID: exec-detail'));
await tester.pumpAndSettle();
expect(find.text('Log is empty.'), findsOneWidget);
expect(find.text('No artifacts produced.'), findsOneWidget);
// Collapse
await tester.tap(find.text('Exec ID: exec-detail'));
await tester.pumpAndSettle();
// Change to error state
shouldError = true;
// Expand again to reload detail in error state
await tester.tap(find.text('Exec ID: exec-detail'));
await tester.pumpAndSettle();
expect(find.text('Error loading logs: Failed to load logs'), findsOneWidget);
expect(find.text('Error loading artifacts: Failed to load artifacts'), findsOneWidget);
});
}

View file

@ -3,3 +3,6 @@ export 'src/oto_console_contract.dart';
export 'src/oto_console_overview.dart';
export 'src/oto_console_shell.dart';
export 'src/oto_runners_surface.dart';
export 'src/oto_jobs_surface.dart';
export 'src/oto_executions_surface.dart';
export 'src/oto_artifacts_surface.dart';

View file

@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'oto_console_contract.dart';
class OtoArtifactsSurface extends StatelessWidget {
final OtoSurfaceSnapshot<List<OtoArtifactViewModel>> snapshot;
final OtoConsoleThemeAdapter? themeAdapter;
const OtoArtifactsSurface({
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.inventory_2_outlined,
color: theme.primaryColor,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Artifacts',
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 artifacts = snapshot.data ?? [];
if (artifacts.isEmpty) {
return _buildEmptyState(theme);
}
return _buildArtifactsList(context, theme, artifacts);
}
}
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.inventory_2_outlined,
color: theme.primaryColor,
size: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No artifacts',
style: TextStyle(
color: theme.textColor,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'Artifact events are 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 artifacts',
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 _buildArtifactsList(
BuildContext context,
OtoConsoleThemeAdapter theme,
List<OtoArtifactViewModel> artifacts,
) {
return ListView.separated(
itemCount: artifacts.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final artifact = artifacts[index];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.railBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.borderColor),
),
child: Row(
children: [
Icon(
Icons.insert_drive_file_outlined,
color: theme.primaryColor,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
artifact.name,
style: TextStyle(
color: theme.textColor,
fontSize: 15,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Path: ${artifact.path}',
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
},
);
}
}

View file

@ -179,3 +179,59 @@ class OtoRunnerViewModel {
required this.message,
});
}
class OtoJobViewModel {
final String jobID;
final String name;
final String state;
final String executionID;
final String createdAt;
final String updatedAt;
const OtoJobViewModel({
required this.jobID,
required this.name,
required this.state,
required this.executionID,
required this.createdAt,
required this.updatedAt,
});
}
class OtoExecutionViewModel {
final String executionID;
final String jobID;
final String state;
final String runnerID;
final String createdAt;
final String updatedAt;
const OtoExecutionViewModel({
required this.executionID,
required this.jobID,
required this.state,
required this.runnerID,
required this.createdAt,
required this.updatedAt,
});
}
class OtoLogEntryViewModel {
final String timestamp;
final String line;
const OtoLogEntryViewModel({
required this.timestamp,
required this.line,
});
}
class OtoArtifactViewModel {
final String name;
final String path;
const OtoArtifactViewModel({
required this.name,
required this.path,
});
}

View file

@ -40,6 +40,14 @@ class OtoConsoleShell extends StatefulWidget {
class _OtoConsoleShellState extends State<OtoConsoleShell> {
late OtoConsoleSection _section = widget.initialSection;
@override
void didUpdateWidget(covariant OtoConsoleShell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialSection != widget.initialSection) {
_section = widget.initialSection;
}
}
@override
Widget build(BuildContext context) {
final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter();

View file

@ -0,0 +1,499 @@
import 'package:flutter/material.dart';
import 'oto_console_contract.dart';
class OtoExecutionsSurface extends StatelessWidget {
final OtoSurfaceSnapshot<List<OtoExecutionViewModel>> snapshot;
final String? expandedExecutionID;
final OtoSurfaceSnapshot<List<OtoLogEntryViewModel>>? logsSnapshot;
final OtoSurfaceSnapshot<List<OtoArtifactViewModel>>? artifactsSnapshot;
final ValueChanged<String?>? onExpandExecution;
final VoidCallback? onViewArtifactsSection;
final OtoConsoleThemeAdapter? themeAdapter;
const OtoExecutionsSurface({
super.key,
required this.snapshot,
this.expandedExecutionID,
this.logsSnapshot,
this.artifactsSnapshot,
this.onExpandExecution,
this.onViewArtifactsSection,
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.play_circle_outline,
color: theme.primaryColor,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Executions',
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 executions = snapshot.data ?? [];
if (executions.isEmpty) {
return _buildEmptyState(theme);
}
return _buildExecutionsList(context, theme, executions);
}
}
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.play_circle_outline,
color: theme.primaryColor,
size: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No executions',
style: TextStyle(
color: theme.textColor,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'Execution history 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 executions',
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 _buildExecutionsList(
BuildContext context,
OtoConsoleThemeAdapter theme,
List<OtoExecutionViewModel> executions,
) {
return ListView.separated(
itemCount: executions.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final exec = executions[index];
final isExpanded = expandedExecutionID == exec.executionID;
return Container(
decoration: BoxDecoration(
color: theme.railBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
if (isExpanded) {
onExpandExecution?.call(null);
} else {
onExpandExecution?.call(exec.executionID);
}
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Exec ID: ${exec.executionID}',
style: TextStyle(
color: theme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_StatusBadge(state: exec.state, theme: theme),
],
),
const SizedBox(height: 8),
Text(
'Job ID: ${exec.jobID}',
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 13,
),
),
if (exec.runnerID.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Runner ID: ${exec.runnerID}',
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 13,
),
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Created: ${exec.createdAt}',
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
isExpanded
? Icons.expand_less
: Icons.expand_more,
color: theme.textColor,
),
],
),
],
),
),
),
if (isExpanded) ...[
const Divider(height: 1, thickness: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLogsSection(theme),
const SizedBox(height: 16),
_buildArtifactsSection(theme),
],
),
),
],
],
),
);
},
);
}
Widget _buildLogsSection(OtoConsoleThemeAdapter theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Logs Preview',
style: TextStyle(
color: theme.textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: theme.borderColor),
),
constraints: const BoxConstraints(maxHeight: 180),
child: _buildLogsContent(theme),
),
],
);
}
Widget _buildLogsContent(OtoConsoleThemeAdapter theme) {
if (logsSnapshot == null) {
return Text(
'No logs loaded.',
style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'),
);
}
switch (logsSnapshot!.state) {
case OtoSurfaceLoadState.loading:
return const Center(child: CircularProgressIndicator());
case OtoSurfaceLoadState.empty:
return Text(
'Log is empty.',
style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'),
);
case OtoSurfaceLoadState.error:
return Text(
'Error loading logs: ${logsSnapshot!.errorMessage}',
style: const TextStyle(color: Colors.red, fontFamily: 'monospace'),
);
case OtoSurfaceLoadState.data:
final logs = logsSnapshot!.data ?? [];
if (logs.isEmpty) {
return Text(
'Log is empty.',
style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'),
);
}
return SingleChildScrollView(
child: Text(
logs.map((e) => '[${e.timestamp}] ${e.line}').join('\n'),
style: TextStyle(
color: theme.textColor,
fontFamily: 'monospace',
fontSize: 12,
),
),
);
}
}
Widget _buildArtifactsSection(OtoConsoleThemeAdapter theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Artifacts',
style: TextStyle(
color: theme.textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
if (onViewArtifactsSection != null)
TextButton.icon(
onPressed: onViewArtifactsSection,
icon: const Icon(Icons.arrow_forward, size: 14),
label: const Text('View Artifacts Tab'),
style: TextButton.styleFrom(
foregroundColor: theme.primaryColor,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
],
),
const SizedBox(height: 8),
_buildArtifactsContent(theme),
],
);
}
Widget _buildArtifactsContent(OtoConsoleThemeAdapter theme) {
if (artifactsSnapshot == null) {
return Text(
'No artifacts loaded.',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
);
}
switch (artifactsSnapshot!.state) {
case OtoSurfaceLoadState.loading:
return const Center(child: CircularProgressIndicator());
case OtoSurfaceLoadState.empty:
return Text(
'No artifacts produced.',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
);
case OtoSurfaceLoadState.error:
return Text(
'Error loading artifacts: ${artifactsSnapshot!.errorMessage}',
style: const TextStyle(color: Colors.red, fontSize: 13),
);
case OtoSurfaceLoadState.data:
final artifacts = artifactsSnapshot!.data ?? [];
if (artifacts.isEmpty) {
return Text(
'No artifacts produced.',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
);
}
return Column(
children: artifacts.map((artifact) {
return Card(
color: theme.backgroundColor,
margin: const EdgeInsets.only(bottom: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: BorderSide(color: theme.borderColor),
),
child: ListTile(
dense: true,
leading: Icon(Icons.insert_drive_file, color: theme.primaryColor, size: 16),
title: Text(
artifact.name,
style: TextStyle(color: theme.textColor, fontSize: 13, fontWeight: FontWeight.bold),
),
subtitle: Text(
artifact.path,
style: TextStyle(color: theme.textSecondaryColor, fontSize: 11),
),
),
);
}).toList(),
);
}
}
}
class _StatusBadge extends StatelessWidget {
final String state;
final OtoConsoleThemeAdapter theme;
const _StatusBadge({required this.state, required this.theme});
@override
Widget build(BuildContext context) {
Color color;
switch (state.toLowerCase()) {
case 'queued':
color = theme.textSecondaryColor;
break;
case 'running':
color = theme.accentColor;
break;
case 'succeeded':
color = theme.primaryColor;
break;
case 'failed':
color = Colors.red;
break;
case 'canceled':
color = Colors.orange;
break;
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(
state.toUpperCase(),
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
);
}
}

View file

@ -0,0 +1,319 @@
import 'package:flutter/material.dart';
import 'oto_console_contract.dart';
class OtoJobsSurface extends StatelessWidget {
final OtoSurfaceSnapshot<List<OtoJobViewModel>> snapshot;
final OtoConsoleThemeAdapter? themeAdapter;
final ValueChanged<String>? onExecutionTap;
const OtoJobsSurface({
super.key,
required this.snapshot,
this.themeAdapter,
this.onExecutionTap,
});
@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.account_tree_outlined,
color: theme.primaryColor,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Pipelines / Jobs',
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 jobs = snapshot.data ?? [];
if (jobs.isEmpty) {
return _buildEmptyState(theme);
}
return _buildJobsList(context, theme, jobs);
}
}
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.account_tree_outlined,
color: theme.primaryColor,
size: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No pipeline jobs',
style: TextStyle(
color: theme.textColor,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'Job queue 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 jobs',
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 _buildJobsList(
BuildContext context,
OtoConsoleThemeAdapter theme,
List<OtoJobViewModel> jobs,
) {
return ListView.separated(
itemCount: jobs.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final job = jobs[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(
job.name,
style: TextStyle(
color: theme.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_StatusBadge(state: job.state, theme: theme),
],
),
const SizedBox(height: 8),
Text(
'Job ID: ${job.jobID}',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(
'Created: ${job.createdAt}',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 16),
Expanded(
child: Text(
'Updated: ${job.updatedAt}',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
],
),
if (job.executionID.isNotEmpty) ...[
const SizedBox(height: 12),
InkWell(
onTap: () => onExecutionTap?.call(job.executionID),
borderRadius: BorderRadius.circular(4),
child: 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(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.play_circle_outline,
color: theme.primaryColor,
size: 16,
),
const SizedBox(width: 8),
Text(
'Execution: ${job.executionID}',
style: TextStyle(
color: theme.primaryColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
],
),
);
},
);
}
}
class _StatusBadge extends StatelessWidget {
final String state;
final OtoConsoleThemeAdapter theme;
const _StatusBadge({required this.state, required this.theme});
@override
Widget build(BuildContext context) {
Color color;
switch (state.toLowerCase()) {
case 'queued':
color = theme.textSecondaryColor;
break;
case 'running':
color = theme.accentColor;
break;
case 'succeeded':
color = theme.primaryColor;
break;
case 'failed':
color = Colors.red;
break;
case 'canceled':
color = Colors.orange;
break;
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(
state.toUpperCase(),
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
);
}
}

View file

@ -210,4 +210,153 @@ void main() {
expect(find.text('OFFLINE'), findsOneWidget);
expect(find.text('Message: Connection lost'), findsOneWidget);
});
testWidgets('OtoJobsSurface renders loading/empty/error/data states', (tester) async {
// Loading
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.loading()))));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Empty
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.empty()))));
expect(find.text('No pipeline jobs'), findsOneWidget);
expect(find.text('Job queue is empty.'), findsOneWidget);
// Error
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.error('Failed to fetch jobs')))));
expect(find.text('Error loading jobs'), findsOneWidget);
expect(find.text('Failed to fetch jobs'), findsOneWidget);
// Data
final jobs = [
const OtoJobViewModel(
jobID: 'job-1',
name: 'Build Job',
state: 'running',
executionID: 'exec-1',
createdAt: '2026-06-15T00:00:00Z',
updatedAt: '2026-06-15T00:05:00Z',
),
];
String? tappedExecution;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: OtoJobsSurface(
snapshot: OtoSurfaceSnapshot.data(jobs),
onExecutionTap: (id) => tappedExecution = id,
),
),
),
);
expect(find.text('Build Job'), findsOneWidget);
expect(find.text('RUNNING'), findsOneWidget);
expect(find.text('Job ID: job-1'), findsOneWidget);
expect(find.textContaining('Execution: exec-1'), findsOneWidget);
await tester.tap(find.textContaining('Execution: exec-1'));
await tester.pumpAndSettle();
expect(tappedExecution, 'exec-1');
});
testWidgets('OtoExecutionsSurface renders loading/empty/error/data states and expanded detail', (tester) async {
// Loading
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.loading()))));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Empty
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.empty()))));
expect(find.text('No executions'), findsOneWidget);
expect(find.text('Execution history is empty.'), findsOneWidget);
// Error
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.error('Failed to fetch executions')))));
expect(find.text('Error loading executions'), findsOneWidget);
expect(find.text('Failed to fetch executions'), findsOneWidget);
// Data
final executions = [
const OtoExecutionViewModel(
executionID: 'exec-1',
jobID: 'job-1',
state: 'succeeded',
runnerID: 'runner-1',
createdAt: '2026-06-15T00:01:00Z',
updatedAt: '2026-06-15T00:05:00Z',
),
];
String? expandedId;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: OtoExecutionsSurface(
snapshot: OtoSurfaceSnapshot.data(executions),
onExpandExecution: (id) => expandedId = id,
),
),
),
);
expect(find.text('Exec ID: exec-1'), findsOneWidget);
expect(find.text('SUCCEEDED'), findsOneWidget);
expect(find.text('Job ID: job-1'), findsOneWidget);
expect(find.text('Runner ID: runner-1'), findsOneWidget);
await tester.tap(find.text('Exec ID: exec-1'));
await tester.pumpAndSettle();
expect(expandedId, 'exec-1');
// Expanded detail rendering
final logs = [
const OtoLogEntryViewModel(timestamp: '12:00:00', line: 'Starting build...'),
const OtoLogEntryViewModel(timestamp: '12:00:01', line: 'Finished successfully'),
];
final artifacts = [
const OtoArtifactViewModel(name: 'binary.exe', path: '/build/binary.exe'),
];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: OtoExecutionsSurface(
snapshot: OtoSurfaceSnapshot.data(executions),
expandedExecutionID: 'exec-1',
logsSnapshot: OtoSurfaceSnapshot.data(logs),
artifactsSnapshot: OtoSurfaceSnapshot.data(artifacts),
),
),
),
);
expect(find.text('Logs Preview'), findsOneWidget);
expect(find.textContaining('Starting build...'), findsOneWidget);
expect(find.text('Artifacts'), findsOneWidget);
expect(find.text('binary.exe'), findsOneWidget);
});
testWidgets('OtoArtifactsSurface renders loading/empty/error/data states', (tester) async {
// Loading
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.loading()))));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Empty
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.empty()))));
expect(find.text('No artifacts'), findsOneWidget);
expect(find.text('Artifact events are empty.'), findsOneWidget);
// Error
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.error('Failed to fetch artifacts')))));
expect(find.text('Error loading artifacts'), findsOneWidget);
expect(find.text('Failed to fetch artifacts'), findsOneWidget);
// Data
final artifacts = [
const OtoArtifactViewModel(name: 'app.apk', path: '/build/app.apk'),
];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.data(artifacts)),
),
),
);
expect(find.text('app.apk'), findsOneWidget);
expect(find.text('Path: /build/app.apk'), findsOneWidget);
});
}