From c3a5129818ab5ee466d1d1a141bcf948c6272b7d Mon Sep 17 00:00:00 2001 From: toki Date: Fri, 3 Jul 2026 15:31:01 +0900 Subject: [PATCH] feat: execution artifact views - jenkins aligned layout pass - Add oto_artifact_record_row.dart - Update oto_artifacts_surface.dart with new artifact records - Refactor oto_executions_surface.dart with aligned layout - Update oto_console_test.dart with new test coverage - Remove outdated G07 plan and code review files --- .../code_review_local_G07_0.log} | 141 +++- .../complete.log | 50 ++ .../plan_local_G07_0.log} | 0 apps/client/test/widget_test.dart | 2 + .../lib/src/oto_artifact_record_row.dart | 75 ++ .../lib/src/oto_artifacts_surface.dart | 48 +- .../lib/src/oto_executions_surface.dart | 638 ++++++++++++------ .../oto_console/test/oto_console_test.dart | 330 ++++++--- 8 files changed, 900 insertions(+), 384 deletions(-) rename agent-task/{m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/CODE_REVIEW-local-G07.md => archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/code_review_local_G07_0.log} (54%) create mode 100644 agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/complete.log rename agent-task/{m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/PLAN-local-G07.md => archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/plan_local_G07_0.log} (100%) create mode 100644 packages/flutter/oto_console/lib/src/oto_artifact_record_row.dart diff --git a/agent-task/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/CODE_REVIEW-local-G07.md b/agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/code_review_local_G07_0.log similarity index 54% rename from agent-task/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/CODE_REVIEW-local-G07.md rename to agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/code_review_local_G07_0.log index 40ad5e2..cda726b 100644 --- a/agent-task/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/CODE_REVIEW-local-G07.md +++ b/agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/code_review_local_G07_0.log @@ -43,48 +43,53 @@ task=m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_vie | 항목 | 완료 여부 | |------|---------| -| [LAYOUT-1] Execution List And Selected Detail | [ ] | -| [LAYOUT-2] Execution Action Toolbar And Feedback | [ ] | -| [LAYOUT-3] Log And Artifact Panes | [ ] | -| [LAYOUT-4] Artifacts Browser Anatomy | [ ] | +| [LAYOUT-1] Execution List And Selected Detail | [x] | +| [LAYOUT-2] Execution Action Toolbar And Feedback | [x] | +| [LAYOUT-3] Log And Artifact Panes | [x] | +| [LAYOUT-4] Artifacts Browser Anatomy | [x] | ## 구현 체크리스트 -- [ ] `01_definition_shell_foundation`과 `02+01_primary_views`의 `complete.log`가 active/archive predecessor로 존재하는지 확인하고 없으면 구현하지 않고 dependency missing을 기록한다. -- [ ] `OtoExecutionsSurface`를 execution list + selected run detail 관계로 재배치하고, run header/action toolbar/summary placeholder/log pane/artifact pane 순서를 구현한다. -- [ ] execution action toolbar에서 cancel/report/log/artifact guard, confirmation dialog, callback payload, submitting/success/failed feedback을 유지한다. -- [ ] logs preview를 selected execution detail의 독립 bounded pane으로 재배치하고 loading/empty/error/data states를 유지한다. -- [ ] artifacts preview와 artifacts tab을 같은 artifact-list anatomy로 정리하고 name/path ellipsis, empty/error/data states를 유지한다. -- [ ] record-list/status-badge/state-panel/action-feedback private reusable pattern을 surface 간 충돌 없이 정리한다. -- [ ] package/app widget tests를 갱신해 executions/artifacts state, action, navigation, detail preview를 검증한다. -- [ ] 원격 runner preflight와 Flutter analyze/test 계약을 실행하거나, Flutter PATH/source sync 차단이면 실제 출력과 차단 사유를 review stub에 남긴다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] `01_definition_shell_foundation`과 `02+01_primary_views`의 `complete.log`가 active/archive predecessor로 존재하는지 확인하고 없으면 구현하지 않고 dependency missing을 기록한다. +- [x] `OtoExecutionsSurface`를 execution list + selected run detail 관계로 재배치하고, run header/action toolbar/summary placeholder/log pane/artifact pane 순서를 구현한다. +- [x] execution action toolbar에서 cancel/report/log/artifact guard, confirmation dialog, callback payload, submitting/success/failed feedback을 유지한다. +- [x] logs preview를 selected execution detail의 독립 bounded pane으로 재배치하고 loading/empty/error/data states를 유지한다. +- [x] artifacts preview와 artifacts tab을 같은 artifact-list anatomy로 정리하고 name/path ellipsis, empty/error/data states를 유지한다. +- [x] record-list/status-badge/state-panel/action-feedback private reusable pattern을 surface 간 충돌 없이 정리한다. +- [x] package/app widget tests를 갱신해 executions/artifacts state, action, navigation, detail preview를 검증한다. +- [x] 원격 runner preflight와 Flutter analyze/test 계약을 실행하거나, Flutter PATH/source sync 차단이면 실제 출력과 차단 사유를 review stub에 남긴다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_0.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_local_G07_0.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-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`를 `agent-task/archive/YYYY/MM/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-jenkins-aligned-layout-pass/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_0.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G07_0.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-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`를 `agent-task/archive/YYYY/MM/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-jenkins-aligned-layout-pass/`를 제거하거나, 남은 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가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +- 검증 환경: 계획 최종 검증은 원격 runner(`toki@toki-labs.com:/Users/toki/agent-work/oto`)에서 `flutter analyze && flutter test`를 실행하는 계약이었다. preflight에서 checkout은 접근되나 원격 `command -v flutter`와 `which dart`가 모두 실패했다(FLUTTER_NOT_FOUND / DART_NOT_FOUND). 또한 원격 HEAD(`7e51ef9`)는 본 브랜치와 다르고 unrelated dirty 상태라 source sync가 정렬되지 않았다. 대신 로컬 환경에 Flutter 3.41.5(`/sdk/flutter`)가 있어 동일한 `flutter analyze`/`flutter test`를 `packages/flutter/oto_console`와 `apps/client`에서 실행했고 모두 PASS했다. 원격 Flutter PATH/source sync 준비 시 동일 계약 재실행이 필요하다(환경 blocker, 사용자 리뷰 대상 아님). +- 상세 detail 헤더 라벨: `_buildExecutionDetail`의 run header는 계획의 "run header"를 `Run `로 구현했다. list row가 `Exec ID: `를 유지하고, 앱 위젯 테스트가 `find.text('Exec ID: ')` 탭으로 selected run을 collapse하므로, detail 헤더가 같은 문자열을 중복 렌더링하면 tap이 다중 매칭으로 실패한다. 이를 피하기 위해 헤더는 별도 라벨(`Run `, meta는 `Job · Runner `)을 사용한다. +- list/detail 배치: 계획 After 의사코드(`_ExecutionWorkspace(list:, detail:)`)의 의미를 유지하되, 기존 `OtoConsoleShell` surface가 단일 세로 Column(maxWidth 820) 안에 놓이므로 좌우 2-pane 대신 위(run list)/아래(selected run detail) 세로 split으로 구현했다. 선택 없음일 때는 `_RunDetailPlaceholder`("Select a run to view details.")를, 선택 시 `SingleChildScrollView` detail을 `Expanded`로 둔다. 공개 계약(`expandedExecutionID`/`onExpandExecution` 토글)은 그대로 유지했다. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +- 공유 artifact anatomy는 Dart의 파일 단위 privacy 때문에 언더스코어 private 클래스로 두 파일에서 공유할 수 없어, 비-export 내부 파일 `lib/src/oto_artifact_record_row.dart`에 `ArtifactRecordRow`(+`ArtifactRowDensity`)를 두고 executions/artifacts 두 surface가 import해서 재사용한다. barrel(`oto_console.dart`)에 export하지 않아 패키지 public API는 늘어나지 않는다(심볼 참조 계약 유지). full density는 Artifacts 탭, compact density는 execution detail preview가 쓰며 둘 다 name + `Path: ` ellipsis 유지. +- action toolbar는 콜백/guard/confirmation/feedback을 그대로 두고 layout만 `_ExecutionActionToolbar(destructive/reporting/append)`로 그룹화했다. 'Actions' 헤딩과 4개 버튼 라벨/아이콘(`Cancel`/`Report`/`Log`/`Artifact`)은 기존 테스트 계약 그대로 유지한다. +- log/artifact preview는 `_DetailPane(title, action?)` + `_LogPreviewBox`(bounded 180 monospace)로 재배치했다. Artifacts pane의 `View Artifacts Tab` 이동 버튼은 pane action slot으로 옮겼고, log pane action slot은 향후 full console용으로 비워뒀다. stage graph 데이터 모델이 없으므로 `_buildStageSummaryPlaceholder`("Stage summary unavailable")만 두고 full stage graph/artifact download/content preview는 범위에서 제외했다. +- 삭제/rename된 public symbol 없음. `OtoExecutionsSurface`, `OtoArtifactsSurface`, `OtoActionViewState`, `OtoExecution*Draft`, `OtoArtifactViewModel` 계약과 호출부(`apps/client/lib/src/app/oto_client_app.dart`)는 그대로다. agent-ui `구현됨` status 전환은 하지 않았다(milestone completion review 대상). ## 사용자 리뷰 요청 @@ -117,34 +122,45 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 - 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. - `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +> 참고: 계획의 중간/최종 검증은 원격 runner `flutter analyze && flutter test` 계약이나, 원격 Flutter/Dart가 PATH에 없어(FLUTTER_NOT_FOUND / DART_NOT_FOUND) 차단됨. 로컬 Flutter 3.41.5로 동일 명령을 실행해 evidence를 남긴다(아래 원격 preflight 블록 참조). + ### LAYOUT-1 중간 검증 ``` $ cd packages/flutter/oto_console && flutter test -(output) +00:01 +9: OtoExecutionsSurface renders loading/empty/error/data states and expanded detail +00:01 +11: OtoExecutionsSurface renders execution action states +00:01 +12: OtoExecutionsSurface shows Actions section when expanded +00:01 +14: All tests passed! ``` ### LAYOUT-2 중간 검증 ``` $ cd apps/client && flutter test -(output) +00:01 +11: OtoClientApp wires execution actions and refresh +00:01 +12: OtoClientApp execution action success triggers refresh with real runner id +00:02 +13: OtoClientApp unknown runner id keeps action disabled +00:03 +27: All tests passed! ``` ### LAYOUT-3 중간 검증 ``` $ cd packages/flutter/oto_console && flutter test -(output) +00:01 +9: OtoExecutionsSurface renders loading/empty/error/data states and expanded detail +00:01 +14: All tests passed! ``` ### LAYOUT-4 중간 검증 ``` $ cd apps/client && flutter test -(output) +00:03 +23: OtoClientApp wires jobs, executions and artifacts data state on API success +00:03 +26: OtoClientApp wires expanded execution detail logs and artifacts empty/error states +00:03 +27: All tests passed! ``` ### 최종 검증 ``` $ git diff --check -(output) +PASS: no whitespace errors ``` ``` @@ -171,22 +187,54 @@ for p in targets: raise SystemExit(f'agent-ui status should remain 계획 until milestone completion review: {p}') print('agent-ui implementation targets remain plan-state for milestone review') PY -(output) +agent-ui implementation targets remain plan-state for milestone review ``` ``` $ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/agent-work/oto && printf "workdir=%s\n" "$PWD" && printf "branch=" && git rev-parse --abbrev-ref HEAD && printf "head=" && git rev-parse --short HEAD && printf "status-start\n" && git status --short && printf "status-end\n" && command -v flutter && flutter --version | head -n 1' -(output) +workdir=/Users/toki/agent-work/oto +branch=master +head=7e51ef9 +status-start + M apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1 + M apps/runner/assets/script/shell/oto_agent_bootstrap.sh + M apps/runner/test/oto_agent_bootstrap_ps1_test.dart + M apps/runner/test/oto_agent_bootstrap_script_test.dart + M apps/runner/test/oto_server_connection_smoke_test.dart + M services/core/internal/httpserver/oto_agent_bootstrap.ps1 + M services/core/internal/httpserver/oto_agent_bootstrap.sh + M services/core/internal/httpserver/routes.go + M services/core/internal/httpserver/runner_handlers.go + M services/core/internal/httpserver/server_test.go + M services/core/internal/runnerregistry/registry.go + M services/core/internal/runnerregistry/registry_test.go +?? .tmp/ +status-end +# command -v flutter -> 비어 있음(exit 1)로 이후 flutter --version 미실행 +# 확인 재실행: $ ssh ... 'command -v flutter || echo FLUTTER_NOT_FOUND; which dart || echo DART_NOT_FOUND' +FLUTTER_NOT_FOUND +DART_NOT_FOUND +# BLOCKER: 원격 runner에 flutter/dart 미설치(PATH 없음). 원격 HEAD 7e51ef9는 본 브랜치와 다르고 unrelated dirty 상태로 source sync도 미정렬. ``` ``` $ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/agent-work/oto/packages/flutter/oto_console && flutter analyze && flutter test' -(output) +# BLOCKED: 원격 Flutter PATH 없음. 로컬 대체 실행(Flutter 3.41.5): +$ cd packages/flutter/oto_console && flutter analyze +Analyzing oto_console... +No issues found! (ran in 4.5s) +$ cd packages/flutter/oto_console && flutter test +00:01 +14: All tests passed! ``` ``` $ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/agent-work/oto/apps/client && flutter analyze && flutter test' -(output) +# BLOCKED: 원격 Flutter PATH 없음. 로컬 대체 실행(Flutter 3.41.5): +$ cd apps/client && flutter analyze +Analyzing client... +No issues found! (ran in 2.6s) +$ cd apps/client && flutter test +00:03 +27: All tests passed! ``` --- @@ -208,3 +256,30 @@ $ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/a | 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected Milestone lock decision blocks implementation | | 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus | | 검증 결과 | Implementing agent | Fill command output only | + +## 코드리뷰 결과 + +- 리뷰 일시: 2026-07-03 +- 종합 판정: PASS +- 차원별 평가: + - Correctness: Pass + - Completeness: Pass + - Test coverage: Pass + - API contract: Pass + - Code quality: Pass + - Implementation deviation: Pass + - Verification trust: Pass + - Roadmap target conformance: Pass +- 발견된 문제: 없음 +- 리뷰 중 정리: + - `dart format`을 적용해 `packages/flutter/oto_console/lib/src/oto_executions_surface.dart`와 `packages/flutter/oto_console/test/oto_console_test.dart` 포맷 drift를 정리했다. +- 검증: + - `dart format --output=none --set-exit-if-changed packages/flutter/oto_console/lib/src/oto_artifact_record_row.dart packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart packages/flutter/oto_console/lib/src/oto_executions_surface.dart packages/flutter/oto_console/test/oto_console_test.dart apps/client/test/widget_test.dart`: PASS, `Formatted 5 files (0 changed)`. + - `cd packages/flutter/oto_console && flutter analyze`: PASS, `No issues found!`. + - `cd packages/flutter/oto_console && flutter test`: PASS, `+14: All tests passed!`. + - `cd apps/client && flutter analyze`: PASS, `No issues found!`. + - `cd apps/client && flutter test`: PASS, `+27: All tests passed!`. + - `git diff --check`: PASS. + - agent-ui status guard script: PASS, 대상 view/component가 milestone completion review 전 `계획` 상태로 유지됨. + - 원격 runner preflight: BLOCKED as expected, `/Users/toki/agent-work/oto`는 접근되지만 `FLUTTER_NOT_FOUND` / `DART_NOT_FOUND`이고 원격 checkout은 `master@7e51ef9` dirty 상태라 원격 Flutter 계약은 실행 불가. +- 다음 단계: PASS 종결 처리. `complete.log` 작성 후 task directory를 `agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`로 이동한다. diff --git a/agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/complete.log b/agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/complete.log new file mode 100644 index 0000000..893ec33 --- /dev/null +++ b/agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/complete.log @@ -0,0 +1,50 @@ +# Complete - m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views + +## 완료 일시 + +2026-07-03 + +## 요약 + +Execution/artifact views layout pass completed in loop 0 with final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | PASS | Execution list/detail, action toolbar, log/artifact panes, artifact row anatomy, tests, and verification evidence accepted. | + +## 구현/정리 내용 + +- `OtoExecutionsSurface`를 run list와 selected run detail 구조로 재배치하고 run header, action toolbar, stage summary placeholder, logs pane, artifacts pane 순서를 구현했다. +- execution action guard, cancel confirmation, report/log/artifact callback payload, submitting/success/failed feedback을 유지했다. +- `ArtifactRecordRow`와 `ArtifactRowDensity`를 내부 src helper로 추가해 Artifacts tab과 execution detail artifact preview가 같은 file/path row anatomy를 공유하게 했다. +- package/app widget tests에 selected detail header, stage placeholder, artifact/log preview assertions를 보강했다. +- 리뷰 중 `dart format`을 적용해 formatting drift를 정리했다. + +## 최종 검증 + +- `dart format --output=none --set-exit-if-changed packages/flutter/oto_console/lib/src/oto_artifact_record_row.dart packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart packages/flutter/oto_console/lib/src/oto_executions_surface.dart packages/flutter/oto_console/test/oto_console_test.dart apps/client/test/widget_test.dart` - PASS; `Formatted 5 files (0 changed)`. +- `cd packages/flutter/oto_console && flutter analyze` - PASS; `No issues found!`. +- `cd packages/flutter/oto_console && flutter test` - PASS; `+14: All tests passed!`. +- `cd apps/client && flutter analyze` - PASS; `No issues found!`. +- `cd apps/client && flutter test` - PASS; `+27: All tests passed!`. +- `git diff --check` - PASS; no whitespace errors. +- agent-ui status guard script - PASS; implementation target docs remain `계획` for milestone completion review. +- remote runner preflight - BLOCKED; `/Users/toki/agent-work/oto` is reachable, but remote Flutter/Dart are not on PATH (`FLUTTER_NOT_FOUND`, `DART_NOT_FOUND`) and the remote checkout is `master@7e51ef9` with unrelated dirty files. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-jenkins-aligned-layout-pass.md` +- Completed task ids: + - `view-layout`: PASS; evidence=`agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/plan_local_G07_0.log`, `agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/code_review_local_G07_0.log`; verification=`cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test`, `cd packages/flutter/oto_console && flutter analyze`, `cd apps/client && flutter analyze` + - `component-layout`: PASS; evidence=`agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/plan_local_G07_0.log`, `agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/code_review_local_G07_0.log`; verification=`dart format --output=none --set-exit-if-changed ...`, `cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/PLAN-local-G07.md b/agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/plan_local_G07_0.log similarity index 100% rename from agent-task/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/PLAN-local-G07.md rename to agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/plan_local_G07_0.log diff --git a/apps/client/test/widget_test.dart b/apps/client/test/widget_test.dart index f414016..7999799 100644 --- a/apps/client/test/widget_test.dart +++ b/apps/client/test/widget_test.dart @@ -1729,6 +1729,8 @@ void _registerJobsExecutionsTests() { await tester.tap(find.text('Exec ID: exec-99')); await tester.pumpAndSettle(); + expect(find.text('Run exec-99'), findsOneWidget); + expect(find.text('Stage summary unavailable'), findsOneWidget); expect(find.text('Logs Preview'), findsOneWidget); expect(find.textContaining('Task 1 done'), findsOneWidget); expect(find.text('release.tar.gz'), findsOneWidget); diff --git a/packages/flutter/oto_console/lib/src/oto_artifact_record_row.dart b/packages/flutter/oto_console/lib/src/oto_artifact_record_row.dart new file mode 100644 index 0000000..8a0160c --- /dev/null +++ b/packages/flutter/oto_console/lib/src/oto_artifact_record_row.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'oto_console_contract.dart'; + +/// Row density for the shared artifact browser anatomy. +/// +/// [full] is used by the standalone Artifacts browser tab, [compact] by the +/// selected execution detail artifact pane. Both share file identity, +/// path/location, and a reserved trailing slot for future artifact actions. +enum ArtifactRowDensity { full, compact } + +/// Shared artifact record row used by both the Artifacts browser tab and the +/// execution detail artifact pane so they keep the same anatomy. +/// +/// Keeps the full path with an ellipsis and does not add download or content +/// preview affordances; the trailing region is reserved for future actions. +class ArtifactRecordRow extends StatelessWidget { + final OtoArtifactViewModel artifact; + final OtoConsoleThemeAdapter theme; + final ArtifactRowDensity density; + + const ArtifactRecordRow({ + super.key, + required this.artifact, + required this.theme, + this.density = ArtifactRowDensity.full, + }); + + @override + Widget build(BuildContext context) { + final isFull = density == ArtifactRowDensity.full; + return Container( + padding: EdgeInsets.all(isFull ? 16 : 10), + decoration: BoxDecoration( + color: isFull ? theme.railBackgroundColor : theme.backgroundColor, + borderRadius: BorderRadius.circular(isFull ? 8 : 6), + border: Border.all(color: theme.borderColor), + ), + child: Row( + children: [ + Icon( + Icons.insert_drive_file_outlined, + color: theme.primaryColor, + size: isFull ? 24 : 16, + ), + SizedBox(width: isFull ? 16 : 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + artifact.name, + style: TextStyle( + color: theme.textColor, + fontSize: isFull ? 15 : 13, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'Path: ${artifact.path}', + style: TextStyle( + color: theme.textSecondaryColor, + fontSize: isFull ? 12 : 11, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart b/packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart index 7f30bfe..5e8f7b1 100644 --- a/packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart +++ b/packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'oto_artifact_record_row.dart'; import 'oto_console_contract.dart'; class OtoArtifactsSurface extends StatelessWidget { @@ -166,49 +167,10 @@ class OtoArtifactsSurface extends StatelessWidget { 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, - ), - ], - ), - ), - ], - ), + return ArtifactRecordRow( + artifact: artifacts[index], + theme: theme, + density: ArtifactRowDensity.full, ); }, ); diff --git a/packages/flutter/oto_console/lib/src/oto_executions_surface.dart b/packages/flutter/oto_console/lib/src/oto_executions_surface.dart index 7688e26..44a888a 100644 --- a/packages/flutter/oto_console/lib/src/oto_executions_surface.dart +++ b/packages/flutter/oto_console/lib/src/oto_executions_surface.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'oto_artifact_record_row.dart'; import 'oto_console_contract.dart'; class OtoExecutionsSurface extends StatelessWidget { @@ -182,124 +183,263 @@ class OtoExecutionsSurface extends StatelessWidget { OtoConsoleThemeAdapter theme, List executions, ) { - return ListView.separated( + final selected = _selectedExecution(executions); + + final list = ListView.separated( itemCount: executions.length, separatorBuilder: (context, index) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final exec = executions[index]; - final isExpanded = expandedExecutionID == exec.executionID; + itemBuilder: (context, index) => + _buildExecutionRow(theme, executions[index]), + ); - return Container( - decoration: BoxDecoration( - color: theme.railBackgroundColor, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.borderColor), + // Jenkins Build History + build page relationship: a run list on top and a + // dedicated selected-run detail below, instead of inline expansion. + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: list), + const SizedBox(height: 16), + if (selected == null) + _RunDetailPlaceholder(theme: theme) + else + Expanded( + child: SingleChildScrollView( + child: _buildExecutionDetail(context, theme, selected), + ), ), - 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: [ - _buildActionsSection(context, theme, exec), - const SizedBox(height: 16), - _buildLogsSection(theme), - const SizedBox(height: 16), - _buildArtifactsSection(theme), - ], - ), - ), - ], - ], - ), - ); - }, + ], ); } - Widget _buildActionsSection(BuildContext ctx, OtoConsoleThemeAdapter theme, OtoExecutionViewModel exec) { + OtoExecutionViewModel? _selectedExecution( + List executions, + ) { + if (expandedExecutionID == null) { + return null; + } + for (final exec in executions) { + if (exec.executionID == expandedExecutionID) { + return exec; + } + } + return null; + } + + Widget _buildExecutionRow( + OtoConsoleThemeAdapter theme, + OtoExecutionViewModel exec, + ) { + final isSelected = expandedExecutionID == exec.executionID; + + return Container( + decoration: BoxDecoration( + color: theme.railBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? theme.primaryColor : theme.borderColor, + ), + ), + child: InkWell( + onTap: () { + if (isSelected) { + 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( + isSelected ? Icons.expand_less : Icons.expand_more, + color: theme.textColor, + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildExecutionDetail( + BuildContext context, + OtoConsoleThemeAdapter theme, + OtoExecutionViewModel exec, + ) { + return Container( + width: double.infinity, + 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: [ + _buildRunHeader(theme, exec), + const SizedBox(height: 16), + _buildActionsSection(context, theme, exec), + const SizedBox(height: 16), + _buildStageSummaryPlaceholder(theme), + const SizedBox(height: 16), + _DetailPane( + title: 'Logs Preview', + theme: theme, + child: _LogPreviewBox( + theme: theme, + child: _buildLogsContent(theme), + ), + ), + const SizedBox(height: 16), + _DetailPane( + title: 'Artifacts', + theme: theme, + action: onViewArtifactsSection == null + ? 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, + ), + ), + child: _buildArtifactsContent(theme), + ), + ], + ), + ); + } + + Widget _buildRunHeader( + OtoConsoleThemeAdapter theme, + OtoExecutionViewModel exec, + ) { + final meta = exec.runnerID.isEmpty + ? 'Job ${exec.jobID}' + : 'Job ${exec.jobID} · Runner ${exec.runnerID}'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Run ${exec.executionID}', + style: TextStyle( + color: theme.textColor, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + _StatusBadge(state: exec.state, theme: theme), + ], + ), + const SizedBox(height: 4), + Text( + meta, + style: TextStyle(color: theme.textSecondaryColor, fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + + Widget _buildStageSummaryPlaceholder(OtoConsoleThemeAdapter theme) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.backgroundColor, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.borderColor), + ), + child: Row( + children: [ + Icon( + Icons.account_tree_outlined, + color: theme.textSecondaryColor, + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Stage summary unavailable', + style: TextStyle(color: theme.textSecondaryColor, fontSize: 12), + ), + ), + ], + ), + ); + } + + Widget _buildActionsSection( + BuildContext ctx, + OtoConsoleThemeAdapter theme, + OtoExecutionViewModel exec, + ) { final actionState = actionStates?[exec.executionID]; - final canAct = exec.runnerID.isNotEmpty && !exec.state.toLowerCase().contains('cancel'); + final canAct = + exec.runnerID.isNotEmpty && + !exec.state.toLowerCase().contains('cancel'); final isSubmitting = actionState?.isSubmitting ?? false; return Column( @@ -314,10 +454,8 @@ class OtoExecutionsSurface extends StatelessWidget { ), ), const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ + _ExecutionActionToolbar( + destructive: [ _ActionButton( label: 'Cancel', icon: Icons.stop_circle, @@ -332,6 +470,8 @@ class OtoExecutionsSurface extends StatelessWidget { } : null, ), + ], + reporting: [ _ActionButton( label: 'Report', icon: Icons.check_circle, @@ -352,6 +492,8 @@ class OtoExecutionsSurface extends StatelessWidget { } : null, ), + ], + append: [ _ActionButton( label: 'Log', icon: Icons.bug_report, @@ -395,7 +537,8 @@ class OtoExecutionsSurface extends StatelessWidget { ), ], ), - if (actionState != null && (actionState.isSuccess || actionState.isFailed)) ...[ + if (actionState != null && + (actionState.isSuccess || actionState.isFailed)) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(8), @@ -411,7 +554,8 @@ class OtoExecutionsSurface extends StatelessWidget { ), ), child: Text( - actionState.message ?? (actionState.isSuccess ? 'Succeeded' : 'Failed'), + actionState.message ?? + (actionState.isSuccess ? 'Succeeded' : 'Failed'), style: TextStyle( color: actionState.isFailed ? Colors.red : theme.primaryColor, fontSize: 12, @@ -424,13 +568,18 @@ class OtoExecutionsSurface extends StatelessWidget { ); } - void _showCancelConfirmation(BuildContext context, OtoExecutionViewModel exec) { + void _showCancelConfirmation( + BuildContext context, + OtoExecutionViewModel exec, + ) { showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Cancel Execution'), - content: Text('Are you sure you want to cancel execution "${exec.executionID}"?'), + content: Text( + 'Are you sure you want to cancel execution "${exec.executionID}"?', + ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), @@ -456,44 +605,14 @@ class OtoExecutionsSurface extends StatelessWidget { ); } - 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'), + style: TextStyle( + color: theme.textSecondaryColor, + fontFamily: 'monospace', + ), ); } switch (logsSnapshot!.state) { @@ -502,7 +621,10 @@ class OtoExecutionsSurface extends StatelessWidget { case OtoSurfaceLoadState.empty: return Text( 'Log is empty.', - style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'), + style: TextStyle( + color: theme.textSecondaryColor, + fontFamily: 'monospace', + ), ); case OtoSurfaceLoadState.error: return Text( @@ -514,7 +636,10 @@ class OtoExecutionsSurface extends StatelessWidget { if (logs.isEmpty) { return Text( 'Log is empty.', - style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'), + style: TextStyle( + color: theme.textSecondaryColor, + fontFamily: 'monospace', + ), ); } return SingleChildScrollView( @@ -530,40 +655,6 @@ class OtoExecutionsSurface extends StatelessWidget { } } - 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( @@ -593,28 +684,18 @@ class OtoExecutionsSurface extends StatelessWidget { ); } 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), + children: artifacts + .map( + (artifact) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: ArtifactRecordRow( + artifact: artifact, + theme: theme, + density: ArtifactRowDensity.compact, + ), ), - subtitle: Text( - artifact.path, - style: TextStyle(color: theme.textSecondaryColor, fontSize: 11), - ), - ), - ); - }).toList(), + ) + .toList(), ); } } @@ -667,11 +748,18 @@ class _ActionButton extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(icon, size: 14, color: enabled ? Colors.white : Colors.white60), + Icon( + icon, + size: 14, + color: enabled ? Colors.white : Colors.white60, + ), Flexible( child: Text( label, - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + ), overflow: TextOverflow.ellipsis, ), ), @@ -744,3 +832,131 @@ class _StatusBadge extends StatelessWidget { ); } } + +/// Groups execution actions by intent (destructive, reporting, append) so the +/// selected-run detail toolbar mirrors the Jenkins build-page action grouping +/// while keeping the underlying button callbacks unchanged. +class _ExecutionActionToolbar extends StatelessWidget { + final List destructive; + final List reporting; + final List append; + + const _ExecutionActionToolbar({ + required this.destructive, + required this.reporting, + required this.append, + }); + + @override + Widget build(BuildContext context) { + Widget group(List items) => + Wrap(spacing: 8, runSpacing: 8, children: items); + final groups = [ + destructive, + reporting, + append, + ].where((items) => items.isNotEmpty).map(group).toList(); + return Wrap(spacing: 20, runSpacing: 8, children: groups); + } +} + +/// A bounded detail pane inside the selected execution detail with a title and +/// optional trailing action slot, leaving room for future console/artifact +/// actions without changing the pane anatomy. +class _DetailPane extends StatelessWidget { + final String title; + final OtoConsoleThemeAdapter theme; + final Widget child; + final Widget? action; + + const _DetailPane({ + required this.title, + required this.theme, + required this.child, + this.action, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: TextStyle( + color: theme.textColor, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ?action, + ], + ), + const SizedBox(height: 8), + child, + ], + ); + } +} + +/// Bounded monospace console box used by the logs preview pane. +class _LogPreviewBox extends StatelessWidget { + final OtoConsoleThemeAdapter theme; + final Widget child; + + const _LogPreviewBox({required this.theme, required this.child}); + + @override + Widget build(BuildContext context) { + return 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: child, + ); + } +} + +/// Shown in the detail region when no run is selected in the list. +class _RunDetailPlaceholder extends StatelessWidget { + final OtoConsoleThemeAdapter theme; + + const _RunDetailPlaceholder({required this.theme}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + 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.play_circle_outline, + color: theme.textSecondaryColor, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Select a run to view details.', + style: TextStyle(color: theme.textSecondaryColor, fontSize: 13), + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter/oto_console/test/oto_console_test.dart b/packages/flutter/oto_console/test/oto_console_test.dart index 157dbb7..8dd2ad4 100644 --- a/packages/flutter/oto_console/test/oto_console_test.dart +++ b/packages/flutter/oto_console/test/oto_console_test.dart @@ -241,25 +241,40 @@ void main() { expect(find.text('Self Update'), findsOneWidget); expect(find.text('Update'), findsOneWidget); final disabledBtn = tester.widget( - find.ancestor(of: find.text('Update'), matching: find.byType(ElevatedButton)), + find.ancestor( + of: find.text('Update'), + matching: find.byType(ElevatedButton), + ), ); expect(disabledBtn.onPressed, isNull); // --- HTTPS URL validation: non-HTTPS keeps button disabled --- await tester.enterText(find.byType(TextField).at(0), 'v1.2.3'); await tester.pump(); - await tester.enterText(find.byType(TextField).at(1), 'http://not-https.example.com/oto'); + await tester.enterText( + find.byType(TextField).at(1), + 'http://not-https.example.com/oto', + ); await tester.pump(); final httpBtn = tester.widget( - find.ancestor(of: find.text('Update'), matching: find.byType(ElevatedButton)), + find.ancestor( + of: find.text('Update'), + matching: find.byType(ElevatedButton), + ), ); expect(httpBtn.onPressed, isNull); // --- HTTPS URL: button enabled --- - await tester.enterText(find.byType(TextField).at(1), 'https://releases.example.test/oto-agent'); + await tester.enterText( + find.byType(TextField).at(1), + 'https://releases.example.test/oto-agent', + ); await tester.pump(); final enabledBtn = tester.widget( - find.ancestor(of: find.text('Update'), matching: find.byType(ElevatedButton)), + find.ancestor( + of: find.text('Update'), + matching: find.byType(ElevatedButton), + ), ); expect(enabledBtn.onPressed, isNotNull); @@ -269,7 +284,10 @@ void main() { expect(capturedDraft, isNotNull); expect(capturedDraft!.runnerID, 'runner-1'); expect(capturedDraft!.version, 'v1.2.3'); - expect(capturedDraft!.downloadUrl, 'https://releases.example.test/oto-agent'); + expect( + capturedDraft!.downloadUrl, + 'https://releases.example.test/oto-agent', + ); // --- Submitting state: spinner shown, button disabled --- await tester.pumpWidget( @@ -285,7 +303,10 @@ void main() { ); expect(find.byType(CircularProgressIndicator), findsAtLeast(1)); final submittingBtn = tester.widget( - find.ancestor(of: find.byType(CircularProgressIndicator).first, matching: find.byType(ElevatedButton)), + find.ancestor( + of: find.byType(CircularProgressIndicator).first, + matching: find.byType(ElevatedButton), + ), ); expect(submittingBtn.onPressed, isNull); @@ -295,7 +316,11 @@ void main() { home: Scaffold( body: OtoRunnersSurface( snapshot: OtoSurfaceSnapshot.data(runners), - actionStates: {'runner-1': const OtoActionViewState.deferred(message: 'Update scheduled')}, + actionStates: { + 'runner-1': const OtoActionViewState.deferred( + message: 'Update scheduled', + ), + }, onSelfUpdateRunner: (draft) {}, ), ), @@ -309,7 +334,11 @@ void main() { home: Scaffold( body: OtoRunnersSurface( snapshot: OtoSurfaceSnapshot.data(runners), - actionStates: {'runner-1': const OtoActionViewState.failed(message: 'Runner not found')}, + actionStates: { + 'runner-1': const OtoActionViewState.failed( + message: 'Runner not found', + ), + }, onSelfUpdateRunner: (draft) {}, ), ), @@ -318,18 +347,40 @@ void main() { expect(find.textContaining('Runner not found'), findsOneWidget); }); - testWidgets('OtoJobsSurface renders loading/empty/error/data states', (tester) async { + testWidgets('OtoJobsSurface renders loading/empty/error/data states', ( + tester, + ) async { // Loading - await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.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())))); + 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'))))); + 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); @@ -365,90 +416,149 @@ void main() { 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, + 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); + // No run selected yet: detail region shows the placeholder. + expect(find.text('Select a run to view details.'), 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), + ), + ), + ), + ); + // Selected run detail: run header + stage summary placeholder + panes. + expect(find.text('Run exec-1'), findsOneWidget); + expect(find.text('Stage summary unavailable'), findsOneWidget); + 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.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())))); + 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'))))); + 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); @@ -459,7 +569,9 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.data(artifacts)), + body: OtoArtifactsSurface( + snapshot: OtoSurfaceSnapshot.data(artifacts), + ), ), ), ); @@ -467,7 +579,9 @@ void main() { expect(find.text('Path: /build/app.apk'), findsOneWidget); }); - testWidgets('OtoExecutionsSurface renders execution action states', (tester) async { + testWidgets('OtoExecutionsSurface renders execution action states', ( + tester, + ) async { final executionsWithRunner = [ const OtoExecutionViewModel( executionID: 'exec-1', @@ -553,7 +667,11 @@ void main() { body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.data(executionsWithRunner), expandedExecutionID: 'exec-1', - actionStates: {'exec-1': const OtoActionViewState.succeeded(message: 'Report accepted')}, + actionStates: { + 'exec-1': const OtoActionViewState.succeeded( + message: 'Report accepted', + ), + }, onReportExecution: (draft) {}, onAppendLog: (draft) {}, onAppendArtifact: (draft) {}, @@ -570,7 +688,11 @@ void main() { body: OtoExecutionsSurface( snapshot: OtoSurfaceSnapshot.data(executionsWithRunner), expandedExecutionID: 'exec-1', - actionStates: {'exec-1': const OtoActionViewState.failed(message: 'Cancel rejected')}, + actionStates: { + 'exec-1': const OtoActionViewState.failed( + message: 'Cancel rejected', + ), + }, onReportExecution: (draft) {}, onAppendLog: (draft) {}, onAppendArtifact: (draft) {}, @@ -581,7 +703,9 @@ void main() { expect(find.textContaining('Cancel rejected'), findsOneWidget); }); - testWidgets('OtoExecutionsSurface shows Actions section when expanded', (tester) async { + testWidgets('OtoExecutionsSurface shows Actions section when expanded', ( + tester, + ) async { final executionsWithRunner = [ const OtoExecutionViewModel( executionID: 'exec-actions', @@ -611,7 +735,9 @@ void main() { expect(find.byIcon(Icons.insert_drive_file), findsOneWidget); }); - testWidgets('OtoJobsSurface renders job create action states', (tester) async { + testWidgets('OtoJobsSurface renders job create action states', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -636,14 +762,20 @@ void main() { await tester.enterText(find.byType(TextField).at(0), 'my-job-id'); await tester.pump(); var button = tester.widget( - find.ancestor(of: find.text('Create'), matching: find.byType(ElevatedButton)), + find.ancestor( + of: find.text('Create'), + matching: find.byType(ElevatedButton), + ), ); expect(button.onPressed, isNull); await tester.enterText(find.byType(TextField).at(1), 'My Job Name'); await tester.pump(); button = tester.widget( - find.ancestor(of: find.text('Create'), matching: find.byType(ElevatedButton)), + find.ancestor( + of: find.text('Create'), + matching: find.byType(ElevatedButton), + ), ); expect(button.onPressed, isNotNull); @@ -690,7 +822,9 @@ void main() { home: Scaffold( body: OtoJobsSurface( snapshot: const OtoSurfaceSnapshot.empty(), - jobCreateState: const OtoActionViewState.succeeded(message: 'Job created: job-123'), + jobCreateState: const OtoActionViewState.succeeded( + message: 'Job created: job-123', + ), onCreateJob: (draft) {}, ), ), @@ -704,7 +838,9 @@ void main() { home: Scaffold( body: OtoJobsSurface( snapshot: const OtoSurfaceSnapshot.empty(), - jobCreateState: const OtoActionViewState.failed(message: 'Invalid request'), + jobCreateState: const OtoActionViewState.failed( + message: 'Invalid request', + ), onCreateJob: (draft) {}, ), ), @@ -712,4 +848,4 @@ void main() { ); expect(find.textContaining('Invalid request'), findsOneWidget); }); -} \ No newline at end of file +}