feat: proto socket infrastructure communication rail implementation
- Add proto socket client and envelope implementations - Add proto socket lifecycle integration tests - Add task service PLAN and CODE_REVIEW documents - Add workspace integration PLAN and CODE_REVIEW documents - Update bootstrap with proto socket integration - Update pubspec dependencies and lock file - Update Flutter core API candidates documentation - Update workflow core milestone tracking
This commit is contained in:
parent
5fd9cfeb02
commit
88674917de
14 changed files with 1411 additions and 22 deletions
|
|
@ -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 문서화, 검증 항목을 확인하는 운영 체크를 추가했다.
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
<!-- task=m-proto-socket-infrastructure-communication-rail/02+01_task_service plan=0 tag=API -->
|
||||
|
||||
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 |
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
<!-- task=m-proto-socket-infrastructure-communication-rail/02+01_task_service plan=0 tag=API -->
|
||||
|
||||
# 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<String, Object?> 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<List<WorkspaceTask>> 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>()
|
||||
.map((item) => WorkspaceTask.fromMap(Map<String, Object?>.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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<!-- task=m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration plan=0 tag=API -->
|
||||
|
||||
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 |
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
<!-- task=m-proto-socket-infrastructure-communication-rail/03+02_workspace_integration plan=0 tag=API -->
|
||||
|
||||
# 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<bool> Function(ProjectWorkspace)? onLaunchCodeServer;
|
||||
|
||||
const WorkspaceHomePage({super.key, this.onLaunchCodeServer});
|
||||
}
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```dart
|
||||
typedef WorkspaceTaskLoader = Future<List<WorkspaceTask>> Function(
|
||||
ProjectWorkspace project,
|
||||
);
|
||||
|
||||
class WorkspaceHomePage extends StatefulWidget {
|
||||
final Future<bool> 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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<NavigatorState>();
|
||||
|
||||
/// 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<dynamic> Function(ProtoSocketEndpointConfig config);
|
||||
|
||||
typedef ProtoSocketTransportFactory =
|
||||
ProtoSocketTransport Function(
|
||||
dynamic socket,
|
||||
ProtoSocketEndpointConfig config,
|
||||
);
|
||||
|
||||
abstract interface class NomadProtoSocketPeer {
|
||||
Future<ProtoSocketEnvelope> sendEnvelopeRequest(
|
||||
ProtoSocketEnvelope envelope, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
});
|
||||
Stream<ProtoSocketEnvelope> get envelopeEvents;
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
Map<String, GeneratedMessage Function(List<int>)> protoSocketStructParserMap() {
|
||||
final qualifiedName = Struct.getDefault().info_.qualifiedMessageName;
|
||||
return <String, GeneratedMessage Function(List<int>)>{
|
||||
(Struct).toString(): Struct.fromBuffer,
|
||||
qualifiedName: Struct.fromBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
class NomadProtoSocketClient extends WsProtobufClient
|
||||
implements NomadProtoSocketPeer {
|
||||
final StreamController<ProtoSocketEnvelope> _events =
|
||||
StreamController<ProtoSocketEnvelope>.broadcast();
|
||||
|
||||
NomadProtoSocketClient(
|
||||
dynamic socket, {
|
||||
required int heartbeatIntervalSeconds,
|
||||
required int heartbeatWaitSeconds,
|
||||
}) : super(
|
||||
socket,
|
||||
heartbeatIntervalSeconds,
|
||||
heartbeatWaitSeconds,
|
||||
protoSocketStructParserMap(),
|
||||
) {
|
||||
addListener<Struct>(_handleStructEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<ProtoSocketEnvelope> get envelopeEvents => _events.stream;
|
||||
|
||||
@override
|
||||
Future<ProtoSocketEnvelope> sendEnvelopeRequest(
|
||||
ProtoSocketEnvelope envelope, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
final response = await sendRequest<Struct, Struct>(
|
||||
envelope.toStruct(),
|
||||
timeout: timeout,
|
||||
);
|
||||
return ProtoSocketEnvelope.fromStruct(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(dynamic e) {
|
||||
if (!_events.isClosed) {
|
||||
_events.addError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<ProtoSocketEnvelope> get events => _client.envelopeEvents;
|
||||
|
||||
@override
|
||||
Future<ProtoSocketEnvelope> sendRequest(
|
||||
ProtoSocketEnvelope envelope, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) {
|
||||
return _client.sendEnvelopeRequest(envelope, timeout: timeout);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<ProtoSocketTransport> connect(ProtoSocketEndpointConfig config) async {
|
||||
final socket = await _socketFactory(config);
|
||||
final transport = _transportFactory(socket, config);
|
||||
_transport = transport;
|
||||
return transport;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
final transport = _transport;
|
||||
_transport = null;
|
||||
await transport?.close();
|
||||
}
|
||||
|
||||
static Future<dynamic> _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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Object?> actor;
|
||||
final Map<String, Object?> auth;
|
||||
final Map<String, Object?> payload;
|
||||
final ProtoSocketEnvelopeError? error;
|
||||
final Map<String, Object?> 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<String, Object?> 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<String, Object?> toMap() {
|
||||
final value = <String, Object?>{
|
||||
'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<String, Object?> details;
|
||||
|
||||
const ProtoSocketEnvelopeError({
|
||||
required this.code,
|
||||
required this.message,
|
||||
required this.retryable,
|
||||
this.details = const {},
|
||||
});
|
||||
|
||||
factory ProtoSocketEnvelopeError.fromMap(Map<String, Object?> 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<String, Object?>) {
|
||||
return ProtoSocketEnvelopeError.fromMap(value);
|
||||
}
|
||||
if (value is Map) {
|
||||
return ProtoSocketEnvelopeError.fromMap(
|
||||
value.map((key, entry) => MapEntry(key.toString(), entry)),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object?> toMap() {
|
||||
final value = <String, Object?>{
|
||||
'code': code,
|
||||
'message': message,
|
||||
'retryable': retryable,
|
||||
};
|
||||
if (details.isNotEmpty) value['details'] = details;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
Struct mapToStruct(Map<String, Object?> value) => Struct(
|
||||
fields: value.entries.map(
|
||||
(entry) => MapEntry(entry.key, valueFromObject(entry.value)),
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, Object?> 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<String, Object?>) {
|
||||
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<String, Object?> _mapValue(Object? value) {
|
||||
if (value is Map<String, Object?>) return value;
|
||||
if (value is Map) {
|
||||
return value.map((key, entry) => MapEntry(key.toString(), entry));
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
|
@ -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<ProtoSocketEnvelope> sendRequest(
|
||||
ProtoSocketEnvelope envelope, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
});
|
||||
Stream<ProtoSocketEnvelope> get events;
|
||||
Future<void> 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<void> connect(ProtoSocketEndpointConfig config);
|
||||
Future<ProtoSocketTransport> connect(ProtoSocketEndpointConfig config);
|
||||
Future<void> 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<ProtoSocketConnectionState> get stateStream => _stateController.stream;
|
||||
Object? get lastError => _lastError;
|
||||
ProtoSocketTransport? get transport => _transport;
|
||||
|
||||
Future<void> 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<void> 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<void> connect(ProtoSocketEndpointConfig config) async {}
|
||||
Future<ProtoSocketTransport> connect(
|
||||
ProtoSocketEndpointConfig config,
|
||||
) async => _NoopProtoSocketTransport();
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {}
|
||||
}
|
||||
|
||||
class _NoopProtoSocketTransport implements ProtoSocketTransport {
|
||||
final StreamController<ProtoSocketEnvelope> _events =
|
||||
StreamController<ProtoSocketEnvelope>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<ProtoSocketEnvelope> get events => _events.stream;
|
||||
|
||||
@override
|
||||
Future<ProtoSocketEnvelope> sendRequest(
|
||||
ProtoSocketEnvelope envelope, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) {
|
||||
return Future.error(StateError('proto-socket transport is not connected'));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => _events.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ dependencies:
|
|||
proto_socket:
|
||||
path: ../../../proto-socket/dart
|
||||
|
||||
protobuf: ^6.0.0
|
||||
|
||||
# HTTP client
|
||||
http: ^1.3.0
|
||||
|
||||
|
|
|
|||
|
|
@ -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}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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<ProtoSocketEnvelope> controller =
|
||||
StreamController<ProtoSocketEnvelope>.broadcast();
|
||||
int sendCalls = 0;
|
||||
int closeCalls = 0;
|
||||
ProtoSocketEnvelope? response;
|
||||
|
||||
@override
|
||||
Stream<ProtoSocketEnvelope> get events => controller.stream;
|
||||
|
||||
@override
|
||||
Future<ProtoSocketEnvelope> sendRequest(
|
||||
ProtoSocketEnvelope envelope, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
sendCalls += 1;
|
||||
return response ?? envelope;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
closeCalls += 1;
|
||||
await controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeConnector implements ProtoSocketConnector {
|
||||
int connectCalls = 0;
|
||||
int disconnectCalls = 0;
|
||||
Object? throwOnConnect;
|
||||
_FakeTransport transport = _FakeTransport();
|
||||
|
||||
@override
|
||||
Future<void> connect(ProtoSocketEndpointConfig config) async {
|
||||
Future<ProtoSocketTransport> 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<ProtoSocketEnvelope> controller =
|
||||
StreamController<ProtoSocketEnvelope>.broadcast();
|
||||
ProtoSocketEnvelope? lastRequest;
|
||||
Duration? lastTimeout;
|
||||
int closeCalls = 0;
|
||||
|
||||
@override
|
||||
Stream<ProtoSocketEnvelope> get envelopeEvents => controller.stream;
|
||||
|
||||
@override
|
||||
Future<ProtoSocketEnvelope> 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<void> 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<void>.delayed(Duration.zero);
|
||||
|
|
@ -61,6 +130,7 @@ void main() {
|
|||
await expectLater(lifecycle.connect(config), throwsA(isA<StateError>()));
|
||||
expect(lifecycle.state, ProtoSocketConnectionState.failed);
|
||||
expect(lifecycle.lastError, isA<StateError>());
|
||||
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<Struct>(),
|
||||
);
|
||||
expect(parsers[qualifiedName]!(struct.writeToBuffer()), isA<Struct>());
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue