feat: proto socket infrastructure communication rail - client diagnostics & archive M12 core diagnostics

- Archive 12+11_core_diagnostics to agent-task/archive/2026/05
- Update client app initialization with proto socket lifecycle
- Add workspace home page with diagnostic capabilities
- Integrate proto socket lifecycle management
- Update tests for proto socket integration
- Update core server tests
- Update roadmap milestone tracking
This commit is contained in:
toki 2026-05-31 05:50:18 +09:00
parent 382b5f14ba
commit 03f9e98906
14 changed files with 594 additions and 56 deletions

View file

@ -61,7 +61,7 @@ Flutter client가 no-op proto-socket facade를 실제 Core 연결 레일로 전
내부 통신 레일을 운영 가능하게 만들고 예외 프로토콜이 다시 기본값으로 새지 않게 한다.
- [ ] [diagnostics] connection id, protocol version, channel name, error code를 로그와 디버그 표면에서 확인할 수 있게 한다.
- [ ] [diagnostics] connection id, protocol version, channel name, error code를 로그와 디버그 표면에서 확인할 수 있게 한다. 검증: client diagnostics 구현은 반영되었고, 허용된 검증 환경에서 Flutter test/analyze와 code-review PASS가 필요하다.
- [x] [rollout-docs] 새 내부 통신을 추가할 때 proto-socket 우선 여부와 REST/HTTP 예외 사유를 확인하는 운영 체크를 문서화한다.
## 완료 리뷰
@ -88,10 +88,11 @@ Flutter client가 no-op proto-socket facade를 실제 Core 연결 레일로 전
- 표준선(선택): 내부 런타임 간 신규 통신은 `proto-socket`을 기본 표준으로 삼고, REST/HTTP는 명시된 예외 조건에서만 유지한다.
- 선행 작업: Mattermost Nexo Messaging Alignment, Client Integration Standardization, 프로젝트 룰의 proto-socket 표준 규약
- 후속 작업: Workflow Core, External Integration, Project Workspace Management UX
- 현재 지점: Core proto-socket endpoint, task channel, Flutter concrete connector, client task service boundary, workspace/task 화면 소비 경계는 반영되었고, 다음 작업은 diagnostics 로그와 디버그 표면을 여는 것이다.
- 현재 지점: Core proto-socket endpoint, task channel, Flutter concrete connector, client task service boundary, workspace/task 화면 소비 경계, client diagnostics snapshot/debug surface 구현은 반영되었고, 다음 작업은 허용된 검증 환경에서 Flutter test/analyze 실행과 code-review PASS로 diagnostics 항목을 닫는 것이다.
- 반영 근거: `packages/contracts/notes/flutter-core-api-candidates.md`에 내부 통신 원칙, proto-socket semantic envelope 후보, REST compatibility map 후보를 추가했다.
- Core 반영 근거: `services/core/internal/protosocket/`, `services/core/internal/http/router.go`, `services/core/cmd/server/main.go`에 proto-socket server, task channel, task status event broadcaster, authenticated route 연결과 contract tests가 추가되었다.
- Client connector 반영 근거: `apps/client/lib/src/integrations/proto_socket/proto_socket_client.dart`, `apps/client/lib/src/app/bootstrap.dart`, `apps/client/lib/src/app/nomadcode_client_app.dart``RealProtoSocketConnector`, endpoint-config 기반 connect, task service wiring이 추가되었다.
- Client task/test 반영 근거: `apps/client/lib/src/integrations/proto_socket/proto_socket_task_service.dart`, `apps/client/lib/src/features/workspaces/presentation/workspace_home_page.dart`, `apps/client/test/integrations/proto_socket_*_test.dart`, `apps/client/test/widget_test.dart`에 task channel 소비 경계와 endpoint/lifecycle/task service/widget 검증이 추가되었다.
- Client diagnostics 반영 근거: `apps/client/lib/src/integrations/proto_socket/proto_socket_lifecycle.dart``ProtoSocketDiagnostics` snapshot/stream과 response/event diagnostics recording을 추가했고, `apps/client/lib/src/app/nomadcode_client_app.dart`, `apps/client/lib/src/features/workspaces/presentation/workspace_home_page.dart`, `apps/client/test/integrations/proto_socket_lifecycle_test.dart`, `apps/client/test/widget_test.dart`에 payload/auth/raw error message 없이 connection id, protocol version, channel/action, error code, timestamp를 확인하는 debug surface와 테스트를 추가했다.
- 운영 체크 근거: `packages/contracts/notes/flutter-core-api-candidates.md`에 새 내부 통신 추가 시 proto-socket 우선 여부, REST/HTTP 예외 기록, channel/action 문서화, 검증 항목을 확인하는 운영 체크를 추가했다.
- 확인 필요: 없음
- 확인 필요: 허용된 원격/검증 환경에서 `cd apps/client && flutter test`, `cd apps/client && flutter analyze --no-fatal-infos`를 실행하고, `agent-task/m-proto-socket-infrastructure-communication-rail/13+11,12_client_diagnostics/` code-review PASS 후 `[diagnostics]` 완료 처리 여부를 반영한다.

View file

@ -34,45 +34,48 @@ task=m-proto-socket-infrastructure-communication-rail/12+11_core_diagnostics, pl
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] Checklist contract recovery | [ ] |
| [REVIEW_API-2] Core test evidence recovery | [ ] |
| [REVIEW_API-3] Core final verification recovery | [ ] |
| [REVIEW_API-1] Checklist contract recovery | [x] |
| [REVIEW_API-2] Core test evidence recovery | [x] |
| [REVIEW_API-3] Core final verification recovery | [x] |
## 구현 체크리스트
- [ ] 이번 plan/review의 `구현 체크리스트` 항목 텍스트와 순서를 동일하게 유지하고, 실행 사유나 메모는 체크리스트 문구가 아닌 `계획 대비 변경 사항` 또는 `검증 결과`에 기록한다.
- [ ] `cd services/core && go test ./internal/protosocket -run 'TestServer.*Diagnostics|TestServerRespondsWithErrorEnvelopeForUnsupportedAction' -count=1`를 허용된 원격/검증 환경에서 실행하고 실제 stdout/stderr를 기록한다.
- [ ] `cd services/core && go test ./internal/protosocket -run 'TestRouterProtoSocketTaskListRequiresAuth|TestProtoSocketTaskErrorEnvelopeCodes|Test.*Broadcast.*' -count=1`를 허용된 원격/검증 환경에서 실행하고 실제 stdout/stderr를 기록한다.
- [ ] `cd services/core && go test ./...`를 허용된 원격/검증 환경에서 실행하고 실제 stdout/stderr를 기록한다.
- [ ] `cd services/core && go vet ./...`를 허용된 원격/검증 환경에서 실행하고 실제 stdout/stderr를 기록한다.
- [ ] 검증 명령이 실패하면 repo-owned 원인을 최소 범위로 수정하고, 실패 출력과 수정 후 통과 출력을 모두 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] 이번 plan/review의 `구현 체크리스트` 항목 텍스트와 순서를 동일하게 유지하고, 실행 사유나 메모는 체크리스트 문구가 아닌 `계획 대비 변경 사항` 또는 `검증 결과`에 기록한다.
- [x] `cd services/core && go test ./internal/protosocket -run 'TestServer.*Diagnostics|TestServerRespondsWithErrorEnvelopeForUnsupportedAction' -count=1`를 허용된 원격/검증 환경에서 실행하고 실제 stdout/stderr를 기록한다.
- [x] `cd services/core && go test ./internal/protosocket -run 'TestRouterProtoSocketTaskListRequiresAuth|TestProtoSocketTaskErrorEnvelopeCodes|Test.*Broadcast.*' -count=1`를 허용된 원격/검증 환경에서 실행하고 실제 stdout/stderr를 기록한다.
- [x] `cd services/core && go test ./...`를 허용된 원격/검증 환경에서 실행하고 실제 stdout/stderr를 기록한다.
- [x] `cd services/core && go vet ./...`를 허용된 원격/검증 환경에서 실행하고 실제 stdout/stderr를 기록한다.
- [x] 검증 명령이 실패하면 repo-owned 원인을 최소 범위로 수정하고, 실패 출력과 수정 후 통과 출력을 모두 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-proto-socket-infrastructure-communication-rail/12+11_core_diagnostics/`를 `agent-task/archive/YYYY/MM/m-proto-socket-infrastructure-communication-rail/12+11_core_diagnostics/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-proto-socket-infrastructure-communication-rail/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하여 plan/review/archive 산출물이 추적 가능한지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-proto-socket-infrastructure-communication-rail/12+11_core_diagnostics/`를 `agent-task/archive/YYYY/MM/m-proto-socket-infrastructure-communication-rail/12+11_core_diagnostics/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-proto-socket-infrastructure-communication-rail/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 원격 검증은 기존 원격 `~/works/nomadcode`가 다른 커밋과 사용자 변경을 가진 상태라 건드리지 않고, 현재 작업 트리를 `/tmp/nomadcode-verify-382b5f1`에 임시 전개해 실행했다.
- `go test ./...` 첫 실행에서 `TestServerAcceptsWebSocketAndClosesClients`가 server-side client 등록 전에 `srv.Close()`를 호출할 수 있는 race로 실패했다. `server_test.go`에서 기존 helper `waitForServerClients(t, srv, 1)`를 close 직전에 호출하도록 최소 수정했다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- checklist contract 회복은 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`의 체크리스트 문구를 바꾸지 않고 체크 상태만 갱신했다.
- 소스 변경은 production code가 아니라 `services/core/internal/protosocket/server_test.go`의 기존 close lifecycle test 안정화에 한정했다.
- remote verification은 `toki@toki-labs.com:/tmp/nomadcode-verify-382b5f1`에서 수행했다.
## 사용자 리뷰 요청
@ -106,34 +109,93 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### REVIEW_API-1 중간 검증
```
$ diff -u <(awk '/^## 구현 체크리스트$/{flag=1; next} /^## 의존 관계 및 구현 순서$/{flag=0} flag && /^- \[[ x]\] /{sub(/^- \[[ x]\] /,"- [ ] "); print}' PLAN-cloud-G07.md) <(awk '/^## 구현 체크리스트$/{flag=1; next} /^## 코드리뷰 전용 체크리스트$/{flag=0} flag && /^- \[[ x]\] /{sub(/^- \[[ x]\] /,"- [ ] "); print}' CODE_REVIEW-cloud-G07.md)
(output)
(no stdout/stderr; exit 0)
```
### REVIEW_API-2 중간 검증
```
$ cd services/core && go test ./internal/protosocket -run 'TestServer.*Diagnostics|TestServerRespondsWithErrorEnvelopeForUnsupportedAction' -count=1
(output)
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.572s
$ cd services/core && go test ./internal/protosocket -run 'TestRouterProtoSocketTaskListRequiresAuth|TestProtoSocketTaskErrorEnvelopeCodes|Test.*Broadcast.*' -count=1
(output)
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.368s
```
### REVIEW_API-3 중간 검증
```
$ cd services/core && go test ./...
(output)
첫 실행 실패:
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke 0.518s
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a 0.469s
? github.com/nomadcode/nomadcode-core/internal/adapters/mattermost [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai 0.925s
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane 1.660s
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/config 2.879s
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/http 2.488s
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification 2.054s
--- FAIL: TestServerAcceptsWebSocketAndClosesClients (1.00s)
server_test.go:135: timed out waiting for client to disconnect after server close
FAIL
FAIL github.com/nomadcode/nomadcode-core/internal/protosocket 2.234s
ok github.com/nomadcode/nomadcode-core/internal/scheduler 5.458s
? github.com/nomadcode/nomadcode-core/internal/storage [no test files]
ok github.com/nomadcode/nomadcode-core/internal/workflow 3.117s
ok github.com/nomadcode/nomadcode-core/internal/workitem 3.531s
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline 4.084s
FAIL
수정 후 재실행:
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached)
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a (cached)
? github.com/nomadcode/nomadcode-core/internal/adapters/mattermost [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached)
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/config (cached)
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/http (cached)
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification (cached)
ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.397s
ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached)
? github.com/nomadcode/nomadcode-core/internal/storage [no test files]
ok github.com/nomadcode/nomadcode-core/internal/workflow (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitem (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached)
$ cd services/core && go vet ./...
(output)
(no stdout/stderr; exit 0)
```
### 최종 검증
```
$ cd services/core && go test ./...
(output)
ok github.com/nomadcode/nomadcode-core/cmd/plane-smoke (cached)
? github.com/nomadcode/nomadcode-core/cmd/server [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/a2a (cached)
? github.com/nomadcode/nomadcode-core/internal/adapters/mattermost [no test files]
ok github.com/nomadcode/nomadcode-core/internal/adapters/openai (cached)
ok github.com/nomadcode/nomadcode-core/internal/adapters/plane (cached)
? github.com/nomadcode/nomadcode-core/internal/agent [no test files]
ok github.com/nomadcode/nomadcode-core/internal/config (cached)
? github.com/nomadcode/nomadcode-core/internal/db [no test files]
ok github.com/nomadcode/nomadcode-core/internal/http (cached)
? github.com/nomadcode/nomadcode-core/internal/model [no test files]
ok github.com/nomadcode/nomadcode-core/internal/notification (cached)
ok github.com/nomadcode/nomadcode-core/internal/protosocket (cached)
ok github.com/nomadcode/nomadcode-core/internal/scheduler (cached)
? github.com/nomadcode/nomadcode-core/internal/storage [no test files]
ok github.com/nomadcode/nomadcode-core/internal/workflow (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitem (cached)
ok github.com/nomadcode/nomadcode-core/internal/workitempipeline (cached)
$ cd services/core && go vet ./...
(output)
(no stdout/stderr; exit 0)
```
---
@ -141,3 +203,17 @@ $ cd services/core && go vet ./...
> **[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`를 작성하고 active task directory를 archive로 이동한다.

View file

@ -0,0 +1,36 @@
# Complete - m-proto-socket-infrastructure-communication-rail/12+11_core_diagnostics
## 완료 일시
2026-05-30
## 요약
Core proto-socket diagnostics implementation and verification recovery completed after 2 review loops; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | Core diagnostics source changes were present, but checklist contract and required verification evidence were incomplete. |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | Verification evidence was recovered in the allowed remote environment and the flaky close lifecycle test was stabilized. |
## 구현/정리 내용
- Core proto-socket response/event diagnostics meta and structured log coverage from the original implementation loop were retained.
- Follow-up loop preserved plan/review checklist text, recorded actual remote stdout/stderr, and stabilized `TestServerAcceptsWebSocketAndClosesClients` with `waitForServerClients(t, srv, 1)` before server close.
## 최종 검증
- `cd services/core && go test ./internal/protosocket -run 'TestServer.*Diagnostics|TestServerRespondsWithErrorEnvelopeForUnsupportedAction' -count=1` - PASS; remote rerun output: `ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.853s`.
- `cd services/core && go test ./internal/protosocket -run 'TestRouterProtoSocketTaskListRequiresAuth|TestProtoSocketTaskErrorEnvelopeCodes|Test.*Broadcast.*' -count=1` - PASS; remote rerun output: `ok github.com/nomadcode/nomadcode-core/internal/protosocket 0.541s`.
- `cd services/core && go test ./...` - PASS; remote rerun completed with all packages passing or cached and no failures.
- `cd services/core && go vet ./...` - PASS; remote rerun exited 0 with no stdout/stderr.
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -41,19 +41,19 @@ task=m-proto-socket-infrastructure-communication-rail/13+11,12_client_diagnostic
| 항목 | 완료 여부 |
|------|---------|
| [API-1] Client diagnostics state | [ ] |
| [API-2] Workspace debug surface | [ ] |
| [API-1] Client diagnostics state | [x] |
| [API-2] Workspace debug surface | [x] |
## 구현 체크리스트
- [ ] `11_contracts_diagnostics``12+11_core_diagnostics`가 PASS되어 각 `complete.log`가 생겼는지 확인하고 경로를 review stub에 기록한다.
- [ ] `ProtoSocketLifecycle` 또는 dedicated diagnostics controller가 connection state, connection id, protocol version, recent channel/action/error code를 비밀 없이 유지하게 한다.
- [ ] `ProtoSocketTaskService`가 response/error envelope meta를 diagnostics sink에 전달하고 기존 error mapping을 유지한다.
- [ ] `WorkspaceHomePage` 또는 app shell에 compact debug surface를 추가해 connection id, protocol version, channel, error code를 확인할 수 있게 한다.
- [x] `ProtoSocketLifecycle` 또는 dedicated diagnostics controller가 connection state, connection id, protocol version, recent channel/action/error code를 비밀 없이 유지하게 한다.
- [x] `ProtoSocketTaskService`가 response/error envelope meta를 diagnostics sink에 전달하고 기존 error mapping을 유지한다.
- [x] `WorkspaceHomePage` 또는 app shell에 compact debug surface를 추가해 connection id, protocol version, channel, error code를 확인할 수 있게 한다.
- [ ] Flutter tests로 endpoint 설정, reconnect/failure lifecycle, task service diagnostics, widget debug surface를 검증한다.
- [ ] `cd apps/client && flutter test`를 허용된 원격/검증 환경에서 실행한다.
- [ ] `cd apps/client && flutter analyze --no-fatal-infos`를 허용된 원격/검증 환경에서 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
@ -75,11 +75,19 @@ task=m-proto-socket-infrastructure-communication-rail/13+11,12_client_diagnostic
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- 선행 `11_contracts_diagnostics`, `12+11_core_diagnostics`의 active `complete.log`는 아직 확인되지 않았다. 사용자의 직접 요청으로 client diagnostics 구현과 문서 동기화를 먼저 진행했으며, 최종 PASS/complete 처리는 code-review와 런타임 완료 이벤트 뒤에 남긴다.
- 새 `proto_socket_diagnostics.dart` 파일을 만들지 않고 `ProtoSocketDiagnostics``proto_socket_lifecycle.dart`에 두었다. 현재 diagnostics 소비자가 lifecycle과 workspace UI에 한정되어 있어 파일 추가보다 기존 facade 안에 보관하는 편이 범위를 작게 유지한다.
- `ProtoSocketTaskService`에 sink를 직접 주입하지 않고, `ProtoSocketLifecycle`이 연결 후 반환하는 transport를 diagnostics wrapper로 감싸 response envelope를 기록하게 했다. 이 방식은 기존 task service error mapping을 바꾸지 않으면서 task service의 response/error envelope meta를 같은 diagnostics state로 전달한다.
- app shell이 별도 diagnostics subscription을 소유하지 않고 `WorkspaceHomePage`에 lifecycle을 넘겨 `StreamBuilder``diagnosticsStream`을 직접 구독한다. state 중복 저장을 피하고 dispose 책임은 기존 lifecycle 소유권에 둔다.
- `agent-test/local/rules.md`가 로컬 테스트/검증 실행 금지를 명시하므로 이번 세션에서는 Flutter test/analyze 명령을 실행하지 않았다. 허용된 원격/검증 환경에서 후속 실행이 필요하다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- diagnostics snapshot에는 `state`, `connection_id`, `protocol_version`, `channel`, `action`, `error_code`, `timestamp`만 보관한다.
- response/event의 `payload`, `auth`, raw error message, secret 계열 값은 diagnostics state와 UI에 올리지 않는다.
- error가 없는 envelope는 `errorCode``null`로 유지하고 UI에서는 `none`으로 표시한다.
- Core가 이미 envelope meta와 structured log diagnostics를 제공하므로 client 작업은 수신/표시 표면에만 한정했다.
- workspace debug surface는 `ACTIVE TASKS` 아래의 compact `PROTO-SOCKET` section으로 추가했고, 긴 값은 ellipsis 처리해 기존 화면 레이아웃을 흔들지 않게 했다.
## 사용자 리뷰 요청
@ -113,22 +121,26 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### API-1 중간 검증
```
$ cd apps/client && flutter test test/integrations/proto_socket_lifecycle_test.dart test/integrations/proto_socket_task_service_test.dart
(output)
미실행. `agent-test/local/rules.md`가 로컬 테스트/검증 실행 금지를 명시한다.
후속으로 허용된 원격/검증 환경에서 실행해야 한다.
```
### API-2 중간 검증
```
$ cd apps/client && flutter test test/widget_test.dart
(output)
미실행. `agent-test/local/rules.md`가 로컬 테스트/검증 실행 금지를 명시한다.
후속으로 허용된 원격/검증 환경에서 실행해야 한다.
```
### 최종 검증
```
$ cd apps/client && flutter test
(output)
미실행. `agent-test/local/rules.md`가 로컬 테스트/검증 실행 금지를 명시한다.
후속으로 허용된 원격/검증 환경에서 실행해야 한다.
$ cd apps/client && flutter analyze --no-fatal-infos
(output)
미실행. `agent-test/local/rules.md`가 로컬 테스트/검증 실행 금지를 명시한다.
후속으로 허용된 원격/검증 환경에서 실행해야 한다.
```
---

View file

@ -144,6 +144,7 @@ class _NomadCodeClientHomeState extends State<_NomadCodeClientHome> {
return WorkspaceHomePage(
onLaunchCodeServer: (project) => _launcher.openCodeServer(project),
loadTasks: _taskLoader,
protoSocketLifecycle: widget.protoSocketLifecycle,
);
}
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../integrations/proto_socket/proto_socket_lifecycle.dart';
import '../domain/project_workspace.dart';
import '../domain/workspace_task.dart';
@ -13,8 +14,14 @@ typedef WorkspaceTaskLoader =
class WorkspaceHomePage extends StatefulWidget {
final Future<bool> Function(ProjectWorkspace)? onLaunchCodeServer;
final WorkspaceTaskLoader? loadTasks;
final ProtoSocketLifecycle? protoSocketLifecycle;
const WorkspaceHomePage({super.key, this.onLaunchCodeServer, this.loadTasks});
const WorkspaceHomePage({
super.key,
this.onLaunchCodeServer,
this.loadTasks,
this.protoSocketLifecycle,
});
@override
State<WorkspaceHomePage> createState() => _WorkspaceHomePageState();
@ -53,9 +60,7 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
void _reloadTasks() {
final loader = widget.loadTasks;
final project = _selectedProject;
_tasksFuture = (loader != null && project != null)
? loader(project)
: null;
_tasksFuture = (loader != null && project != null) ? loader(project) : null;
}
@override
@ -366,6 +371,7 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
),
const SizedBox(height: 16),
_buildTaskSection(project),
_buildProtoSocketDiagnosticsSection(),
],
),
);
@ -479,6 +485,138 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
);
}
Widget _buildProtoSocketDiagnosticsSection() {
final lifecycle = widget.protoSocketLifecycle;
if (lifecycle == null) return const SizedBox.shrink();
return StreamBuilder<ProtoSocketDiagnostics>(
stream: lifecycle.diagnosticsStream,
initialData: lifecycle.diagnostics,
builder: (context, snapshot) {
final diagnostics = snapshot.data ?? lifecycle.diagnostics;
final stateColor = _diagnosticsStateColor(diagnostics.state);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 32),
const Text(
'PROTO-SOCKET',
style: TextStyle(
color: Colors.white38,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
),
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF14142B),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF222244)),
),
child: Wrap(
spacing: 10,
runSpacing: 10,
children: [
_buildDiagnosticsChip(
Icons.wifi_tethering,
'state',
diagnostics.state.name,
color: stateColor,
),
_buildDiagnosticsChip(
Icons.link,
'connection_id',
_diagnosticValue(diagnostics.connectionId),
),
_buildDiagnosticsChip(
Icons.device_hub,
'protocol_version',
_diagnosticValue(diagnostics.protocolVersion),
),
_buildDiagnosticsChip(
Icons.label_outline,
'channel',
_diagnosticValue(diagnostics.channel),
),
_buildDiagnosticsChip(
Icons.flash_on,
'action',
_diagnosticValue(diagnostics.action),
),
_buildDiagnosticsChip(
Icons.error_outline,
'error_code',
_diagnosticValue(diagnostics.errorCode, empty: 'none'),
color: diagnostics.errorCode == null
? Colors.greenAccent
: Colors.redAccent,
),
_buildDiagnosticsChip(
Icons.schedule,
'timestamp',
_diagnosticValue(diagnostics.timestamp),
),
],
),
),
],
);
},
);
}
Widget _buildDiagnosticsChip(
IconData icon,
String label,
String value, {
Color color = Colors.cyanAccent,
}) {
return Container(
constraints: const BoxConstraints(minWidth: 132, maxWidth: 280),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withValues(alpha: 0.22)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white38, fontSize: 10),
),
const SizedBox(height: 2),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
),
);
}
Widget _buildStatusChip(String status) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@ -579,4 +717,22 @@ class _WorkspaceHomePageState extends State<WorkspaceHomePage> {
},
);
}
Color _diagnosticsStateColor(ProtoSocketConnectionState state) {
switch (state) {
case ProtoSocketConnectionState.connected:
return Colors.greenAccent;
case ProtoSocketConnectionState.connecting:
return Colors.amberAccent;
case ProtoSocketConnectionState.failed:
return Colors.redAccent;
case ProtoSocketConnectionState.disconnected:
return Colors.white54;
}
}
String _diagnosticValue(String? value, {String empty = 'n/a'}) {
if (value == null || value.isEmpty) return empty;
return value;
}
}

