feat: logcat console milestone completion - archive subtasks, update console features

This commit is contained in:
toki 2026-06-14 06:49:05 +09:00
parent 2877616548
commit 8c0bb0a6b0
16 changed files with 1179 additions and 82 deletions

View file

@ -71,4 +71,8 @@
- 표준선(선택): MVP에서는 logcat viewer를 우선하고 interactive shell은 후속 후보로 둔다.
- 선행 작업: USB 설치와 device 선택
- 후속 작업: 설치 세션 이력과 오류 리포트
- 작업 현황:
- `01_logcat_process`: PASS 완료. archive: `agent-task/archive/2026/06/m-logcat-console/01_logcat_process/complete.log`. `Roadmap Completion`이 없어 기능 Task 체크는 no-op.
- `02+01_console_stream`: 활성 작업. Roadmap Targets: `stream`, `lifecycle`. 선행 `01_logcat_process` 완료 조건 충족.
- `03+02_auto_scroll`: 활성 작업. Roadmap Targets: `auto-scroll`. 선행 `02+01_console_stream` 완료 대기.
- 확인 필요: 없음

View file

@ -43,42 +43,44 @@ task=m-logcat-console/02+01_console_stream, plan=0, tag=LOGCAT_STREAM
| 항목 | 완료 여부 |
|------|---------|
| [LOGCAT_STREAM-1] Shell Device Selection | [ ] |
| [LOGCAT_STREAM-2] Live Stream Lifecycle | [ ] |
| [LOGCAT_STREAM-1] Shell Device Selection | [x] |
| [LOGCAT_STREAM-2] Live Stream Lifecycle | [x] |
## 구현 체크리스트
- [ ] `AppSokApp`에서 logcat session starter를 `AppSokShell`로 주입한다.
- [ ] `DevicesPage` logcat 버튼이 ready device를 console 선택 상태로 넘기고 콘솔 탭으로 이동하게 한다.
- [ ] `ConsolePage`가 선택 device의 logcat stream을 시작하고 화면에 append하며 dispose/device 변경 시 session을 정리한다.
- [ ] `flutter test test/widget_test.dart test/console_page_test.dart`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `AppSokApp`에서 logcat session starter를 `AppSokShell`로 주입한다.
- [x] `DevicesPage` logcat 버튼이 ready device를 console 선택 상태로 넘기고 콘솔 탭으로 이동하게 한다.
- [x] `ConsolePage`가 선택 device의 logcat stream을 시작하고 화면에 append하며 dispose/device 변경 시 session을 정리한다.
- [x] `flutter test test/widget_test.dart test/console_page_test.dart`를 실행한다.
- [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하는지 확인한다.
- [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-logcat-console/02+01_console_stream/`를 `agent-task/archive/YYYY/MM/m-logcat-console/02+01_console_stream/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-logcat-console/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- `test/widget_test.dart` 에 `Process`, `ProcessSignal`, `IOSink` 등의 타입을 mocking하기 위해 `dart:io` 패키지를 추가로 import했습니다.
- `AdbLogcatSessionStarter` typedef를 `adb_service.dart`에 선언하여 `AdbService` 도메인 내의 타입 참조 일관성을 유지했습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `ConsolePage`가 `device` 와 `logcatStarter` 가 전달되지 않고 호출된 경우(기존 widget test 및 preview 상태 등)에는 호환성(compatibility) 유지를 위해 기존 sample log (`_sampleLogLines`)를 fallback 렌더링하도록 `_displayLines` getter를 설계했습니다.
- `AppSokApp` 및 `AppSokShell` 에 `AdbLogcatSessionStarter?`를 dynamic dependency injection이 가능하게 필드를 추가하여, production에서는 `_adbService.startLogcat`을 주입하고 test/mocking 환경에서는 mock/fake starter를 주입할 수 있도록 testability를 극대화했습니다.
## 사용자 리뷰 요청
@ -94,7 +96,7 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 리뷰어를 위한 체크포인트
- `02+01_console_stream` 구현이 `01_logcat_process` complete.log 이후 시작됐는가.
- `02+01_console_stream` 구현이 `agent-task/archive/2026/06/m-logcat-console/01_logcat_process/complete.log` 이후 시작됐는가.
- ready device logcat 버튼만 console handoff를 수행하는가.
- 화면 이탈과 device 변경 모두 session stop을 보장하는가.
- Roadmap Targets의 `stream`, `lifecycle` completion 근거가 충분한가.
@ -113,22 +115,29 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### LOGCAT_STREAM-1 중간 검증
```bash
$ flutter test test/widget_test.dart
(output)
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
...
00:06 +31: All tests passed!
```
### LOGCAT_STREAM-2 중간 검증
```bash
$ flutter test test/console_page_test.dart test/widget_test.dart
(output)
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
...
00:07 +33: All tests passed!
```
### 최종 검증
```bash
$ flutter analyze
(output)
Analyzing appsok...
No issues found! (ran in 4.2s)
$ flutter test test/widget_test.dart test/console_page_test.dart
(output)
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
...
00:07 +33: All tests passed!
```
---
@ -136,3 +145,20 @@ $ flutter test test/widget_test.dart test/console_page_test.dart
> **[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: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- 발견된 문제:
- Required: `lib/src/features/console/console_page.dart:120` - 계획의 LOGCAT_STREAM-1 테스트/UX는 ready device logcat 진입 후 선택된 device serial 또는 display name이 console에 표시되어야 한다고 했지만, 현재 `ConsolePage`는 `widget.device`를 stream 시작에만 쓰고 화면에는 표시하지 않습니다. `test/widget_test.dart:1484`도 `콘솔` title만 확인해서 이 요구를 놓칩니다. 콘솔 상단에 선택 device label/serial을 표시하고, ready device logcat 버튼 테스트가 해당 label/serial을 검증하도록 고치세요.
- Required: `lib/src/features/console/console_page.dart:65` - `startLogcat`이 늦게 완료되는 동안 widget이 다른 device/starter로 갱신되면, 오래된 `session`이 `mounted` 상태만 통과하고 `_session`/`_subscription`에 붙습니다. 그러면 이전 device logcat이 현재 콘솔에 append되거나 새 session이 leak될 수 있어 `device 변경 시 process를 정리한다`는 lifecycle 요구를 만족하지 못합니다. session generation/token 또는 현재 serial/starter 대조를 추가해 stale session은 즉시 `stop()`하고, 실제 `didUpdateWidget` device 변경과 delayed starter 경계를 검증하는 widget test를 추가하세요.
- 다음 단계:
- WARN/FAIL 후속: `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성해 위 Required 문제만 좁게 수정한다.

View file

@ -1,6 +1,6 @@
<!-- task=m-logcat-console/03+02_auto_scroll plan=0 tag=LOGCAT_SCROLL -->
<!-- task=m-logcat-console/02+01_console_stream plan=1 tag=REVIEW_LOGCAT_STREAM -->
# Code Review Reference - LOGCAT_SCROLL
# Code Review Reference - REVIEW_LOGCAT_STREAM
> **[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,13 +14,14 @@
## 개요
date=2026-06-14
task=m-logcat-console/03+02_auto_scroll, plan=0, tag=LOGCAT_SCROLL
task=m-logcat-console/02+01_console_stream, plan=1, tag=REVIEW_LOGCAT_STREAM
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
- Task ids:
- `auto-scroll`: 새 로그가 들어올 때 자동 스크롤하되 pause 상태에서는 사용자의 위치를 유지한다.
- `stream`: 선택 device의 `adb logcat` stream을 시작하고 화면에 append한다.
- `lifecycle`: 화면 이탈 또는 device 변경 시 logcat process를 정리한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
@ -31,8 +32,8 @@ task=m-logcat-console/03+02_auto_scroll, plan=0, tag=LOGCAT_SCROLL
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-logcat-console/03+02_auto_scroll/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
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-logcat-console/02+01_console_stream/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
@ -42,41 +43,44 @@ task=m-logcat-console/03+02_auto_scroll, plan=0, tag=LOGCAT_SCROLL
| 항목 | 완료 여부 |
|------|---------|
| [LOGCAT_SCROLL-1] Pause-Aware Auto Scroll | [ ] |
| [REVIEW_LOGCAT_STREAM-1] Selected Device Identity | [x] |
| [REVIEW_LOGCAT_STREAM-2] Async Session Race | [x] |
## 구현 체크리스트
- [ ] `ConsolePage`에 `ScrollController`와 pause 상태를 추가하고 새 로그 append 후 unpaused 상태에서 bottom으로 이동한다.
- [ ] pause/resume 버튼이 auto-scroll 정책을 제어하고 pause 중 사용자 scroll offset을 유지한다.
- [ ] `test/console_page_test.dart`에서 unpaused auto-scroll과 paused offset 유지 동작을 검증한다.
- [ ] `flutter test test/console_page_test.dart`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] `ConsolePage`가 선택 device label/serial을 표시하고 logcat 진입 test가 이를 검증한다.
- [x] `ConsolePage` logcat start lifecycle에 stale async session guard를 추가한다.
- [x] delayed starter/device change widget test로 stale session stop과 현재 session 유지 동작을 검증한다.
- [x] `flutter test test/widget_test.dart test/console_page_test.dart`를 실행한다.
- [x] `flutter analyze`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_local_G05_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-logcat-console/03+02_auto_scroll/`를 `agent-task/archive/YYYY/MM/m-logcat-console/03+02_auto_scroll/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-logcat-console/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.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-logcat-console/02+01_console_stream/`를 `agent-task/archive/YYYY/MM/m-logcat-console/02+01_console_stream/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-logcat-console/`를 제거하거나, 남은 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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- `test/console_page_test.dart` 에 추가한 stale session async start 경계 테스트 중, stale session의 process가 이미 kill/close 되었기 때문에 stdout에 추가적인 이벤트를 emit하려 할 때 `StateError`가 발생하여, emitStdout 대신 `killCalled` 플래그가 true인지만 검증하도록 테스트 assertion 방식을 변경했습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- `ConsolePage`에서 selected device display name과 serial을 디바이스가 null일 때에도 자연스럽도록 "device 미선택"으로 처리하여 UX 일관성을 유지했습니다.
- `ConsolePage._startSession` 및 `_stopSession`에 `_sessionGeneration` 카운터 변수를 이용한 Generation Guard를 적용하여, 비동기 세션 시작 중 디바이스가 변경되거나 화면에서 이탈하는 경우에도 stale session이 leak되거나 현재 상태를 오염시키지 않고 즉각 `stop()` 되도록 하였습니다.
## 사용자 리뷰 요청
@ -92,10 +96,10 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 리뷰어를 위한 체크포인트
- `03+02_auto_scroll` 구현이 `02+01_console_stream` complete.log 이후 시작됐는가.
- unpaused 상태에서 새 로그가 bottom으로 이동하는가.
- paused 상태에서 사용자 scroll offset을 보존하는가.
- Roadmap Targets의 `auto-scroll` completion 근거가 충분한가.
- selected device display name 또는 serial이 console 화면에 표시되는가.
- stale async `startLogcat` 완료가 현재 device/session 상태를 덮어쓰지 않는가.
- delayed starter/device change test가 실제 `didUpdateWidget` 경로를 검증하는가.
- `stream`, `lifecycle` Roadmap Targets를 PASS로 닫을 만큼 검증 근거가 충분한가.
## 검증 결과
@ -108,19 +112,32 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### LOGCAT_SCROLL-1 중간 검증
### REVIEW_LOGCAT_STREAM-1 중간 검증
```bash
$ flutter test test/widget_test.dart
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
...
00:06 +33: All tests passed!
```
### REVIEW_LOGCAT_STREAM-2 중간 검증
```bash
$ flutter test test/console_page_test.dart
(output)
00:00 +0: loading /config/workspace/appsok/test/console_page_test.dart
...
00:03 +3: All tests passed!
```
### 최종 검증
```bash
$ flutter analyze
(output)
Analyzing appsok...
No issues found! (ran in 6.0s)
$ flutter test test/console_page_test.dart
(output)
$ flutter test test/widget_test.dart test/console_page_test.dart
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
...
00:06 +34: All tests passed!
```
---
@ -128,3 +145,18 @@ $ flutter test test/console_page_test.dart
> **[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 디렉터리를 archive로 이동한다.

View file

@ -0,0 +1,46 @@
# Complete - m-logcat-console/02+01_console_stream
## 완료 일시
2026-06-14
## 요약
`02+01_console_stream` logcat console stream/lifecycle 작업을 2회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | selected device 식별자 표시 누락과 stale async session guard 누락으로 follow-up 필요 |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | selected device label/serial 표시, generation guard, delayed starter/device change test로 Required 2건 해소 |
## 구현/정리 내용
- `ConsolePage`가 선택된 device display name과 serial을 표시하고, device 미선택 상태도 명확히 보여준다.
- `ConsolePage` logcat start lifecycle에 `_sessionGeneration` guard를 추가해 delayed stale session이 현재 상태를 덮어쓰지 못하게 했다.
- ready device console handoff, live stream append/dispose, delayed starter device change 경계 테스트를 추가했다.
- 리뷰 중 `dart format`으로 변경 파일 포맷을 정리했다.
## 최종 검증
- `flutter analyze` - PASS; local checkout에서 `No issues found! (ran in 5.6s)`.
- `flutter test test/widget_test.dart test/console_page_test.dart` - PASS; local checkout에서 `All tests passed!` (`+34`).
- `git diff --check` - PASS; 출력 없음.
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && flutter analyze && flutter test"'` - PASS; remote Mac runner에서 `No issues found!` 및 전체 `flutter test` `All tests passed!` (`+206`).
## Roadmap Completion
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
- Completed task ids:
- `stream`: PASS; evidence=`plan_local_G07_1.log`, `code_review_local_G07_1.log`; verification=`flutter test test/widget_test.dart test/console_page_test.dart`, remote `flutter test`
- `lifecycle`: PASS; evidence=`plan_local_G07_1.log`, `code_review_local_G07_1.log`; verification=`flutter test test/console_page_test.dart`, remote `flutter test`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -69,7 +69,7 @@ Console 화면은 현재 샘플 로그만 렌더링하고 실제 device 선택
- split decision policy를 먼저 평가했다.
- 공유 task group: `m-logcat-console`
- 이 subtask는 `02+01_console_stream`이며 predecessor `01`은 `agent-task/m-logcat-console/01_logcat_process/complete.log` 또는 archive matching `complete.log`가 필요하다.
- 현재 확인 결과 predecessor complete.log는 없음. 구현 시작 전 `01_logcat_process` PASS 완료가 필요하다.
- 현재 확인 결과 predecessor complete.log는 `agent-task/archive/2026/06/m-logcat-console/01_logcat_process/complete.log`에 있다. `01_logcat_process` PASS 완료 조건은 충족됐다.
- `03+02_auto_scroll`은 이 plan의 live console UI에 의존한다.
### 범위 결정 근거
@ -93,7 +93,7 @@ Console 화면은 현재 샘플 로그만 렌더링하고 실제 device 선택
## 의존 관계 및 구현 순서
- Directory dependency: `02+01_console_stream` -> `01_logcat_process`
- 구현 전 `agent-task/m-logcat-console/01_logcat_process/complete.log` 또는 archive `agent-task/archive/*/*/m-logcat-console/01_logcat_process/complete.log`가 있어야 한다. 현재는 없음.
- 구현 전 `agent-task/m-logcat-console/01_logcat_process/complete.log` 또는 archive `agent-task/archive/*/*/m-logcat-console/01_logcat_process/complete.log`가 있어야 한다. 현재 충족: `agent-task/archive/2026/06/m-logcat-console/01_logcat_process/complete.log`.
## LOGCAT_STREAM-1 Shell Device Selection

View file

@ -0,0 +1,96 @@
<!-- task=m-logcat-console/02+01_console_stream plan=1 tag=REVIEW_LOGCAT_STREAM -->
# REVIEW_LOGCAT_STREAM 후속 계획
## 이 파일을 읽는 구현 에이전트에게
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것은 필수입니다. 구현 후 검증을 실행하고 실제 내용과 출력을 기록한 뒤 active 파일을 그대로 두고 리뷰 준비를 보고하세요. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용입니다. 구현이 사용자 결정, 사용자 소유 외부 환경, 범위 충돌로 막히면 review stub의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 중단하세요. 직접 질문, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 작성, archive, `complete.log` 작성은 금지입니다. 후속 에이전트가 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아닙니다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
- Task ids:
- `stream`: 선택 device의 `adb logcat` stream을 시작하고 화면에 append한다.
- `lifecycle`: 화면 이탈 또는 device 변경 시 logcat process를 정리한다.
- Completion mode: check-on-pass
## 리뷰 요약
`code_review_local_G06_0.log` 판정은 FAIL입니다. 선택된 device 식별자 표시가 빠졌고, `startLogcat`이 늦게 완료되는 경계에서 stale session이 현재 콘솔에 붙을 수 있습니다.
## 범위 결정 근거
- `filter`, `auto-scroll`, export/clipboard, clear/pause 실제 동작은 이번 후속 범위에서 제외한다.
- 로드맵 문서는 수정하지 않는다. PASS completion event 이후 런타임이 처리한다.
- 실패 원인이 명확하고 widget test로 재현 가능하므로 follow-up route는 `local-G07`로 둔다.
## 구현 체크리스트
- [ ] `ConsolePage`가 선택 device label/serial을 표시하고 logcat 진입 test가 이를 검증한다.
- [ ] `ConsolePage` logcat start lifecycle에 stale async session guard를 추가한다.
- [ ] delayed starter/device change widget test로 stale session stop과 현재 session 유지 동작을 검증한다.
- [ ] `flutter test test/widget_test.dart test/console_page_test.dart`를 실행한다.
- [ ] `flutter analyze`를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## REVIEW_LOGCAT_STREAM-1 Selected Device Identity
### 문제
`ConsolePage`는 선택된 `AdbDevice`를 stream 시작에만 사용하고 화면에는 표시하지 않습니다. 이전 계획의 LOGCAT_STREAM-1 테스트 기대값은 ready device logcat 진입 후 선택 device serial 또는 display name이 console에 보이는 것이었지만, 현재 test는 `콘솔` title만 확인합니다.
### 해결 방법
콘솔 상단 controls 근처에 선택 device display name과 serial을 표시합니다. `device == null`인 sample/preview 상태에서는 기존 sample console UI가 어색하지 않도록 표시를 생략하거나 "device 미선택" 상태를 명확히 둡니다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/console/console_page.dart`: `widget.device` 기반 selected device label/serial UI 추가.
- [ ] `test/widget_test.dart`: `ready device logcat button opens console for that device`가 console 이동 후 device label 또는 serial을 검증하도록 보강.
### 테스트 작성
- 기존 `ready device logcat button opens console for that device` test를 보강한다.
- assertion: logcat 버튼 tap 후 `device-ready-logcat` 또는 `Pixel 8`이 console 화면에 표시된다.
### 중간 검증
```bash
flutter test test/widget_test.dart
```
## REVIEW_LOGCAT_STREAM-2 Async Session Race
### 문제
`ConsolePage._startSession`은 `await starter(serial: device.serial)` 이후 `mounted`만 확인하고 `_session`과 `_subscription`을 갱신합니다. 그 사이 widget이 다른 device/starter로 갱신되면 오래된 session이 현재 화면에 붙거나 새 session이 leak될 수 있습니다.
### 해결 방법
session generation/token 또는 capture한 serial/starter 대조를 추가합니다. `await starter` 이후 현재 widget 상태와 다르면 반환된 stale session을 즉시 `stop()`하고 화면 상태를 변경하지 않습니다. catch path도 현재 generation일 때만 error/loading 상태를 반영합니다. `_stopSession`은 기존 session/subscription을 정리하면서 pending start를 무효화해야 합니다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/console/console_page.dart`: stale async session guard 추가.
- [ ] `test/console_page_test.dart`: delayed starter로 device 변경 중 첫 session이 늦게 반환되는 케이스 추가.
### 테스트 작성
- `ConsolePage`를 device A로 렌더링하고 starter completer A를 보류한다.
- 같은 widget tree에서 device B로 갱신한 뒤 starter completer B를 완료한다.
- 이후 completer A를 완료했을 때 A session은 `stop()`되고, B session line만 화면에 append되는지 확인한다.
### 중간 검증
```bash
flutter test test/console_page_test.dart
```
## 최종 검증
```bash
flutter analyze
flutter test test/widget_test.dart test/console_page_test.dart
```
검증 출력은 `CODE_REVIEW-local-G07.md`의 `검증 결과`에 실제 stdout/stderr로 기록한다.

View file

@ -0,0 +1,167 @@
<!-- task=m-logcat-console/03+02_auto_scroll plan=0 tag=LOGCAT_SCROLL -->
# Code Review Reference - LOGCAT_SCROLL
> **[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-14
task=m-logcat-console/03+02_auto_scroll, plan=0, tag=LOGCAT_SCROLL
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
- Task ids:
- `auto-scroll`: 새 로그가 들어올 때 자동 스크롤하되 pause 상태에서는 사용자의 위치를 유지한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-logcat-console/03+02_auto_scroll/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [LOGCAT_SCROLL-1] Pause-Aware Auto Scroll | [x] |
## 구현 체크리스트
- [x] `ConsolePage`에 `ScrollController`와 pause 상태를 추가하고 새 로그 append 후 unpaused 상태에서 bottom으로 이동한다.
- [x] pause/resume 버튼이 auto-scroll 정책을 제어하고 pause 중 사용자 scroll offset을 유지한다.
- [x] `test/console_page_test.dart`에서 unpaused auto-scroll과 paused offset 유지 동작을 검증한다.
- [x] `flutter test test/console_page_test.dart`를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-logcat-console/03+02_auto_scroll/`를 `agent-task/archive/YYYY/MM/m-logcat-console/03+02_auto_scroll/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-logcat-console/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
- **ListView Lazy Layout 이슈 처리**: pause 중에 10줄의 로그가 더 쌓이고 사용자가 중간(offset 100.0)에 머물렀을 경우, ListView의 Lazy Layout 특성상 화면 밖에 있는 아이템들은 레이아웃되지 않아 `maxScrollExtent`가 즉시 늘어나지 않습니다. 이 상태에서 resume 버튼을 눌러 한 번만 `jumpTo`를 하면 바닥에 완전히 닿지 않고 이전 최대 지점(762.0)에서 멈추는 문제가 발생했습니다. 이를 보완하기 위해 `_scrollToBottom` 함수 내에서 현재 프레임의 `jumpTo` 이후에 `WidgetsBinding.instance.addPostFrameCallback`을 사용해 다음 프레임에서 한 번 더 `jumpTo`를 수행하여 최종 바닥에 정상 안착할 수 있도록 안전장치를 추가했습니다.
## 주요 설계 결정
- **상태의 리셋**: 디바이스 변경이나 세션의 didUpdateWidget이 실행될 때 `_isPaused = false;`로 설정하여, 다른 디바이스로 변경했을 때 이전 일시정지 상태가 유지되어 새로운 로그를 놓치는 현상을 방지했습니다.
- **적응형 UI**: `_isPaused` 상태에 따라 툴팁은 '일시정지' / '재개', 아이콘은 `Icons.pause` / `Icons.play_arrow`로 갱신하여 직관적인 조작이 가능하게 했습니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `03+02_auto_scroll` 구현이 `02+01_console_stream` complete.log 이후 시작됐는가.
- unpaused 상태에서 새 로그가 bottom으로 이동하는가.
- paused 상태에서 사용자 scroll offset을 보존하는가.
- Roadmap Targets의 `auto-scroll` completion 근거가 충분한가.
## 검증 결과
### LOGCAT_SCROLL-1 중간 검증
```bash
$ flutter test test/console_page_test.dart
00:00 +0: loading /config/workspace/appsok/test/console_page_test.dart
00:01 +0: colors sample logcat rows by parsed level
00:01 +1: colors sample logcat rows by parsed level
00:01 +1: appends live stream lines and stops session on dispose
00:02 +1: appends live stream lines and stops session on dispose
00:02 +2: appends live stream lines and stops session on dispose
00:02 +2: discards stale session when device changes during async start
00:02 +3: discards stale session when device changes during async start
00:02 +3: auto-scrolls to the newest log while running
00:02 +4: auto-scrolls to the newest log while running
00:02 +4: keeps user scroll position while paused
00:02 +5: keeps user scroll position while paused
00:02 +5: All tests passed!
```
### 최종 검증
```bash
$ flutter analyze
Analyzing appsok...
No issues found! (ran in 5.3s)
$ flutter test test/console_page_test.dart
00:00 +0: loading /config/workspace/appsok/test/console_page_test.dart
00:01 +0: colors sample logcat rows by parsed level
00:01 +1: colors sample logcat rows by parsed level
00:01 +1: appends live stream lines and stops session on dispose
00:02 +1: appends live stream lines and stops session on dispose
00:02 +2: appends live stream lines and stops session on dispose
00:02 +2: discards stale session when device changes during async start
00:02 +3: discards stale session when device changes during async start
00:02 +3: auto-scrolls to the newest log while running
00:02 +4: auto-scrolls to the newest log while running
00:02 +4: keeps user scroll position while paused
00:02 +5: keeps user scroll position while paused
00:02 +5: 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.
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 리뷰 중 정리:
- `dart format`이 요구한 줄바꿈만 원격 Mac runner에서 적용했다. 동작/API 변경은 없다.
- 검증 확인:
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && dart format lib/src/features/console/console_page.dart test/console_page_test.dart && dart format --output=none --set-exit-if-changed lib/src/features/console/console_page.dart test/console_page_test.dart'\'''` - PASS; 2개 파일 포맷 적용 후 재검사 0 changed.
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && git diff --check -- lib/src/features/console/console_page.dart test/console_page_test.dart'\'''` - PASS; 출력 없음.
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze && flutter test'\'''` - PASS; `No issues found!`, `All tests passed!` (`+208`).
- 다음 단계: PASS이므로 active plan/review를 `.log`로 아카이브하고 `complete.log` 작성 후 task directory를 `agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/`로 이동한다.

View file

@ -0,0 +1,43 @@
# Complete - m-logcat-console/03+02_auto_scroll
## 완료 일시
2026-06-14
## 요약
`03+02_auto_scroll` pause-aware logcat auto-scroll 작업을 1회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | unpaused append 시 bottom scroll, pause 중 offset 유지, resume 시 bottom 복귀 구현과 테스트 확인 |
## 구현/정리 내용
- `ConsolePage`에 `ScrollController`와 `_isPaused` 상태를 추가해 unpaused live append 후 bottom으로 이동하도록 했다.
- pause/resume 버튼이 tooltip/icon 상태를 바꾸고, pause 중에는 사용자의 scroll offset을 유지하도록 했다.
- `test/console_page_test.dart`에 unpaused auto-scroll 및 paused offset 유지 widget test를 추가했다.
- 리뷰 중 원격 Mac runner에서 `dart format`으로 변경 파일의 줄바꿈만 정리했다.
## 최종 검증
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && dart format lib/src/features/console/console_page.dart test/console_page_test.dart && dart format --output=none --set-exit-if-changed lib/src/features/console/console_page.dart test/console_page_test.dart'\'''` - PASS; 2개 파일 포맷 적용 후 재검사 0 changed.
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && git diff --check -- lib/src/features/console/console_page.dart test/console_page_test.dart'\'''` - PASS; 출력 없음.
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze && flutter test'\'''` - PASS; `No issues found!`, `All tests passed!` (`+208`).
## Roadmap Completion
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
- Completed task ids:
- `auto-scroll`: PASS; evidence=`plan_local_G05_0.log`, `code_review_local_G05_0.log`; verification=`flutter analyze`, `flutter test`, `dart format --output=none --set-exit-if-changed lib/src/features/console/console_page.dart test/console_page_test.dart`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -21,6 +21,7 @@ class AppSokApp extends StatefulWidget {
this.adbDiagnosticsLoader,
this.webLoginLauncher,
this.onWebLoginCompleted,
this.adbLogcatStarter,
});
final TokenStore? tokenStore;
@ -32,6 +33,7 @@ class AppSokApp extends StatefulWidget {
final AdbDiagnosticsLoader? adbDiagnosticsLoader;
final JenkinsWebLoginLauncher? webLoginLauncher;
final void Function(JenkinsWebLoginResult result)? onWebLoginCompleted;
final AdbLogcatSessionStarter? adbLogcatStarter;
@override
State<AppSokApp> createState() => _AppSokAppState();
@ -156,6 +158,7 @@ class _AppSokAppState extends State<AppSokApp> {
),
adbDiagnosticsLoader:
widget.adbDiagnosticsLoader ?? _adbService.collectDiagnostics,
logcatStarter: widget.adbLogcatStarter ?? _adbService.startLogcat,
webLoginLauncher: widget.webLoginLauncher,
onWebLoginCompleted: widget.onWebLoginCompleted,
onSessionSaved: _onSessionSaved,

View file

@ -41,6 +41,7 @@ class AppSokShell extends StatefulWidget {
this.sessionClearer,
this.connectionStatus,
this.onRetryConnection,
this.logcatStarter,
});
final JenkinsJobLoader? jobLoader;
@ -58,6 +59,7 @@ class AppSokShell extends StatefulWidget {
final JenkinsSessionClearer? sessionClearer;
final JenkinsConnectionStatus? connectionStatus;
final VoidCallback? onRetryConnection;
final AdbLogcatSessionStarter? logcatStarter;
@override
State<AppSokShell> createState() => _AppSokShellState();
@ -66,6 +68,14 @@ class AppSokShell extends StatefulWidget {
class _AppSokShellState extends State<AppSokShell> {
int _selectedIndex = 0;
PendingInstall? _pendingInstall;
AdbDevice? _selectedConsoleDevice;
void _handleLogcatRequested(AdbDevice device) {
setState(() {
_selectedConsoleDevice = device;
_selectedIndex = 2;
});
}
// Install lifecycle state owned by shell so cleanup survives DevicesPage dispose.
bool _isInstalling = false;
@ -196,13 +206,17 @@ class _AppSokShellState extends State<AppSokShell> {
isInstalling: _isInstalling,
lastInstallDevice: _lastInstallDevice,
lastInstallResult: _lastInstallResult,
onLogcatRequested: _handleLogcatRequested,
),
),
const _ShellPage(
_ShellPage(
label: '콘솔',
icon: Icons.terminal_outlined,
selectedIcon: Icons.terminal,
child: ConsolePage(),
child: ConsolePage(
device: _selectedConsoleDevice,
logcatStarter: widget.logcatStarter,
),
),
_ShellPage(
label: '설정',

View file

@ -1,7 +1,140 @@
import 'dart:async';
import 'package:flutter/material.dart';
class ConsolePage extends StatelessWidget {
const ConsolePage({super.key});
import '../../models/adb_device.dart';
import '../../services/adb_service.dart';
class ConsolePage extends StatefulWidget {
const ConsolePage({super.key, this.device, this.logcatStarter});
final AdbDevice? device;
final AdbLogcatSessionStarter? logcatStarter;
@override
State<ConsolePage> createState() => _ConsolePageState();
}
class _ConsolePageState extends State<ConsolePage> {
final List<String> _logLines = [];
AdbLogcatSession? _session;
StreamSubscription<String>? _subscription;
bool _isLoading = false;
String? _errorMsg;
int _sessionGeneration = 0;
final ScrollController _scrollController = ScrollController();
bool _isPaused = false;
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients && !_isPaused) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
});
}
}
@override
void initState() {
super.initState();
_startSession();
}
@override
void didUpdateWidget(ConsolePage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.device != oldWidget.device ||
widget.logcatStarter != oldWidget.logcatStarter) {
_stopSession();
_logLines.clear();
_isPaused = false;
_startSession();
}
}
@override
void dispose() {
_scrollController.dispose();
_stopSession();
super.dispose();
}
Future<void> _startSession() async {
final starter = widget.logcatStarter;
final device = widget.device;
if (starter == null || device == null) {
return;
}
_sessionGeneration++;
final currentGen = _sessionGeneration;
setState(() {
_isLoading = true;
_errorMsg = null;
});
try {
final session = await starter(serial: device.serial);
if (currentGen != _sessionGeneration) {
await session.stop();
return;
}
if (!mounted) {
await session.stop();
return;
}
_session = session;
_subscription = session.lines.listen(
(line) {
if (!mounted || currentGen != _sessionGeneration) return;
setState(() {
_logLines.add(line);
if (_logLines.length > 1000) {
_logLines.removeRange(0, _logLines.length - 1000);
}
});
if (!_isPaused) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && currentGen == _sessionGeneration) {
_scrollToBottom();
}
});
}
},
onError: (err) {
if (!mounted || currentGen != _sessionGeneration) return;
setState(() {
_errorMsg = err.toString();
});
},
);
setState(() {
_isLoading = false;
});
} catch (e) {
if (!mounted || currentGen != _sessionGeneration) return;
setState(() {
_isLoading = false;
_errorMsg = e.toString();
});
}
}
void _stopSession() {
_sessionGeneration++;
_subscription?.cancel();
_subscription = null;
_session?.stop();
_session = null;
}
List<String> get _displayLines =>
(widget.device == null || widget.logcatStarter == null)
? _sampleLogLines
: _logLines;
@override
Widget build(BuildContext context) {
@ -12,6 +145,34 @@ class ConsolePage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.device != null) ...[
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
key: const ValueKey('console-selected-device-label'),
children: [
const Icon(Icons.phone_android, size: 18),
const SizedBox(width: 8),
Text(
'${widget.device!.displayName} (${widget.device!.serial})',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
] else ...[
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Row(
key: ValueKey('console-no-device-label'),
children: [
Icon(Icons.phone_android, size: 18, color: Colors.grey),
SizedBox(width: 8),
Text('device 미선택', style: TextStyle(color: Colors.grey)),
],
),
),
],
Wrap(
spacing: 12,
runSpacing: 8,
@ -44,9 +205,18 @@ class ConsolePage extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: '일시정지',
onPressed: () {},
icon: const Icon(Icons.pause),
tooltip: _isPaused ? '재개' : '일시정지',
onPressed: () {
setState(() {
_isPaused = !_isPaused;
if (!_isPaused) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _scrollToBottom(),
);
}
});
},
icon: Icon(_isPaused ? Icons.play_arrow : Icons.pause),
),
const SizedBox(width: 8),
IconButton.outlined(
@ -66,26 +236,36 @@ class ConsolePage extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.outlineVariant),
),
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _logLines.length,
itemBuilder: (context, index) {
final line = _logLines[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Text(
line,
style: TextStyle(
color: _lineColor(line),
fontFamily: 'Menlo',
fontSize: 13,
height: 1.4,
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMsg != null
? Center(
child: Text(
'에러 발생: $_errorMsg',
style: TextStyle(color: colorScheme.error),
),
)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _displayLines.length,
itemBuilder: (context, index) {
final line = _displayLines[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Text(
line,
style: TextStyle(
color: _lineColor(line),
fontFamily: 'Menlo',
fontSize: 13,
height: 1.4,
),
),
);
},
),
);
},
),
),
),
],
@ -123,7 +303,7 @@ enum _LogcatLevel {
}
}
const _logLines = [
const _sampleLogLines = [
'06-08 10:41:22.121 1832 2110 I ActivityTaskManager: START u0 com.toki.app/.MainActivity',
'06-08 10:41:22.339 5521 5521 D AppSokInstall: install session committed',
'06-08 10:41:22.921 5521 5540 W NetworkMonitor: vpn route not available, using usb install cache',

View file

@ -20,6 +20,7 @@ class DevicesPage extends StatefulWidget {
this.isInstalling = false,
this.lastInstallDevice,
this.lastInstallResult,
this.onLogcatRequested,
});
final PendingInstall? pendingInstall;
@ -29,6 +30,7 @@ class DevicesPage extends StatefulWidget {
final bool isInstalling;
final AdbDevice? lastInstallDevice;
final AdbInstallResult? lastInstallResult;
final ValueChanged<AdbDevice>? onLogcatRequested;
@override
State<DevicesPage> createState() => _DevicesPageState();
@ -199,6 +201,7 @@ class _DevicesPageState extends State<DevicesPage> {
pendingInstall: widget.pendingInstall,
isInstalling: widget.isInstalling,
installer: widget.installer,
onLogcatRequested: widget.onLogcatRequested,
);
},
),
@ -286,12 +289,14 @@ class _DeviceTile extends StatelessWidget {
this.pendingInstall,
required this.isInstalling,
this.installer,
this.onLogcatRequested,
});
final AdbDevice device;
final PendingInstall? pendingInstall;
final bool isInstalling;
final PendingInstallRunner? installer;
final ValueChanged<AdbDevice>? onLogcatRequested;
@override
Widget build(BuildContext context) {
@ -368,7 +373,9 @@ class _DeviceTile extends StatelessWidget {
_CompactIconButton(
tooltip: 'logcat',
icon: Icons.terminal,
onPressed: () {},
onPressed: device.isReady && onLogcatRequested != null
? () => onLogcatRequested!(device)
: null,
),
const SizedBox(width: 4),
_CompactIconButton(

View file

@ -20,6 +20,11 @@ typedef AdbProcessStarter =
Map<String, String>? environment,
});
typedef AdbLogcatSessionStarter =
Future<AdbLogcatSession> Function({
String? serial,
});
class AdbService {
AdbService({
String? adbPath,

View file

@ -1,9 +1,50 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appsok/src/features/console/console_page.dart';
import 'package:appsok/src/models/adb_device.dart';
import 'package:appsok/src/services/adb_service.dart';
import 'package:appsok/src/theme/app_theme.dart';
class _FakeProcess implements Process {
final _stdout = StreamController<List<int>>();
final _stderr = StreamController<List<int>>();
final _exitCode = Completer<int>();
bool killCalled = false;
void emitStdout(String text) => _stdout.add(utf8.encode(text));
void emitStderr(String text) => _stderr.add(utf8.encode(text));
@override
Stream<List<int>> get stdout => _stdout.stream;
@override
Stream<List<int>> get stderr => _stderr.stream;
@override
Future<int> get exitCode => _exitCode.future;
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
killCalled = true;
if (!_exitCode.isCompleted) _exitCode.complete(-15);
_stdout.close();
_stderr.close();
return true;
}
@override
int get pid => 4242;
@override
IOSink get stdin => throw UnimplementedError();
}
void main() {
testWidgets('colors sample logcat rows by parsed level', (tester) async {
await tester.pumpWidget(
@ -27,6 +68,250 @@ void main() {
expect(_textColor(tester, warnLine), const Color(0xFFFFD28A));
expect(_textColor(tester, errorLine), const Color(0xFFFFA3A3));
});
testWidgets('appends live stream lines and stops session on dispose', (
tester,
) async {
final fakeProcess = _FakeProcess();
final session = AdbLogcatSession(fakeProcess);
const device = AdbDevice(
serial: 'R5CT90A1B2C',
state: 'device',
model: 'SM_S918N',
product: 'dm3q',
);
var startCalls = 0;
Future<AdbLogcatSession> fakeStarter({String? serial}) async {
startCalls++;
expect(serial, device.serial);
return session;
}
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(device: device, logcatStarter: fakeStarter),
),
),
);
// Initial pump to run initState/startSession
await tester.pump();
expect(startCalls, 1);
// Emit live log lines
fakeProcess.emitStdout('I/test: hello live line\n');
await tester.pump(Duration.zero);
expect(find.text('I/test: hello live line'), findsOneWidget);
// Check dispose kills process
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(body: SizedBox.shrink()),
),
);
expect(fakeProcess.killCalled, isTrue);
});
testWidgets('discards stale session when device changes during async start', (
tester,
) async {
final completerA = Completer<AdbLogcatSession>();
final completerB = Completer<AdbLogcatSession>();
final fakeProcessA = _FakeProcess();
final sessionA = AdbLogcatSession(fakeProcessA);
final fakeProcessB = _FakeProcess();
final sessionB = AdbLogcatSession(fakeProcessB);
const deviceA = AdbDevice(
serial: 'device-A',
state: 'device',
model: 'Pixel_8',
product: 'shiba',
);
const deviceB = AdbDevice(
serial: 'device-B',
state: 'device',
model: 'Galaxy_S23',
product: 'dm3q',
);
var startCalls = 0;
Future<AdbLogcatSession> fakeStarter({String? serial}) async {
startCalls++;
if (serial == 'device-A') {
return completerA.future;
} else if (serial == 'device-B') {
return completerB.future;
}
throw Exception('unexpected serial');
}
// 1. Render ConsolePage with deviceA, starter completerA is pending
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(device: deviceA, logcatStarter: fakeStarter),
),
),
);
// Initial pump to trigger initState
await tester.pump();
expect(startCalls, 1);
// 2. Update widget to deviceB, starter completerB is pending
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(device: deviceB, logcatStarter: fakeStarter),
),
),
);
// didUpdateWidget triggers start for deviceB
await tester.pump();
expect(startCalls, 2);
// 3. Complete starter B first
completerB.complete(sessionB);
await tester.pump(); // let completerB resolve
await tester.pump(Duration.zero); // let stream listen trigger
// Emit line from B
fakeProcessB.emitStdout('I/test: hello from B\n');
await tester.pump(Duration.zero);
expect(find.text('I/test: hello from B'), findsOneWidget);
// 4. Now complete starter A (which is stale)
completerA.complete(sessionA);
await tester.pump(); // let completerA resolve
await tester.pump(Duration.zero);
// A session must be stopped immediately
expect(fakeProcessA.killCalled, isTrue);
});
testWidgets('auto-scrolls to the newest log while running', (tester) async {
final fakeProcess = _FakeProcess();
final session = AdbLogcatSession(fakeProcess);
const device = AdbDevice(
serial: 'R5CT90A1B2C',
state: 'device',
model: 'SM_S918N',
product: 'dm3q',
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(
device: device,
logcatStarter: ({serial}) async => session,
),
),
),
);
await tester.pump();
// Emit enough lines to make the list scrollable (e.g., 50 lines)
for (var i = 0; i < 50; i++) {
fakeProcess.emitStdout('I/test: line $i\n');
}
await tester.pump(Duration.zero);
await tester.pumpAndSettle();
// Verify it auto-scrolled to the bottom
final listView = tester.widget<ListView>(find.byType(ListView));
final controller = listView.controller;
expect(controller, isNotNull);
expect(
controller!.position.pixels,
equals(controller.position.maxScrollExtent),
);
});
testWidgets('keeps user scroll position while paused', (tester) async {
final fakeProcess = _FakeProcess();
final session = AdbLogcatSession(fakeProcess);
const device = AdbDevice(
serial: 'R5CT90A1B2C',
state: 'device',
model: 'SM_S918N',
product: 'dm3q',
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ConsolePage(
device: device,
logcatStarter: ({serial}) async => session,
),
),
),
);
await tester.pump();
// 1. Emit enough lines to scroll
for (var i = 0; i < 50; i++) {
fakeProcess.emitStdout('I/test: line $i\n');
}
await tester.pump(Duration.zero);
await tester.pumpAndSettle();
final listView = tester.widget<ListView>(find.byType(ListView));
final controller = listView.controller!;
final maxScrollExtentBeforePause = controller.position.maxScrollExtent;
expect(controller.position.pixels, equals(maxScrollExtentBeforePause));
// 2. Press pause button
final pauseButtonFinder = find.byTooltip('일시정지');
expect(pauseButtonFinder, findsOneWidget);
await tester.tap(pauseButtonFinder);
await tester.pumpAndSettle();
// Verify tooltip and icon changed
expect(find.byTooltip('재개'), findsOneWidget);
// 3. User scrolls up slightly to some offset (e.g., 100.0)
controller.jumpTo(100.0);
await tester.pumpAndSettle();
expect(controller.position.pixels, equals(100.0));
// 4. Emit more lines while paused
for (var i = 50; i < 60; i++) {
fakeProcess.emitStdout('I/test: line $i\n');
}
await tester.pump(Duration.zero);
await tester.pumpAndSettle();
// Verify offset remains at 100.0 (does not scroll to bottom)
expect(controller.position.pixels, equals(100.0));
// 5. Tap resume (tooltip '재개')
final resumeButtonFinder = find.byTooltip('재개');
await tester.tap(resumeButtonFinder);
await tester.pumpAndSettle();
// Verify tooltip is '일시정지' again and it scrolled to bottom
expect(find.byTooltip('일시정지'), findsOneWidget);
expect(
controller.position.pixels,
equals(controller.position.maxScrollExtent),
);
});
}
Color? _textColor(WidgetTester tester, String text) {

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@ -1432,4 +1433,192 @@ void main() {
expect(find.byKey(const ValueKey('web-login-result')), findsOneWidget);
expect(find.textContaining('Toki Lab'), findsOneWidget);
});
testWidgets('ready device logcat button opens console for that device', (
tester,
) async {
final fakeProcess = _FakeProcess();
final session = AdbLogcatSession(fakeProcess);
var startCalls = 0;
String? startedSerial;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
deviceLoader: () async => const [
AdbDevice(
serial: 'device-ready-logcat',
state: 'device',
model: 'Pixel_8',
product: 'shiba',
),
],
logcatStarter: ({serial}) async {
startCalls++;
startedSerial = serial;
return session;
},
),
),
);
await tester.pumpAndSettle();
// Navigate to
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('디바이스'),
),
);
await tester.pumpAndSettle();
expect(find.text('Pixel 8'), findsOneWidget);
// Tap logcat button on the ready device
final logcatButton = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == 'logcat',
);
await tester.tap(logcatButton.first);
await tester.pumpAndSettle();
// Check we navigated to
expect(startCalls, 1);
expect(startedSerial, 'device-ready-logcat');
expect(find.text('콘솔'), findsWidgets);
// Verify selected device identity is displayed on console
expect(find.textContaining('Pixel 8'), findsOneWidget);
expect(find.textContaining('device-ready-logcat'), findsOneWidget);
});
testWidgets('navigation away or device change stops logcat session', (
tester,
) async {
final fakeProcess1 = _FakeProcess();
final session1 = AdbLogcatSession(fakeProcess1);
final fakeProcess2 = _FakeProcess();
final session2 = AdbLogcatSession(fakeProcess2);
var startCalls = 0;
final sessions = [session1, session2];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AppSokShell(
deviceLoader: () async => const [
AdbDevice(
serial: 'device-1',
state: 'device',
model: 'Pixel_8',
product: 'shiba',
),
AdbDevice(
serial: 'device-2',
state: 'device',
model: 'Galaxy_S23',
product: 'dm3q',
),
],
logcatStarter: ({serial}) async {
final s = sessions[startCalls];
startCalls++;
return s;
},
),
),
);
await tester.pumpAndSettle();
// Navigate to
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('디바이스'),
),
);
await tester.pumpAndSettle();
// Tap logcat button on device-1
final logcatButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == 'logcat',
);
await tester.tap(logcatButtons.at(0));
await tester.pumpAndSettle();
expect(startCalls, 1);
expect(fakeProcess1.killCalled, isFalse);
// Navigate to again (leaving ConsolePage)
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('디바이스'),
),
);
await tester.pumpAndSettle();
expect(fakeProcess1.killCalled, isTrue);
// Tap logcat button on device-2
await tester.tap(logcatButtons.at(1));
await tester.pumpAndSettle();
expect(startCalls, 2);
expect(fakeProcess2.killCalled, isFalse);
// Change selected console device by tapping logcat on device-1
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('디바이스'),
),
);
await tester.pumpAndSettle();
// Tap device-1 logcat
await tester.tap(logcatButtons.at(0));
await tester.pumpAndSettle();
// Now session 2 (device-2) must be stopped because device changed
expect(fakeProcess2.killCalled, isTrue);
});
}
class _FakeProcess implements Process {
final _stdout = StreamController<List<int>>();
final _stderr = StreamController<List<int>>();
final _exitCode = Completer<int>();
bool killCalled = false;
void emitStdout(String text) => _stdout.add(utf8.encode(text));
void emitStderr(String text) => _stderr.add(utf8.encode(text));
@override
Stream<List<int>> get stdout => _stdout.stream;
@override
Stream<List<int>> get stderr => _stderr.stream;
@override
Future<int> get exitCode => _exitCode.future;
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
killCalled = true;
if (!_exitCode.isCompleted) _exitCode.complete(-15);
_stdout.close();
_stderr.close();
return true;
}
@override
int get pid => 4242;
@override
IOSink get stdin => throw UnimplementedError();
}