diff --git a/agent-roadmap/phase/workflow-core/milestones/proto-socket-infrastructure-communication-rail.md b/agent-roadmap/phase/workflow-core/milestones/proto-socket-infrastructure-communication-rail.md index 3778764..1a8bc95 100644 --- a/agent-roadmap/phase/workflow-core/milestones/proto-socket-infrastructure-communication-rail.md +++ b/agent-roadmap/phase/workflow-core/milestones/proto-socket-infrastructure-communication-rail.md @@ -45,9 +45,9 @@ NomadCode 내부 런타임 간 신규 통신을 `proto-socket` 중심으로 통 Core가 내부 런타임 통신용 proto-socket endpoint와 task channel을 제공할 수 있게 한다. -- [ ] [core-endpoint] `services/core`에 proto-socket endpoint 구성 위치와 lifecycle을 추가한다. 검증: Core가 기존 REST health/readiness를 유지하면서 proto-socket 연결 endpoint를 노출한다. -- [ ] [task-channels] task create/list/get/enqueue와 task status event를 proto-socket channel로 제공한다. 검증: REST task API와 같은 의미의 요청/응답이 proto-socket 경로에서 동작한다. -- [ ] [core-contract-tests] core proto-socket channel, auth, error, heartbeat 동작을 테스트로 고정한다. +- [x] [core-endpoint] `services/core`에 proto-socket endpoint 구성 위치와 lifecycle을 추가한다. 검증: Core가 기존 REST health/readiness를 유지하면서 proto-socket 연결 endpoint를 노출한다. +- [x] [task-channels] task create/list/get/enqueue와 task status event를 proto-socket channel로 제공한다. 검증: REST task API와 같은 의미의 요청/응답이 proto-socket 경로에서 동작한다. +- [x] [core-contract-tests] core proto-socket channel, auth, error, heartbeat 동작을 테스트로 고정한다. ### Epic: [client-rail] Flutter client-core rail @@ -62,7 +62,7 @@ Flutter client가 no-op proto-socket facade를 실제 Core 연결 레일로 전 내부 통신 레일을 운영 가능하게 만들고 예외 프로토콜이 다시 기본값으로 새지 않게 한다. - [ ] [diagnostics] connection id, protocol version, channel name, error code를 로그와 디버그 표면에서 확인할 수 있게 한다. -- [ ] [rollout-docs] 새 내부 통신을 추가할 때 proto-socket 우선 여부와 REST/HTTP 예외 사유를 확인하는 운영 체크를 문서화한다. +- [x] [rollout-docs] 새 내부 통신을 추가할 때 proto-socket 우선 여부와 REST/HTTP 예외 사유를 확인하는 운영 체크를 문서화한다. ## 완료 리뷰 @@ -88,6 +88,8 @@ 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 -- 현재 지점: Nexo/Mattermost host 경계는 최신화와 테스트가 끝났고, 다음 작업은 client-core 통신이 가능하도록 내부 통신 레일을 까는 것이다. +- 현재 지점: Core proto-socket endpoint와 task channel은 반영되었고, 다음 작업은 Flutter client connector와 workspace/task 화면의 Core proto-socket 소비 경계를 여는 것이다. - 반영 근거: `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가 추가되었다. +- 운영 체크 근거: `packages/contracts/notes/flutter-core-api-candidates.md`에 새 내부 통신 추가 시 proto-socket 우선 여부, REST/HTTP 예외 기록, channel/action 문서화, 검증 항목을 확인하는 운영 체크를 추가했다. - 확인 필요: 없음 diff --git a/agent-task/m-proto-socket-infrastructure-communication-rail/02+01_task_service/CODE_REVIEW-cloud-G06.md b/agent-task/m-proto-socket-infrastructure-communication-rail/02+01_task_service/CODE_REVIEW-cloud-G06.md new file mode 100644 index 0000000..2714a0b --- /dev/null +++ b/agent-task/m-proto-socket-infrastructure-communication-rail/02+01_task_service/CODE_REVIEW-cloud-G06.md @@ -0,0 +1,148 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> 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-05-30 +task=m-proto-socket-infrastructure-communication-rail/02+01_task_service, plan=0, tag=API + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G06.md` → `code_review_cloud_G06_N.log`, `PLAN-cloud-G06.md` → `plan_cloud_G06_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-proto-socket-infrastructure-communication-rail/02+01_task_service/`로 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] Task Models | [ ] | +| [API-2] Task Channel Service | [ ] | +| [API-3] Service Test Coverage | [ ] | + +## 구현 체크리스트 + +- [ ] [API-1] Add client task models matching the implemented Core task channel response shape. +- [ ] [API-2] Add `ProtoSocketTaskService` that calls `task.list`, `task.get`, `task.enqueue`, and optional `task.create` through `ProtoSocketTransport`. +- [ ] [API-3] Add task channel service tests for success, missing payload, and error envelope mapping. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. +- [ ] 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/02+01_task_service/`를 `agent-task/archive/YYYY/MM/m-proto-socket-infrastructure-communication-rail/02+01_task_service/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-proto-socket-infrastructure-communication-rail/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Predecessor `01_client_transport/complete.log` exists before implementation. +- Service action names and payloads match Core task channel contract. +- Error envelopes become typed client exceptions with retryable preserved. +- Tests use fake transport only and do not open sockets. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### API-1 중간 검증 +```bash +$ cd apps/client && flutter test test/integrations/proto_socket_task_service_test.dart +(output) +``` + +### API-2 중간 검증 +```bash +$ cd apps/client && flutter test test/integrations/proto_socket_task_service_test.dart +(output) +``` + +### API-3 중간 검증 +```bash +$ cd apps/client && flutter test test/integrations/proto_socket_task_service_test.dart +(output) +``` + +### 최종 검증 +```bash +$ test -f agent-task/m-proto-socket-infrastructure-communication-rail/01_client_transport/complete.log +(output) +$ cd apps/client && flutter test test/integrations/proto_socket_task_service_test.dart +(output) +$ cd apps/client && flutter analyze --no-fatal-infos +(output) +``` + +--- + +> **[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. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only | +| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus only | +| 검증 결과 | Implementing agent | Fill command output only | +| 코드리뷰 결과 | Review agent appends | Not included in stub | diff --git a/agent-task/m-proto-socket-infrastructure-communication-rail/02+01_task_service/PLAN-cloud-G06.md b/agent-task/m-proto-socket-infrastructure-communication-rail/02+01_task_service/PLAN-cloud-G06.md new file mode 100644 index 0000000..252dfed --- /dev/null +++ b/agent-task/m-proto-socket-infrastructure-communication-rail/02+01_task_service/PLAN-cloud-G06.md @@ -0,0 +1,256 @@ + + +# Plan - API Proto-Socket Task Service + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan의 구현이 끝나면 active `CODE_REVIEW-*-G??.md`에서 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채워야 한다. 최종 archive, `complete.log`, 리뷰 판정은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret 준비, 또는 안전한 진행을 막는 scope 충돌이 있으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정과 근거를 남기고 멈춘다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 메울 수 있는 검증 공백만으로는 사용자 리뷰 요청을 만들지 않는다. + +## 배경 + +Core task channel은 `task.create`, `task.list`, `task.get`, `task.enqueue`, `task.status.changed`를 proto-socket envelope로 제공한다. Flutter client는 transport가 생긴 뒤에도 task 도메인 모델과 request parsing 경계가 없으면 화면에서 mock data를 대체할 수 없다. 이 작업은 UI를 바꾸지 않고 client service 경계만 만든다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. code-review가 해당 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/workflow-core/PHASE.md` +- `agent-roadmap/phase/workflow-core/milestones/proto-socket-infrastructure-communication-rail.md` +- `agent-ops/rules/project/domain/mobile/rules.md` +- `agent-test/local/mobile-smoke.md` +- `apps/client/lib/src/integrations/proto_socket/proto_socket_lifecycle.dart` +- `apps/client/lib/src/features/workspaces/domain/project_workspace.dart` +- `apps/client/lib/src/features/workspaces/presentation/workspace_home_page.dart` +- `apps/client/test/widget_test.dart` +- `packages/contracts/notes/flutter-core-api-candidates.md` +- `services/core/internal/protosocket/tasks.go` + +### 테스트 커버리지 공백 + +- No existing Flutter test covers task channel request creation. +- No existing Flutter test covers Core error envelope mapping to a client exception. +- Widget tests use `mockProjectWorkspaces` only; they do not exercise task service consumption. + +### 심볼 참조 + +- No renamed or removed symbols planned. +- New service should depend on the `ProtoSocketTransport` produced by `01_client_transport`. + +### 분할 판단 + +Split policy was evaluated before writing files. This subtask depends on `01_client_transport` because it needs `ProtoSocketEnvelope` and `ProtoSocketTransport`. UI integration remains in `03+02_workspace_integration` so service parsing can be reviewed independently. + +### 범위 결정 근거 + +This task does not change `WorkspaceHomePage` rendering or app bootstrap. It does not create Core APIs, new REST endpoints, or persistence. It only creates a Flutter task channel client and tests using fake transport envelopes. + +### 빌드 등급 + +Build lane `cloud-G06`, review lane `cloud-G06`: protocol response parsing and error mapping with moderate blast radius, but no UI or platform runner changes. + +## 구현 체크리스트 + +- [ ] [API-1] Add client task models matching the implemented Core task channel response shape. +- [ ] [API-2] Add `ProtoSocketTaskService` that calls `task.list`, `task.get`, `task.enqueue`, and optional `task.create` through `ProtoSocketTransport`. +- [ ] [API-3] Add task channel service tests for success, missing payload, and error envelope mapping. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [API-1] Task Models + +### 문제 + +The workspace domain model only has `ProjectWorkspace` and a mock count at `apps/client/lib/src/features/workspaces/domain/project_workspace.dart:3-18`. The Core task channel returns full task maps described in `packages/contracts/notes/flutter-core-api-candidates.md:97-108`. + +### 해결 방법 + +Add `apps/client/lib/src/features/workspaces/domain/workspace_task.dart`. + +Before (`apps/client/lib/src/features/workspaces/domain/project_workspace.dart:3-18`): + +```dart +class ProjectWorkspace { + final String id; + final String name; + final String description; + final ProjectStatus status; + final String codeServerUrl; + final int tasksCount; +} +``` + +After: + +```dart +class WorkspaceTask { + final String id; + final String title; + final String status; + final String source; + final String? error; + + const WorkspaceTask({ + required this.id, + required this.title, + required this.status, + required this.source, + this.error, + }); + + factory WorkspaceTask.fromMap(Map map) { + return WorkspaceTask( + id: map['id'] as String? ?? '', + title: map['title'] as String? ?? 'Untitled task', + status: map['status'] as String? ?? 'unknown', + source: map['source'] as String? ?? '', + error: map['error'] as String?, + ); + } +} +``` + +### 수정 파일 및 체크리스트 + +- [ ] Add `apps/client/lib/src/features/workspaces/domain/workspace_task.dart`. +- [ ] Keep model tolerant of missing optional fields returned by partial task maps. + +### 테스트 작성 + +Cover `WorkspaceTask.fromMap` in the service test file, using a full map and a minimal map. + +### 중간 검증 + +```bash +cd apps/client && flutter test test/integrations/proto_socket_task_service_test.dart +``` + +Expected: task model tests pass. + +## [API-2] Task Channel Service + +### 문제 + +Core registers task actions at `services/core/internal/protosocket/tasks.go:32-36`, but Flutter has no client API for sending those actions through the transport. + +### 해결 방법 + +Add `apps/client/lib/src/integrations/proto_socket/proto_socket_task_service.dart`. + +Before (`services/core/internal/protosocket/tasks.go:32-36`): + +```go +dispatcher.Register("task.create", c.handleCreate) +dispatcher.Register("task.list", c.handleList) +dispatcher.Register("task.get", c.handleGet) +dispatcher.Register("task.enqueue", c.handleEnqueue) +``` + +After: + +```dart +class ProtoSocketTaskService { + final ProtoSocketTransport _transport; + + ProtoSocketTaskService(this._transport); + + Future> listTasks({int limit = 20}) async { + final response = await _transport.sendRequest( + ProtoSocketEnvelope( + id: _newMessageId(), + type: 'request', + channel: 'task', + action: 'task.list', + payload: {'limit': limit}, + ), + ); + _throwIfError(response); + final rawTasks = response.payload['tasks']; + if (rawTasks is! List) return const []; + return rawTasks + .whereType() + .map((item) => WorkspaceTask.fromMap(Map.from(item))) + .toList(growable: false); + } +} +``` + +Also add `getTask`, `enqueueTask`, and `createTask` only if their signatures stay thin wrappers around the implemented Core actions. Do not invent workspace/project metadata APIs. + +### 수정 파일 및 체크리스트 + +- [ ] Add `apps/client/lib/src/integrations/proto_socket/proto_socket_task_service.dart`. +- [ ] Add `ProtoSocketTaskException` with `code`, `message`, and `retryable`. +- [ ] Use the Core action names exactly: `task.list`, `task.get`, `task.enqueue`, `task.create`. + +### 테스트 작성 + +Write fake transport tests for: + +- `listTasks` sends `{limit}` and parses `tasks`. +- `enqueueTask` returns the updated task from `payload.task`. +- error envelope `task.not_found` throws `ProtoSocketTaskException`. +- malformed success payload returns empty list or throws a service exception consistently; choose one and document in test name. + +### 중간 검증 + +```bash +cd apps/client && flutter test test/integrations/proto_socket_task_service_test.dart +``` + +Expected: all task service tests pass. + +## [API-3] Service Test Coverage + +### 문제 + +Without service-level tests, later UI work could silently depend on mock data or incorrectly shaped envelopes. + +### 해결 방법 + +Add `apps/client/test/integrations/proto_socket_task_service_test.dart` with a fake `ProtoSocketTransport` that records requests and returns queued `ProtoSocketEnvelope` responses. Do not use network sockets. + +### 수정 파일 및 체크리스트 + +- [ ] Add fake transport fixture. +- [ ] Assert outgoing envelope `type`, `channel`, `action`, and `payload`. +- [ ] Assert error envelope mapping includes `retryable`. + +### 테스트 작성 + +This item is the test implementation for API-1 and API-2. + +### 중간 검증 + +```bash +cd apps/client && flutter test test/integrations/proto_socket_task_service_test.dart +``` + +Expected: all task service tests pass. + +## 의존 관계 및 구현 순서 + +Directory dependency `02+01_task_service` means implementation must start only after `agent-task/m-proto-socket-infrastructure-communication-rail/01_client_transport/complete.log` exists. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/client/lib/src/features/workspaces/domain/workspace_task.dart` | API-1 | +| `apps/client/lib/src/integrations/proto_socket/proto_socket_task_service.dart` | API-2 | +| `apps/client/test/integrations/proto_socket_task_service_test.dart` | API-1, API-2, API-3 | + +## 최종 검증 + +```bash +test -f agent-task/m-proto-socket-infrastructure-communication-rail/01_client_transport/complete.log +cd apps/client && flutter test test/integrations/proto_socket_task_service_test.dart +cd apps/client && flutter analyze --no-fatal-infos +``` + +Expected: predecessor complete.log exists; task service tests pass; analyzer has no fatal issues. Cached Flutter test output is acceptable only if no implementation files changed after the run. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration/CODE_REVIEW-local-G05.md b/agent-task/m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration/CODE_REVIEW-local-G05.md new file mode 100644 index 0000000..8510a62 --- /dev/null +++ b/agent-task/m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration/CODE_REVIEW-local-G05.md @@ -0,0 +1,150 @@ + + +# Code Review Reference - API + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> 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-05-30 +task=m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration, plan=0, tag=API + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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-proto-socket-infrastructure-communication-rail/03+02_workspace_integration/`로 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [API-1] App Lifecycle Wiring | [ ] | +| [API-2] Workspace Task Rendering | [ ] | +| [API-3] Widget Test Coverage | [ ] | + +## 구현 체크리스트 + +- [ ] [API-1] Wire `NomadCodeClientApp` and home state to connect proto-socket only when config is non-null and enabled. +- [ ] [API-2] Update `WorkspaceHomePage` to accept a task loader/service boundary and render loaded `WorkspaceTask` rows instead of generated mock task rows when service data is available. +- [ ] [API-3] Add widget tests for task service rendering, empty state, failed load state, and existing launcher callback. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. +- [ ] 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/03+02_workspace_integration/`를 `agent-task/archive/YYYY/MM/m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-proto-socket-infrastructure-communication-rail/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Predecessor `02+01_task_service/complete.log` exists before implementation. +- App connects only when proto-socket config is present and enabled. +- Workspace page still works with mock fallback when no task loader exists. +- Widget tests cover loaded, empty, and failed task states. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### API-1 중간 검증 +```bash +$ cd apps/client && flutter test test/widget_test.dart +(output) +``` + +### API-2 중간 검증 +```bash +$ cd apps/client && flutter test test/widget_test.dart +(output) +``` + +### API-3 중간 검증 +```bash +$ cd apps/client && flutter test test/widget_test.dart +(output) +``` + +### 최종 검증 +```bash +$ test -f agent-task/m-proto-socket-infrastructure-communication-rail/02+01_task_service/complete.log +(output) +$ cd apps/client && flutter test test/widget_test.dart +(output) +$ cd apps/client && flutter test +(output) +$ cd apps/client && flutter analyze --no-fatal-infos +(output) +``` + +--- + +> **[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. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only | +| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus only | +| 검증 결과 | Implementing agent | Fill command output only | +| 코드리뷰 결과 | Review agent appends | Not included in stub | diff --git a/agent-task/m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration/PLAN-local-G05.md b/agent-task/m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration/PLAN-local-G05.md new file mode 100644 index 0000000..3bc07ed --- /dev/null +++ b/agent-task/m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration/PLAN-local-G05.md @@ -0,0 +1,228 @@ + + +# Plan - API Workspace Task Integration + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan의 구현이 끝나면 active `CODE_REVIEW-*-G??.md`에서 구현 에이전트 소유 섹션을 실제 구현 내용, 검증 출력, 계획 대비 변경 사항으로 채워야 한다. 최종 archive, `complete.log`, 리뷰 판정은 code-review-skill 전용이다. 구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret 준비, 또는 안전한 진행을 막는 scope 충돌이 있으면 review stub의 `사용자 리뷰 요청` 섹션에 정확한 결정과 근거를 남기고 멈춘다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 메울 수 있는 검증 공백만으로는 사용자 리뷰 요청을 만들지 않는다. + +## 배경 + +Workspace UI still renders mock task rows from `tasksCount`. After the transport and task service exist, the app should consume Core proto-socket task channel through a thin service boundary while keeping mock fallback for disabled config and widget tests. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. code-review가 해당 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/workflow-core/PHASE.md` +- `agent-roadmap/phase/workflow-core/milestones/proto-socket-infrastructure-communication-rail.md` +- `agent-ops/rules/project/domain/mobile/rules.md` +- `agent-test/local/mobile-smoke.md` +- `apps/client/lib/src/app/bootstrap.dart` +- `apps/client/lib/src/app/nomadcode_client_app.dart` +- `apps/client/lib/src/features/workspaces/domain/project_workspace.dart` +- `apps/client/lib/src/features/workspaces/presentation/workspace_home_page.dart` +- `apps/client/test/widget_test.dart` + +### 테스트 커버리지 공백 + +- Existing widget tests assert mock workspace names and launcher callback only. +- No widget test covers task loading, empty task state from a service, failed task load, or enabled proto-socket lifecycle connection. + +### 심볼 참조 + +- `WorkspaceHomePage`: constructed in `apps/client/lib/src/app/nomadcode_client_app.dart:93-96` and `apps/client/test/widget_test.dart:23-31`; update both call sites if constructor adds service dependencies. +- `mockProjectWorkspaces`: consumed in `apps/client/lib/src/features/workspaces/presentation/workspace_home_page.dart:20-21` and `146-150`; keep as default fallback or move behind a repository/fallback argument. + +### 분할 판단 + +Split policy was evaluated before writing files. This subtask depends on `02+01_task_service` because UI should consume `ProtoSocketTaskService` and `WorkspaceTask`. It is separate from service parsing so UI state regressions can be reviewed with widget tests. + +### 범위 결정 근거 + +This task does not change transport internals, task channel parsing, Core Go code, platform runner files, Firebase, or Mattermost notification behavior. It only wires app/page state to the service boundary and adds widget tests. + +### 빌드 등급 + +Build lane `local-G05`, review lane `cloud-G05`: mostly Flutter state/widget integration with existing testable boundaries; review remains cloud because it depends on prior protocol subtasks. + +## 구현 체크리스트 + +- [ ] [API-1] Wire `NomadCodeClientApp` and home state to connect proto-socket only when config is non-null and enabled. +- [ ] [API-2] Update `WorkspaceHomePage` to accept a task loader/service boundary and render loaded `WorkspaceTask` rows instead of generated mock task rows when service data is available. +- [ ] [API-3] Add widget tests for task service rendering, empty state, failed load state, and existing launcher callback. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [API-1] App Lifecycle Wiring + +### 문제 + +`NomadCodeClientApp` receives `protoSocketLifecycle` and `protoSocketConfig` at `apps/client/lib/src/app/nomadcode_client_app.dart:15-23`, but `_NomadCodeClientHome` drops them at `apps/client/lib/src/app/nomadcode_client_app.dart:38` and never connects. + +### 해결 방법 + +Pass lifecycle/config into `_NomadCodeClientHome`, connect only when `config.enabled`, and create task service from `lifecycle.transport` after connection. + +Before (`apps/client/lib/src/app/nomadcode_client_app.dart:28-39`): + +```dart +return MaterialApp( + title: 'nomadcode-app', + navigatorKey: navigatorKey, + theme: ThemeData(...), + home: _NomadCodeClientHome(mattermostHost: mattermostHost), +); +``` + +After: + +```dart +return MaterialApp( + title: 'nomadcode-app', + navigatorKey: navigatorKey, + theme: ThemeData(...), + home: _NomadCodeClientHome( + mattermostHost: mattermostHost, + protoSocketLifecycle: protoSocketLifecycle, + protoSocketConfig: protoSocketConfig, + ), +); +``` + +In `initState`, call an async `_connectProtoSocketIfEnabled()` that catches errors and leaves the page usable with mock fallback. + +### 수정 파일 및 체크리스트 + +- [ ] Update `apps/client/lib/src/app/nomadcode_client_app.dart`. +- [ ] Do not connect when `protoSocketConfig == null` or `enabled == false`. +- [ ] Cancel subscriptions and disconnect lifecycle only if this widget initiated the connection. + +### 테스트 작성 + +Add widget tests with fake lifecycle or injectable task loader. Avoid Firebase/bootstrap tests. + +### 중간 검증 + +```bash +cd apps/client && flutter test test/widget_test.dart +``` + +Expected: widget tests pass. + +## [API-2] Workspace Task Rendering + +### 문제 + +Workspace page uses mock count and generated task labels at `apps/client/lib/src/features/workspaces/presentation/workspace_home_page.dart:356-416`, so it cannot show Core task channel data. + +### 해결 방법 + +Add a task loader dependency to `WorkspaceHomePage`. + +Before (`apps/client/lib/src/features/workspaces/presentation/workspace_home_page.dart:4-7`): + +```dart +class WorkspaceHomePage extends StatefulWidget { + final Future Function(ProjectWorkspace)? onLaunchCodeServer; + + const WorkspaceHomePage({super.key, this.onLaunchCodeServer}); +} +``` + +After: + +```dart +typedef WorkspaceTaskLoader = Future> Function( + ProjectWorkspace project, +); + +class WorkspaceHomePage extends StatefulWidget { + final Future Function(ProjectWorkspace)? onLaunchCodeServer; + final WorkspaceTaskLoader? loadTasks; + + const WorkspaceHomePage({super.key, this.onLaunchCodeServer, this.loadTasks}); +} +``` + +When `loadTasks` is null, keep current mock behavior. When present, load selected project tasks, render title/status from `WorkspaceTask`, and show deterministic loading/error/empty states. + +### 수정 파일 및 체크리스트 + +- [ ] Update `apps/client/lib/src/features/workspaces/presentation/workspace_home_page.dart`. +- [ ] Import `WorkspaceTask`. +- [ ] Preserve current launcher button behavior. +- [ ] Keep text within existing compact rows; do not redesign the page. + +### 테스트 작성 + +Add widget tests for: + +- Service returns two tasks and page renders their titles/statuses. +- Service returns empty list and page renders the existing empty state text or an equivalent task-empty state. +- Service throws and page renders a non-secret error state with retry affordance. + +### 중간 검증 + +```bash +cd apps/client && flutter test test/widget_test.dart +``` + +Expected: widget tests pass. + +## [API-3] Widget Test Coverage + +### 문제 + +`apps/client/test/widget_test.dart:7-42` only covers the dashboard title and launcher callback. + +### 해결 방법 + +Extend `widget_test.dart` with fake task loaders. Keep existing tests updated to pass any new constructor parameters. + +### 수정 파일 및 체크리스트 + +- [ ] Update `apps/client/test/widget_test.dart`. +- [ ] Keep existing launcher callback test green. +- [ ] Assert selected default project still starts with NomadCode Core. + +### 테스트 작성 + +This item is the widget test implementation for API-1 and API-2. + +### 중간 검증 + +```bash +cd apps/client && flutter test test/widget_test.dart +``` + +Expected: widget tests pass. + +## 의존 관계 및 구현 순서 + +Directory dependency `03+02_workspace_integration` means implementation must start only after `agent-task/m-proto-socket-infrastructure-communication-rail/02+01_task_service/complete.log` exists. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/client/lib/src/app/nomadcode_client_app.dart` | API-1 | +| `apps/client/lib/src/features/workspaces/presentation/workspace_home_page.dart` | API-2 | +| `apps/client/test/widget_test.dart` | API-1, API-2, API-3 | + +## 최종 검증 + +```bash +test -f agent-task/m-proto-socket-infrastructure-communication-rail/02+01_task_service/complete.log +cd apps/client && flutter test test/widget_test.dart +cd apps/client && flutter test +cd apps/client && flutter analyze --no-fatal-infos +``` + +Expected: predecessor complete.log exists; widget tests and full Flutter tests pass; analyzer has no fatal issues. Cached Flutter test output is acceptable only if no implementation files changed after the run. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/client/lib/src/app/bootstrap.dart b/apps/client/lib/src/app/bootstrap.dart index bbc3d3b..b63d04f 100644 --- a/apps/client/lib/src/app/bootstrap.dart +++ b/apps/client/lib/src/app/bootstrap.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../integrations/mattermost/mattermost_push_host_integration.dart'; import '../integrations/mattermost/mattermost_push_plugin_client.dart'; +import '../integrations/proto_socket/proto_socket_client.dart'; import '../integrations/proto_socket/proto_socket_endpoint_config.dart'; import '../integrations/proto_socket/proto_socket_lifecycle.dart'; import 'nomadcode_client_app.dart'; @@ -13,11 +14,10 @@ final _mattermostHost = MattermostPushHostIntegration( ); final _navigatorKey = GlobalKey(); -/// proto-socket lifecycle wired with a no-op connector by default. Bootstrap -/// does NOT auto-connect; an explicit configuration switch enables it once a -/// real endpoint contract is wired. +/// proto-socket lifecycle wired with the concrete connector. Bootstrap does +/// NOT auto-connect; an explicit configuration switch owns startup timing. final _protoSocketLifecycle = ProtoSocketLifecycle( - connector: NoopProtoSocketConnector(), + connector: RealProtoSocketConnector(), ); ProtoSocketEndpointConfig? _protoSocketConfig; diff --git a/apps/client/lib/src/integrations/proto_socket/proto_socket_client.dart b/apps/client/lib/src/integrations/proto_socket/proto_socket_client.dart new file mode 100644 index 0000000..99c96a6 --- /dev/null +++ b/apps/client/lib/src/integrations/proto_socket/proto_socket_client.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:protobuf/protobuf.dart'; +import 'package:protobuf/well_known_types/google/protobuf/struct.pb.dart'; +import 'package:proto_socket/proto_socket.dart'; + +import 'proto_socket_endpoint_config.dart'; +import 'proto_socket_envelope.dart'; +import 'proto_socket_lifecycle.dart'; + +typedef ProtoSocketFactory = + Future Function(ProtoSocketEndpointConfig config); + +typedef ProtoSocketTransportFactory = + ProtoSocketTransport Function( + dynamic socket, + ProtoSocketEndpointConfig config, + ); + +abstract interface class NomadProtoSocketPeer { + Future sendEnvelopeRequest( + ProtoSocketEnvelope envelope, { + Duration timeout = const Duration(seconds: 30), + }); + Stream get envelopeEvents; + Future close(); +} + +Map)> protoSocketStructParserMap() { + final qualifiedName = Struct.getDefault().info_.qualifiedMessageName; + return )>{ + (Struct).toString(): Struct.fromBuffer, + qualifiedName: Struct.fromBuffer, + }; +} + +class NomadProtoSocketClient extends WsProtobufClient + implements NomadProtoSocketPeer { + final StreamController _events = + StreamController.broadcast(); + + NomadProtoSocketClient( + dynamic socket, { + required int heartbeatIntervalSeconds, + required int heartbeatWaitSeconds, + }) : super( + socket, + heartbeatIntervalSeconds, + heartbeatWaitSeconds, + protoSocketStructParserMap(), + ) { + addListener(_handleStructEvent); + } + + @override + Stream get envelopeEvents => _events.stream; + + @override + Future sendEnvelopeRequest( + ProtoSocketEnvelope envelope, { + Duration timeout = const Duration(seconds: 30), + }) async { + final response = await sendRequest( + envelope.toStruct(), + timeout: timeout, + ); + return ProtoSocketEnvelope.fromStruct(response); + } + + @override + void onError(dynamic e) { + if (!_events.isClosed) { + _events.addError(e); + } + } + + @override + Future close() async { + try { + await super.close(); + } finally { + if (!_events.isClosed) { + await _events.close(); + } + } + } + + void _handleStructEvent(Struct value) { + if (!_events.isClosed) { + _events.add(ProtoSocketEnvelope.fromStruct(value)); + } + } +} + +class ProtoSocketClientTransport implements ProtoSocketTransport { + final NomadProtoSocketPeer _client; + + ProtoSocketClientTransport(this._client); + + @override + Stream get events => _client.envelopeEvents; + + @override + Future sendRequest( + ProtoSocketEnvelope envelope, { + Duration timeout = const Duration(seconds: 30), + }) { + return _client.sendEnvelopeRequest(envelope, timeout: timeout); + } + + @override + Future close() => _client.close(); +} + +class RealProtoSocketConnector implements ProtoSocketConnector { + final ProtoSocketFactory _socketFactory; + final ProtoSocketTransportFactory _transportFactory; + ProtoSocketTransport? _transport; + + RealProtoSocketConnector({ + ProtoSocketFactory? socketFactory, + ProtoSocketTransportFactory? transportFactory, + }) : _socketFactory = socketFactory ?? _connectSocket, + _transportFactory = transportFactory ?? _createTransport; + + @override + Future connect(ProtoSocketEndpointConfig config) async { + final socket = await _socketFactory(config); + final transport = _transportFactory(socket, config); + _transport = transport; + return transport; + } + + @override + Future disconnect() async { + final transport = _transport; + _transport = null; + await transport?.close(); + } + + static Future _connectSocket(ProtoSocketEndpointConfig config) { + if (config.secure) { + return WsProtobufClient.connectSecure( + config.host, + config.port, + path: config.path, + ); + } + return WsProtobufClient.connect( + config.host, + config.port, + path: config.path, + ); + } + + static ProtoSocketTransport _createTransport( + dynamic socket, + ProtoSocketEndpointConfig config, + ) { + return ProtoSocketClientTransport( + NomadProtoSocketClient( + socket, + heartbeatIntervalSeconds: config.heartbeatIntervalSeconds, + heartbeatWaitSeconds: config.heartbeatWaitSeconds, + ), + ); + } +} diff --git a/apps/client/lib/src/integrations/proto_socket/proto_socket_envelope.dart b/apps/client/lib/src/integrations/proto_socket/proto_socket_envelope.dart new file mode 100644 index 0000000..fdc4306 --- /dev/null +++ b/apps/client/lib/src/integrations/proto_socket/proto_socket_envelope.dart @@ -0,0 +1,181 @@ +import 'package:protobuf/well_known_types/google/protobuf/struct.pb.dart'; + +const protoSocketProtocolVersion = 'nomadcode.proto-socket.v1'; + +class ProtoSocketEnvelope { + final String protocolVersion; + final String id; + final String correlationId; + final String type; + final String channel; + final String action; + final Map actor; + final Map auth; + final Map payload; + final ProtoSocketEnvelopeError? error; + final Map meta; + + const ProtoSocketEnvelope({ + required this.id, + required this.type, + required this.channel, + required this.action, + this.protocolVersion = protoSocketProtocolVersion, + this.correlationId = '', + this.actor = const {}, + this.auth = const {}, + this.payload = const {}, + this.error, + this.meta = const {}, + }); + + factory ProtoSocketEnvelope.fromStruct(Struct value) => + ProtoSocketEnvelope.fromMap(structToMap(value)); + + factory ProtoSocketEnvelope.fromMap(Map value) { + return ProtoSocketEnvelope( + protocolVersion: + _stringValue(value['protocol_version']) ?? protoSocketProtocolVersion, + id: _stringValue(value['id']) ?? '', + correlationId: _stringValue(value['correlation_id']) ?? '', + type: _stringValue(value['type']) ?? '', + channel: _stringValue(value['channel']) ?? '', + action: _stringValue(value['action']) ?? '', + actor: _mapValue(value['actor']), + auth: _mapValue(value['auth']), + payload: _mapValue(value['payload']), + error: ProtoSocketEnvelopeError.fromObject(value['error']), + meta: _mapValue(value['meta']), + ); + } + + Map toMap() { + final value = { + 'protocol_version': protocolVersion, + 'id': id, + 'correlation_id': correlationId, + 'type': type, + 'channel': channel, + 'action': action, + 'payload': payload, + }; + if (actor.isNotEmpty) value['actor'] = actor; + if (auth.isNotEmpty) value['auth'] = auth; + if (error != null) value['error'] = error!.toMap(); + if (meta.isNotEmpty) value['meta'] = meta; + return value; + } + + Struct toStruct() => mapToStruct(toMap()); +} + +class ProtoSocketEnvelopeError { + final String code; + final String message; + final bool retryable; + final Map details; + + const ProtoSocketEnvelopeError({ + required this.code, + required this.message, + required this.retryable, + this.details = const {}, + }); + + factory ProtoSocketEnvelopeError.fromMap(Map value) { + return ProtoSocketEnvelopeError( + code: _stringValue(value['code']) ?? '', + message: _stringValue(value['message']) ?? '', + retryable: _boolValue(value['retryable']) ?? false, + details: _mapValue(value['details']), + ); + } + + static ProtoSocketEnvelopeError? fromObject(Object? value) { + if (value == null) return null; + if (value is Map) { + return ProtoSocketEnvelopeError.fromMap(value); + } + if (value is Map) { + return ProtoSocketEnvelopeError.fromMap( + value.map((key, entry) => MapEntry(key.toString(), entry)), + ); + } + return null; + } + + Map toMap() { + final value = { + 'code': code, + 'message': message, + 'retryable': retryable, + }; + if (details.isNotEmpty) value['details'] = details; + return value; + } +} + +Struct mapToStruct(Map value) => Struct( + fields: value.entries.map( + (entry) => MapEntry(entry.key, valueFromObject(entry.value)), + ), +); + +Map structToMap(Struct value) => + value.fields.map((key, entry) => MapEntry(key, objectFromValue(entry))); + +Value valueFromObject(Object? value) { + if (value == null) return Value(nullValue: NullValue.NULL_VALUE); + if (value is bool) return Value(boolValue: value); + if (value is num) return Value(numberValue: value.toDouble()); + if (value is String) return Value(stringValue: value); + if (value is Map) { + return Value(structValue: mapToStruct(value)); + } + if (value is Map) { + return Value( + structValue: mapToStruct( + value.map((key, entry) => MapEntry(key.toString(), entry)), + ), + ); + } + if (value is Iterable) { + return Value( + listValue: ListValue( + values: value.map((entry) => valueFromObject(entry)), + ), + ); + } + throw ArgumentError.value(value, 'value', 'Unsupported Struct value type'); +} + +Object? objectFromValue(Value value) { + switch (value.whichKind()) { + case Value_Kind.nullValue: + return null; + case Value_Kind.numberValue: + return value.numberValue; + case Value_Kind.stringValue: + return value.stringValue; + case Value_Kind.boolValue: + return value.boolValue; + case Value_Kind.structValue: + return structToMap(value.structValue); + case Value_Kind.listValue: + return value.listValue.values.map(objectFromValue).toList(); + case Value_Kind.notSet: + return null; + } +} + +String? _stringValue(Object? value) => value is String ? value : null; + +bool? _boolValue(Object? value) => value is bool ? value : null; + +Map _mapValue(Object? value) { + if (value is Map) return value; + if (value is Map) { + return value.map((key, entry) => MapEntry(key.toString(), entry)); + } + return const {}; +} diff --git a/apps/client/lib/src/integrations/proto_socket/proto_socket_lifecycle.dart b/apps/client/lib/src/integrations/proto_socket/proto_socket_lifecycle.dart index 8c9a764..6df6fe0 100644 --- a/apps/client/lib/src/integrations/proto_socket/proto_socket_lifecycle.dart +++ b/apps/client/lib/src/integrations/proto_socket/proto_socket_lifecycle.dart @@ -1,14 +1,24 @@ import 'dart:async'; import 'proto_socket_endpoint_config.dart'; +import 'proto_socket_envelope.dart'; enum ProtoSocketConnectionState { disconnected, connecting, connected, failed } +abstract interface class ProtoSocketTransport { + Future sendRequest( + ProtoSocketEnvelope envelope, { + Duration timeout = const Duration(seconds: 30), + }); + Stream get events; + Future close(); +} + /// Pluggable connector. Production wires a real proto-socket client; tests /// inject a fake to drive lifecycle transitions deterministically without /// opening network sockets. abstract interface class ProtoSocketConnector { - Future connect(ProtoSocketEndpointConfig config); + Future connect(ProtoSocketEndpointConfig config); Future disconnect(); } @@ -25,6 +35,7 @@ class ProtoSocketLifecycle { ProtoSocketConnectionState _state = ProtoSocketConnectionState.disconnected; Object? _lastError; + ProtoSocketTransport? _transport; ProtoSocketLifecycle({required ProtoSocketConnector connector}) : _connector = connector; @@ -32,6 +43,7 @@ class ProtoSocketLifecycle { ProtoSocketConnectionState get state => _state; Stream get stateStream => _stateController.stream; Object? get lastError => _lastError; + ProtoSocketTransport? get transport => _transport; Future connect(ProtoSocketEndpointConfig config) async { if (_state == ProtoSocketConnectionState.connecting || @@ -40,9 +52,12 @@ class ProtoSocketLifecycle { } _emit(ProtoSocketConnectionState.connecting); try { - await _connector.connect(config); + final transport = await _connector.connect(config); + _transport = transport; + _lastError = null; _emit(ProtoSocketConnectionState.connected); } catch (e) { + _transport = null; _lastError = e; _emit(ProtoSocketConnectionState.failed); rethrow; @@ -52,6 +67,7 @@ class ProtoSocketLifecycle { Future disconnect() async { if (_state == ProtoSocketConnectionState.disconnected) return; await _connector.disconnect(); + _transport = null; _emit(ProtoSocketConnectionState.disconnected); } @@ -67,14 +83,32 @@ class ProtoSocketLifecycle { } } -/// Default production connector. Constructs only when proto-socket is enabled. -/// Currently a no-op placeholder: bootstrap does not auto-open a real socket -/// by default, and a concrete transport will be wired in a later milestone -/// once the core endpoint contract stabilizes. +/// Test/fallback connector for disabled or fake scenarios. class NoopProtoSocketConnector implements ProtoSocketConnector { @override - Future connect(ProtoSocketEndpointConfig config) async {} + Future connect( + ProtoSocketEndpointConfig config, + ) async => _NoopProtoSocketTransport(); @override Future disconnect() async {} } + +class _NoopProtoSocketTransport implements ProtoSocketTransport { + final StreamController _events = + StreamController.broadcast(); + + @override + Stream get events => _events.stream; + + @override + Future sendRequest( + ProtoSocketEnvelope envelope, { + Duration timeout = const Duration(seconds: 30), + }) { + return Future.error(StateError('proto-socket transport is not connected')); + } + + @override + Future close() => _events.close(); +} diff --git a/apps/client/pubspec.lock b/apps/client/pubspec.lock index b11ba72..6e64f4a 100644 --- a/apps/client/pubspec.lock +++ b/apps/client/pubspec.lock @@ -212,10 +212,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" nexo_messaging: dependency: "direct main" description: @@ -247,7 +247,7 @@ packages: source: path version: "1.0.5" protobuf: - dependency: transitive + dependency: "direct main" description: name: protobuf sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06" @@ -303,10 +303,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml index f19e123..3b9fddf 100644 --- a/apps/client/pubspec.yaml +++ b/apps/client/pubspec.yaml @@ -47,6 +47,8 @@ dependencies: proto_socket: path: ../../../proto-socket/dart + protobuf: ^6.0.0 + # HTTP client http: ^1.3.0 diff --git a/apps/client/test/integrations/proto_socket_envelope_test.dart b/apps/client/test/integrations/proto_socket_envelope_test.dart new file mode 100644 index 0000000..5c0eba8 --- /dev/null +++ b/apps/client/test/integrations/proto_socket_envelope_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_envelope.dart'; + +void main() { + test('ProtoSocketEnvelope round-trips through google.protobuf.Struct', () { + const envelope = ProtoSocketEnvelope( + id: 'msg-1', + correlationId: 'corr-1', + type: 'request', + channel: 'task', + action: 'task.list', + actor: {'kind': 'client', 'id': 'apps/client'}, + auth: {'scheme': 'basic', 'token_ref': 'runtime-secret-ref'}, + payload: { + 'limit': 20, + 'include_archived': false, + 'tags': ['core', 'client'], + 'filter': {'source': 'manual', 'empty': null}, + }, + meta: {'connection_id': 'conn-1'}, + ); + + final roundTrip = ProtoSocketEnvelope.fromStruct(envelope.toStruct()); + + expect(roundTrip.protocolVersion, equals(protoSocketProtocolVersion)); + expect(roundTrip.id, equals('msg-1')); + expect(roundTrip.correlationId, equals('corr-1')); + expect(roundTrip.type, equals('request')); + expect(roundTrip.channel, equals('task')); + expect(roundTrip.action, equals('task.list')); + expect(roundTrip.actor, equals(envelope.actor)); + expect(roundTrip.auth, equals(envelope.auth)); + expect(roundTrip.payload['limit'], equals(20.0)); + expect(roundTrip.payload['include_archived'], isFalse); + expect(roundTrip.payload['tags'], equals(['core', 'client'])); + expect( + roundTrip.payload['filter'], + equals({'source': 'manual', 'empty': null}), + ); + expect(roundTrip.meta, equals(envelope.meta)); + }); + + test('error envelope preserves retryable and details', () { + const envelope = ProtoSocketEnvelope( + id: 'err-1', + correlationId: 'req-1', + type: 'error', + channel: 'task', + action: 'task.get', + error: ProtoSocketEnvelopeError( + code: 'task.not_found', + message: 'task not found', + retryable: false, + details: {'id': 'task-404', 'attempts': 2}, + ), + ); + + final roundTrip = ProtoSocketEnvelope.fromStruct(envelope.toStruct()); + + expect(roundTrip.error, isNotNull); + expect(roundTrip.error!.code, equals('task.not_found')); + expect(roundTrip.error!.message, equals('task not found')); + expect(roundTrip.error!.retryable, isFalse); + expect( + roundTrip.error!.details, + equals({'id': 'task-404', 'attempts': 2.0}), + ); + }); +} diff --git a/apps/client/test/integrations/proto_socket_lifecycle_test.dart b/apps/client/test/integrations/proto_socket_lifecycle_test.dart index 1f93d1f..60ea6e9 100644 --- a/apps/client/test/integrations/proto_socket_lifecycle_test.dart +++ b/apps/client/test/integrations/proto_socket_lifecycle_test.dart @@ -1,17 +1,50 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_client.dart'; +import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_envelope.dart'; import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_endpoint_config.dart'; import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_lifecycle.dart'; +import 'package:protobuf/well_known_types/google/protobuf/struct.pb.dart'; + +class _FakeTransport implements ProtoSocketTransport { + final StreamController controller = + StreamController.broadcast(); + int sendCalls = 0; + int closeCalls = 0; + ProtoSocketEnvelope? response; + + @override + Stream get events => controller.stream; + + @override + Future sendRequest( + ProtoSocketEnvelope envelope, { + Duration timeout = const Duration(seconds: 30), + }) async { + sendCalls += 1; + return response ?? envelope; + } + + @override + Future close() async { + closeCalls += 1; + await controller.close(); + } +} class _FakeConnector implements ProtoSocketConnector { int connectCalls = 0; int disconnectCalls = 0; Object? throwOnConnect; + _FakeTransport transport = _FakeTransport(); @override - Future connect(ProtoSocketEndpointConfig config) async { + Future connect(ProtoSocketEndpointConfig config) async { connectCalls += 1; final err = throwOnConnect; if (err != null) throw err; + return transport; } @override @@ -20,6 +53,40 @@ class _FakeConnector implements ProtoSocketConnector { } } +class _FakePeer implements NomadProtoSocketPeer { + final StreamController controller = + StreamController.broadcast(); + ProtoSocketEnvelope? lastRequest; + Duration? lastTimeout; + int closeCalls = 0; + + @override + Stream get envelopeEvents => controller.stream; + + @override + Future sendEnvelopeRequest( + ProtoSocketEnvelope envelope, { + Duration timeout = const Duration(seconds: 30), + }) async { + lastRequest = envelope; + lastTimeout = timeout; + return ProtoSocketEnvelope( + id: envelope.id, + correlationId: envelope.correlationId, + type: 'response', + channel: envelope.channel, + action: envelope.action, + payload: const {'ok': true}, + ); + } + + @override + Future close() async { + closeCalls += 1; + await controller.close(); + } +} + void main() { const config = ProtoSocketEndpointConfig( host: 'core.example.com', @@ -37,10 +104,12 @@ void main() { await lifecycle.connect(config); expect(lifecycle.state, ProtoSocketConnectionState.connected); + expect(lifecycle.transport, same(fake.transport)); expect(fake.connectCalls, equals(1)); await lifecycle.disconnect(); expect(lifecycle.state, ProtoSocketConnectionState.disconnected); + expect(lifecycle.transport, isNull); expect(fake.disconnectCalls, equals(1)); await Future.delayed(Duration.zero); @@ -61,6 +130,7 @@ void main() { await expectLater(lifecycle.connect(config), throwsA(isA())); expect(lifecycle.state, ProtoSocketConnectionState.failed); expect(lifecycle.lastError, isA()); + expect(lifecycle.transport, isNull); await lifecycle.dispose(); }); @@ -86,4 +156,77 @@ void main() { await lifecycle.dispose(); }); + + test( + 'RealProtoSocketConnector uses injected factories without sockets', + () async { + final fakeSocket = Object(); + final fakeTransport = _FakeTransport(); + ProtoSocketEndpointConfig? seenConfig; + Object? seenSocket; + final connector = RealProtoSocketConnector( + socketFactory: (config) async { + seenConfig = config; + return fakeSocket; + }, + transportFactory: (socket, config) { + seenSocket = socket; + expect(config, same(seenConfig)); + return fakeTransport; + }, + ); + + final transport = await connector.connect(config); + expect(seenConfig, same(config)); + expect(seenSocket, same(fakeSocket)); + expect(transport, same(fakeTransport)); + + await connector.disconnect(); + expect(fakeTransport.closeCalls, equals(1)); + }, + ); + + test('Struct parser map supports short and qualified names', () { + final parsers = protoSocketStructParserMap(); + final qualifiedName = Struct.getDefault().info_.qualifiedMessageName; + final struct = ProtoSocketEnvelope( + id: 'msg-1', + type: 'event', + channel: 'task', + action: 'task.status.changed', + payload: const {'id': 'task-1'}, + ).toStruct(); + + expect(parsers[(Struct).toString()], isNotNull); + expect(parsers[qualifiedName], isNotNull); + expect( + parsers[(Struct).toString()]!(struct.writeToBuffer()), + isA(), + ); + expect(parsers[qualifiedName]!(struct.writeToBuffer()), isA()); + }); + + test('ProtoSocketClientTransport delegates requests and close', () async { + final peer = _FakePeer(); + final transport = ProtoSocketClientTransport(peer); + const request = ProtoSocketEnvelope( + id: 'req-1', + type: 'request', + channel: 'task', + action: 'task.list', + ); + + final response = await transport.sendRequest( + request, + timeout: const Duration(seconds: 5), + ); + + expect(peer.lastRequest, same(request)); + expect(peer.lastTimeout, equals(const Duration(seconds: 5))); + expect(response.type, equals('response')); + expect(response.payload, equals({'ok': true})); + + await transport.close(); + expect(peer.closeCalls, equals(1)); + }); } diff --git a/packages/contracts/notes/flutter-core-api-candidates.md b/packages/contracts/notes/flutter-core-api-candidates.md index 281a03e..6b51ef7 100644 --- a/packages/contracts/notes/flutter-core-api-candidates.md +++ b/packages/contracts/notes/flutter-core-api-candidates.md @@ -120,6 +120,14 @@ | `internal.error` | 그 외 내부 오류 | true | 500 | | `UNSUPPORTED_ACTION` | dispatcher에 등록되지 않은 action | false | (REST 없음) | +### 1.6 새 내부 통신 추가 운영 체크 +- 신규 `apps/client` ↔ `services/core` 또는 내부 runtime 간 통신은 먼저 proto-socket channel/action으로 표현 가능한지 확인한다. +- REST/HTTP를 새로 추가하거나 유지해야 하면 health/readiness, 외부 provider 호환 API, 운영 smoke/curl, public callback, 단순 정적 조회 중 어느 예외인지 이 문서 또는 관련 domain rule에 기록한다. +- 새 channel/action은 envelope의 `protocol_version`, `type`, `channel`, `action`, `correlation_id`, `payload`, `error.retryable` 의미를 이 문서에 추가한다. +- 클라이언트가 소비하는 request/response/event shape는 구현 전에 후보 표와 테스트 fixture 기준을 남긴다. +- Core 구현은 기존 REST smoke/compat 표면을 제거하지 않고, 내부 UI/런타임 호출만 proto-socket 우선 경로로 전환한다. +- 검증은 최소한 channel/action dispatch, auth boundary, error envelope, event broadcast 또는 reconnect 영향 중 해당되는 항목을 테스트로 고정한다. + --- ## 2. 공통 및 인프라 API