diff --git a/agent-roadmap/phase/control-plane-product-surface/PHASE.md b/agent-roadmap/phase/control-plane-product-surface/PHASE.md index 94ece8d..3454776 100644 --- a/agent-roadmap/phase/control-plane-product-surface/PHASE.md +++ b/agent-roadmap/phase/control-plane-product-surface/PHASE.md @@ -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를 연결하고, 실제 데이터가 비어 있거나 실패할 때의 상태 표현을 검증한다. diff --git a/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md b/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md index 4f349a6..a0f1a19 100644 --- a/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md +++ b/agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-api-data-binding.md @@ -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 콘솔 스켈레톤` - 후속 작업: 쓰기 액션, 인증/권한, 운영 대시보드 고도화 - 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G06_0.log b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G06_0.log new file mode 100644 index 0000000..96a41e9 --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G06_0.log @@ -0,0 +1,183 @@ + + +# 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`를 작성한다. diff --git a/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/CODE_REVIEW-local-G06.md b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G07_1.log similarity index 58% rename from agent-task/m-control-plane-api-data-binding/02+01_execution_surface/CODE_REVIEW-local-G06.md rename to agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G07_1.log index dafd19a..95bf6e3 100644 --- a/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/CODE_REVIEW-local-G06.md +++ b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/code_review_local_G07_1.log @@ -1,6 +1,6 @@ - + -# 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/complete.log b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/complete.log new file mode 100644 index 0000000..b2d8e80 --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/PLAN-local-G06.md b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G06_0.log similarity index 95% rename from agent-task/m-control-plane-api-data-binding/02+01_execution_surface/PLAN-local-G06.md rename to agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G06_0.log index 773dc93..9dbeb08 100644 --- a/agent-task/m-control-plane-api-data-binding/02+01_execution_surface/PLAN-local-G06.md +++ b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G06_0.log @@ -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`만 좁게 확인한다. ## 구현 체크리스트 diff --git a/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G07_1.log b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G07_1.log new file mode 100644 index 0000000..37f63ba --- /dev/null +++ b/agent-task/archive/2026/06/m-control-plane-api-data-binding/02+01_execution_surface/plan_local_G07_1.log @@ -0,0 +1,97 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/CODE_REVIEW-cloud-G07.md b/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/CODE_REVIEW-cloud-G07.md index 75efc0c..2507139 100644 --- a/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/CODE_REVIEW-cloud-G07.md +++ b/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/CODE_REVIEW-cloud-G07.md @@ -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 화면 상태를 판단하기에 충분한지 확인한다. diff --git a/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md b/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md index fa8a1e6..0450900 100644 --- a/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md +++ b/agent-task/m-control-plane-api-data-binding/03+01,02_preview_smoke/PLAN-cloud-G07.md @@ -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 저장. diff --git a/apps/client/lib/src/app/oto_client_app.dart b/apps/client/lib/src/app/oto_client_app.dart index 1e1ad4c..9a753cf 100644 --- a/apps/client/lib/src/app/oto_client_app.dart +++ b/apps/client/lib/src/app/oto_client_app.dart @@ -34,6 +34,8 @@ class OtoClientApp extends StatefulWidget { final OtoCoreConnectionClient? coreClient; final OtoCoreReadClient? readClient; final List runnerIDs; + final List jobIDs; + final List 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 { late OtoCoreConnectionSnapshot _connection = widget.initialConnection; OtoSurfaceSnapshot> _runnersSnapshot = const OtoSurfaceSnapshot.loading(); + OtoSurfaceSnapshot> _jobsSnapshot = + const OtoSurfaceSnapshot.loading(); + OtoSurfaceSnapshot> _executionsSnapshot = + const OtoSurfaceSnapshot.loading(); + OtoSurfaceSnapshot> _artifactsSnapshot = + const OtoSurfaceSnapshot.loading(); + + String? _expandedExecutionID; + OtoSurfaceSnapshot>? _logsSnapshot; + OtoSurfaceSnapshot>? _expandedArtifactsSnapshot; + + OtoConsoleSection _activeSection = OtoConsoleSection.overview; int _refreshGeneration = 0; @override @@ -80,7 +96,9 @@ class _OtoClientAppState extends State { 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 { 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 { final Future>> runnersFuture = _fetchRunners(readClient); - final results = await Future.wait([connectionFuture, runnersFuture]); + final Future>> jobsFuture = + _fetchJobs(readClient); + + final Future>> executionsFuture = + _fetchExecutions(readClient); + + final Future>> 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>; + final jobsSnapshot = + results[2] as OtoSurfaceSnapshot>; + final executionsSnapshot = + results[3] as OtoSurfaceSnapshot>; + final artifactsSnapshot = + results[4] as OtoSurfaceSnapshot>; if (!mounted || generation != _refreshGeneration) { return; @@ -124,7 +166,14 @@ class _OtoClientAppState extends State { setState(() { _connection = connection; _runnersSnapshot = runnersSnapshot; + _jobsSnapshot = jobsSnapshot; + _executionsSnapshot = executionsSnapshot; + _artifactsSnapshot = artifactsSnapshot; }); + + if (_expandedExecutionID != null) { + _loadExecutionDetail(_expandedExecutionID); + } } Future>> _fetchRunners( @@ -198,6 +247,253 @@ class _OtoClientAppState extends State { } } + Future>> _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 = []; + 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>> _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 = []; + 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>> _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 = []; + 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 _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>> logsFuture = + readClient.fetchLogs(widget.config, execID); + + final Future>> artifactsFuture = + readClient.fetchArtifacts(widget.config, execID); + + final results = await Future.wait([logsFuture, artifactsFuture]); + final logsResult = results[0] as OtoCoreReadResult>; + final artifactsResult = results[1] as OtoCoreReadResult>; + + 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 { 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 { 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, + ), ), ); } diff --git a/apps/client/test/widget_test.dart b/apps/client/test/widget_test.dart index cafe31d..31198e4 100644 --- a/apps/client/test/widget_test.dart +++ b/apps/client/test/widget_test.dart @@ -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 body) { @@ -283,9 +285,24 @@ class _FakeCoreReadClient implements OtoCoreReadClient { onFetchRunner; final OtoCoreReadResult Function(String runnerID)? onFetchRunnerStatus; + final OtoCoreReadResult Function(String jobID)? + onFetchJob; + final OtoCoreReadResult Function(String executionID)? + onFetchExecution; + final OtoCoreReadResult> Function(String executionID)? + onFetchLogs; + final OtoCoreReadResult> 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> 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); + }); +} diff --git a/packages/flutter/oto_console/lib/oto_console.dart b/packages/flutter/oto_console/lib/oto_console.dart index b85b1e8..28c44d4 100644 --- a/packages/flutter/oto_console/lib/oto_console.dart +++ b/packages/flutter/oto_console/lib/oto_console.dart @@ -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'; diff --git a/packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart b/packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart new file mode 100644 index 0000000..7f30bfe --- /dev/null +++ b/packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'oto_console_contract.dart'; + +class OtoArtifactsSurface extends StatelessWidget { + final OtoSurfaceSnapshot> 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 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, + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/packages/flutter/oto_console/lib/src/oto_console_contract.dart b/packages/flutter/oto_console/lib/src/oto_console_contract.dart index 8704668..816d966 100644 --- a/packages/flutter/oto_console/lib/src/oto_console_contract.dart +++ b/packages/flutter/oto_console/lib/src/oto_console_contract.dart @@ -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, + }); +} diff --git a/packages/flutter/oto_console/lib/src/oto_console_shell.dart b/packages/flutter/oto_console/lib/src/oto_console_shell.dart index ee3bfbd..408a146 100644 --- a/packages/flutter/oto_console/lib/src/oto_console_shell.dart +++ b/packages/flutter/oto_console/lib/src/oto_console_shell.dart @@ -40,6 +40,14 @@ class OtoConsoleShell extends StatefulWidget { class _OtoConsoleShellState extends State { 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(); diff --git a/packages/flutter/oto_console/lib/src/oto_executions_surface.dart b/packages/flutter/oto_console/lib/src/oto_executions_surface.dart new file mode 100644 index 0000000..9b2c0f2 --- /dev/null +++ b/packages/flutter/oto_console/lib/src/oto_executions_surface.dart @@ -0,0 +1,499 @@ +import 'package:flutter/material.dart'; +import 'oto_console_contract.dart'; + +class OtoExecutionsSurface extends StatelessWidget { + final OtoSurfaceSnapshot> snapshot; + final String? expandedExecutionID; + final OtoSurfaceSnapshot>? logsSnapshot; + final OtoSurfaceSnapshot>? artifactsSnapshot; + final ValueChanged? 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 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, + ), + ), + ); + } +} diff --git a/packages/flutter/oto_console/lib/src/oto_jobs_surface.dart b/packages/flutter/oto_console/lib/src/oto_jobs_surface.dart new file mode 100644 index 0000000..8dae3f1 --- /dev/null +++ b/packages/flutter/oto_console/lib/src/oto_jobs_surface.dart @@ -0,0 +1,319 @@ +import 'package:flutter/material.dart'; +import 'oto_console_contract.dart'; + +class OtoJobsSurface extends StatelessWidget { + final OtoSurfaceSnapshot> snapshot; + final OtoConsoleThemeAdapter? themeAdapter; + final ValueChanged? 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 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, + ), + ), + ); + } +} diff --git a/packages/flutter/oto_console/test/oto_console_test.dart b/packages/flutter/oto_console/test/oto_console_test.dart index f05a9df..0902f7d 100644 --- a/packages/flutter/oto_console/test/oto_console_test.dart +++ b/packages/flutter/oto_console/test/oto_console_test.dart @@ -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); + }); }