View file

@ -5,6 +5,62 @@ import 'proto_socket_envelope.dart';
enum ProtoSocketConnectionState { disconnected, connecting, connected, failed }
class ProtoSocketDiagnostics {
final ProtoSocketConnectionState state;
final String? connectionId;
final String? protocolVersion;
final String? channel;
final String? action;
final String? errorCode;
final String? timestamp;
const ProtoSocketDiagnostics({
required this.state,
this.connectionId,
this.protocolVersion,
this.channel,
this.action,
this.errorCode,
this.timestamp,
});
ProtoSocketDiagnostics copyWithState(ProtoSocketConnectionState next) {
return ProtoSocketDiagnostics(
state: next,
connectionId: connectionId,
protocolVersion: protocolVersion,
channel: channel,
action: action,
errorCode: errorCode,
timestamp: timestamp,
);
}
ProtoSocketDiagnostics fromEnvelope(ProtoSocketEnvelope envelope) {
return ProtoSocketDiagnostics(
state: state,
connectionId:
_stringValue(envelope.meta['connection_id']) ?? connectionId,
protocolVersion:
_nonEmptyString(envelope.protocolVersion) ??
_stringValue(envelope.meta['protocol_version']) ??
protocolVersion,
channel:
_nonEmptyString(envelope.channel) ??
_stringValue(envelope.meta['channel']) ??
channel,
action:
_nonEmptyString(envelope.action) ??
_stringValue(envelope.meta['action']) ??
action,
errorCode:
_nonEmptyString(envelope.error?.code) ??
_nonEmptyString(_stringValue(envelope.meta['error_code'])),
timestamp: _stringValue(envelope.meta['timestamp']) ?? timestamp,
);
}
}
abstract interface class ProtoSocketTransport {
Future<ProtoSocketEnvelope> sendRequest(
ProtoSocketEnvelope envelope, {
@ -32,16 +88,25 @@ class ProtoSocketLifecycle {
final ProtoSocketConnector _connector;
final StreamController<ProtoSocketConnectionState> _stateController =
StreamController<ProtoSocketConnectionState>.broadcast();
final StreamController<ProtoSocketDiagnostics> _diagnosticsController =
StreamController<ProtoSocketDiagnostics>.broadcast();
ProtoSocketConnectionState _state = ProtoSocketConnectionState.disconnected;
ProtoSocketDiagnostics _diagnostics = const ProtoSocketDiagnostics(
state: ProtoSocketConnectionState.disconnected,
);
Object? _lastError;
ProtoSocketTransport? _transport;
StreamSubscription<ProtoSocketEnvelope>? _eventDiagnosticsSub;
ProtoSocketLifecycle({required ProtoSocketConnector connector})
: _connector = connector;
ProtoSocketConnectionState get state => _state;
Stream<ProtoSocketConnectionState> get stateStream => _stateController.stream;
ProtoSocketDiagnostics get diagnostics => _diagnostics;
Stream<ProtoSocketDiagnostics> get diagnosticsStream =>
_diagnosticsController.stream;
Object? get lastError => _lastError;
ProtoSocketTransport? get transport => _transport;
@ -53,11 +118,18 @@ class ProtoSocketLifecycle {
_emit(ProtoSocketConnectionState.connecting);
try {
final transport = await _connector.connect(config);
_transport = transport;
await _eventDiagnosticsSub?.cancel();
_eventDiagnosticsSub = transport.events.listen(_recordDiagnostics);
_transport = _DiagnosticsProtoSocketTransport(
delegate: transport,
onEnvelope: _recordDiagnostics,
);
_lastError = null;
_emit(ProtoSocketConnectionState.connected);
} catch (e) {
_transport = null;
await _eventDiagnosticsSub?.cancel();
_eventDiagnosticsSub = null;
_lastError = e;
_emit(ProtoSocketConnectionState.failed);
rethrow;
@ -67,20 +139,64 @@ class ProtoSocketLifecycle {
Future<void> disconnect() async {
if (_state == ProtoSocketConnectionState.disconnected) return;
await _connector.disconnect();
await _eventDiagnosticsSub?.cancel();
_eventDiagnosticsSub = null;
_transport = null;
_emit(ProtoSocketConnectionState.disconnected);
}
Future<void> dispose() async {
await _eventDiagnosticsSub?.cancel();
_eventDiagnosticsSub = null;
await _diagnosticsController.close();
await _stateController.close();
}
void _emit(ProtoSocketConnectionState next) {
_state = next;
_emitDiagnostics(_diagnostics.copyWithState(next));
if (!_stateController.isClosed) {
_stateController.add(next);
}
}
void _recordDiagnostics(ProtoSocketEnvelope envelope) {
_emitDiagnostics(_diagnostics.fromEnvelope(envelope));
}
void _emitDiagnostics(ProtoSocketDiagnostics next) {
_diagnostics = next;
if (!_diagnosticsController.isClosed) {
_diagnosticsController.add(next);
}
}
}
class _DiagnosticsProtoSocketTransport implements ProtoSocketTransport {
final ProtoSocketTransport _delegate;
final void Function(ProtoSocketEnvelope envelope) _onEnvelope;
_DiagnosticsProtoSocketTransport({
required ProtoSocketTransport delegate,
required void Function(ProtoSocketEnvelope envelope) onEnvelope,
}) : _delegate = delegate,
_onEnvelope = onEnvelope;
@override
Stream<ProtoSocketEnvelope> get events => _delegate.events;
@override
Future<ProtoSocketEnvelope> sendRequest(
ProtoSocketEnvelope envelope, {
Duration timeout = const Duration(seconds: 30),
}) async {
final response = await _delegate.sendRequest(envelope, timeout: timeout);
_onEnvelope(response);
return response;
}
@override
Future<void> close() => _delegate.close();
}
/// Test/fallback connector for disabled or fake scenarios.
@ -112,3 +228,10 @@ class _NoopProtoSocketTransport implements ProtoSocketTransport {
@override
Future<void> close() => _events.close();
}
String? _stringValue(Object? value) => value is String ? value : null;
String? _nonEmptyString(String? value) {
if (value == null || value.isEmpty) return null;
return value;
}

View file

@ -212,10 +212,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
nexo_messaging:
dependency: "direct main"
description:
@ -303,10 +303,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.10"
typed_data:
dependency: transitive
description:

View file

@ -104,7 +104,7 @@ void main() {
await lifecycle.connect(config);
expect(lifecycle.state, ProtoSocketConnectionState.connected);
expect(lifecycle.transport, same(fake.transport));
expect(lifecycle.transport, isNotNull);
expect(fake.connectCalls, equals(1));
await lifecycle.disconnect();
@ -123,6 +123,80 @@ void main() {
]);
});
test('records response diagnostics from transport requests', () async {
final fake = _FakeConnector();
final lifecycle = ProtoSocketLifecycle(connector: fake);
fake.transport.response = const ProtoSocketEnvelope(
id: 'resp-1',
correlationId: 'req-1',
type: 'response',
channel: 'task',
action: 'task.list',
meta: {
'connection_id': 'conn-response',
'timestamp': '2026-05-30T12:00:00Z',
},
payload: {'secret': 'not-for-diagnostics'},
);
await lifecycle.connect(config);
await lifecycle.transport!.sendRequest(
const ProtoSocketEnvelope(
id: 'req-1',
type: 'request',
channel: 'task',
action: 'task.list',
),
);
expect(lifecycle.diagnostics.state, ProtoSocketConnectionState.connected);
expect(lifecycle.diagnostics.connectionId, equals('conn-response'));
expect(
lifecycle.diagnostics.protocolVersion,
equals(protoSocketProtocolVersion),
);
expect(lifecycle.diagnostics.channel, equals('task'));
expect(lifecycle.diagnostics.action, equals('task.list'));
expect(lifecycle.diagnostics.errorCode, isNull);
expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:00:00Z'));
await lifecycle.dispose();
});
test('records event diagnostics with error codes', () async {
final fake = _FakeConnector();
final lifecycle = ProtoSocketLifecycle(connector: fake);
await lifecycle.connect(config);
fake.transport.controller.add(
const ProtoSocketEnvelope(
id: 'event-1',
type: 'event',
channel: 'task',
action: 'task.status.changed',
error: ProtoSocketEnvelopeError(
code: 'task.conflict',
message: 'payload detail is private',
retryable: false,
),
meta: {
'connection_id': 'conn-event',
'timestamp': '2026-05-30T12:01:00Z',
},
payload: {'token': 'do-not-display'},
),
);
await Future<void>.delayed(Duration.zero);
expect(lifecycle.diagnostics.connectionId, equals('conn-event'));
expect(lifecycle.diagnostics.channel, equals('task'));
expect(lifecycle.diagnostics.action, equals('task.status.changed'));
expect(lifecycle.diagnostics.errorCode, equals('task.conflict'));
expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:01:00Z'));
await lifecycle.dispose();
});
test('connect failure transitions to failed and records error', () async {
final fake = _FakeConnector()..throwOnConnect = StateError('boom');
final lifecycle = ProtoSocketLifecycle(connector: fake);

View file

@ -24,6 +24,8 @@ const _disabledProtoSocketConfig = ProtoSocketEndpointConfig(
);
class _FakeProtoSocketTransport implements ProtoSocketTransport {
final StreamController<ProtoSocketEnvelope> controller =
StreamController<ProtoSocketEnvelope>.broadcast();
final List<Map<String, Object?>> taskMaps = const [
{
'id': 'task-service-1',
@ -35,7 +37,7 @@ class _FakeProtoSocketTransport implements ProtoSocketTransport {
final List<ProtoSocketEnvelope> sent = [];
@override
Stream<ProtoSocketEnvelope> get events => const Stream.empty();
Stream<ProtoSocketEnvelope> get events => controller.stream;
@override
Future<ProtoSocketEnvelope> sendRequest(
@ -53,11 +55,15 @@ class _FakeProtoSocketTransport implements ProtoSocketTransport {
channel: 'task',
action: envelope.action,
payload: {'tasks': taskMaps},
meta: const {
'connection_id': 'conn-widget',
'timestamp': '2026-05-30T12:02:00Z',
},
);
}
@override
Future<void> close() async {}
Future<void> close() => controller.close();
}
class _FakeProtoSocketConnector implements ProtoSocketConnector {
@ -174,7 +180,8 @@ void main() {
await tester.pumpWidget(
MaterialApp(
home: WorkspaceHomePage(
loadTasks: (project) async => throw Exception('secret transport boom'),
loadTasks: (project) async =>
throw Exception('secret transport boom'),
),
),
);
@ -186,6 +193,54 @@ void main() {
expect(find.textContaining('secret transport boom'), findsNothing);
});
testWidgets('WorkspaceHomePage shows proto-socket diagnostics safely', (
WidgetTester tester,
) async {
final connector = _FakeProtoSocketConnector();
final lifecycle = ProtoSocketLifecycle(connector: connector);
await lifecycle.connect(_enabledProtoSocketConfig);
await tester.pumpWidget(
MaterialApp(home: WorkspaceHomePage(protoSocketLifecycle: lifecycle)),
);
await tester.pumpAndSettle();
connector.transport.controller.add(
const ProtoSocketEnvelope(
id: 'event-secret',
type: 'event',
channel: 'task',
action: 'task.status.changed',
error: ProtoSocketEnvelopeError(
code: 'task.conflict',
message: 'secret server detail',
retryable: false,
),
payload: {'secret': 'do-not-render'},
meta: {
'connection_id': 'conn-debug',
'timestamp': '2026-05-30T12:03:00Z',
},
),
);
await tester.pump();
expect(find.text('PROTO-SOCKET'), findsOneWidget);
expect(find.text('connected'), findsOneWidget);
expect(find.text('conn-debug'), findsOneWidget);
expect(find.text(protoSocketProtocolVersion), findsOneWidget);
expect(find.text('task'), findsOneWidget);
expect(find.text('task.status.changed'), findsOneWidget);
expect(find.text('task.conflict'), findsOneWidget);
expect(find.text('2026-05-30T12:03:00Z'), findsOneWidget);
expect(find.textContaining('do-not-render'), findsNothing);
expect(find.textContaining('secret server detail'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await lifecycle.disconnect();
await lifecycle.dispose();
});
testWidgets('reloads tasks when loader is attached after initial build', (
WidgetTester tester,
) async {
@ -272,6 +327,8 @@ void main() {
expect(connector.transport.sent.single.action, equals('task.list'));
expect(find.text('Service task from Core'), findsOneWidget);
expect(find.text('running'), findsOneWidget);
expect(find.text('conn-widget'), findsOneWidget);
expect(find.text('task.list'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();

View file

@ -124,6 +124,8 @@ func TestServerAcceptsWebSocketAndClosesClients(t *testing.T) {
disconnected <- struct{}{}
})
waitForServerClients(t, srv, 1)
if err := srv.Close(); err != nil {
t.Errorf("failed to close server: %v", err)
}