From a8a0f691cf4743fadb5954fcf6fee80b427622a1 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 30 May 2026 18:34:50 +0900 Subject: [PATCH] feat: API-centered proto socket rail milestone completion - Add socket handler and server implementations for real-time communication - Add Mattermost integration (auth service, push client, plugin client) - Add Mattermost push host integration tests - Update Flutter client app with new app structure and bootstrap - Update Android build configurations and add Google Services config - Add asset keep file for Flutter client - Update agent-roadmap with api-centered-proto-socket-rail milestone - Update agent-task with planning and code review documents for all groups - Update domain rules for api, client, worker and project rules --- agent-ops/rules/project/domain/api/rules.md | 4 +- .../rules/project/domain/client/rules.md | 2 + .../rules/project/domain/worker/rules.md | 4 +- agent-ops/rules/project/rules.md | 11 ++ agent-roadmap/current.md | 4 +- agent-roadmap/phase/operator-surface/PHASE.md | 6 +- .../api-centered-proto-socket-rail.md | 73 ++++++++ .../CODE_REVIEW-cloud-G07.md | 25 +++ .../PLAN-cloud-G07.md | 132 +++++++++++++ .../CODE_REVIEW-cloud-G08.md | 25 +++ .../PLAN-cloud-G08.md | 139 ++++++++++++++ .../CODE_REVIEW-cloud-G07.md | 25 +++ .../PLAN-cloud-G07.md | 128 +++++++++++++ .../CODE_REVIEW-cloud-G08.md | 25 +++ .../04+02_backtest_rail/PLAN-cloud-G08.md | 135 ++++++++++++++ .../CODE_REVIEW-cloud-G07.md | 25 +++ .../05+02_market_rail/PLAN-cloud-G07.md | 130 +++++++++++++ apps/client/.gitignore | 3 + apps/client/android/app/build.gradle.kts | 10 +- apps/client/android/app/google-services.json | 29 +++ .../android/app/src/main/AndroidManifest.xml | 2 + apps/client/android/build.gradle.kts | 10 + apps/client/assets/.gitkeep | 1 + apps/client/lib/main.dart | 9 +- apps/client/lib/src/app/app.dart | 78 +++++++- apps/client/lib/src/app/bootstrap.dart | 35 ++++ .../mattermost/mattermost_auth_service.dart | 161 ++++++++++++++++ .../mattermost/mattermost_push_client.dart | 25 +++ .../mattermost_push_host_integration.dart | 70 +++++++ .../mattermost_push_plugin_client.dart | 58 ++++++ apps/client/pubspec.yaml | 5 + ...mattermost_push_host_integration_test.dart | 174 ++++++++++++++++++ services/api/internal/socket/handlers.go | 66 +++++++ services/api/internal/socket/server.go | 19 -- services/api/internal/socket/server_test.go | 49 +++++ 35 files changed, 1663 insertions(+), 34 deletions(-) create mode 100644 agent-roadmap/phase/operator-surface/milestones/api-centered-proto-socket-rail.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/01_contracts_api_registry/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/01_contracts_api_registry/PLAN-cloud-G07.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/02+01_worker_socket_rail/CODE_REVIEW-cloud-G08.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/02+01_worker_socket_rail/PLAN-cloud-G08.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/03+01_client_api_wrappers/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/03+01_client_api_wrappers/PLAN-cloud-G07.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/04+02_backtest_rail/CODE_REVIEW-cloud-G08.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/04+02_backtest_rail/PLAN-cloud-G08.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/05+02_market_rail/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/m-api-centered-proto-socket-rail/05+02_market_rail/PLAN-cloud-G07.md create mode 100644 apps/client/android/app/google-services.json create mode 100644 apps/client/assets/.gitkeep create mode 100644 apps/client/lib/src/app/bootstrap.dart create mode 100644 apps/client/lib/src/integrations/mattermost/mattermost_auth_service.dart create mode 100644 apps/client/lib/src/integrations/mattermost/mattermost_push_client.dart create mode 100644 apps/client/lib/src/integrations/mattermost/mattermost_push_host_integration.dart create mode 100644 apps/client/lib/src/integrations/mattermost/mattermost_push_plugin_client.dart create mode 100644 apps/client/test/integrations/mattermost_push_host_integration_test.dart create mode 100644 services/api/internal/socket/handlers.go diff --git a/agent-ops/rules/project/domain/api/rules.md b/agent-ops/rules/project/domain/api/rules.md index f9ac246..c7bf839 100644 --- a/agent-ops/rules/project/domain/api/rules.md +++ b/agent-ops/rules/project/domain/api/rules.md @@ -8,7 +8,7 @@ last_rule_updated_at: 2026-05-28 ## 목적 / 책임 -클라이언트-facing socket API endpoint를 제공한다. proto-socket server lifecycle, connection setup, heartbeat timing, ALT protobuf parser registration, and API configuration을 이 경계에서 관리한다. +클라이언트-facing socket API endpoint와 ALT operator-facing control plane을 제공한다. proto-socket server lifecycle, connection setup, heartbeat timing, ALT protobuf parser registration, and API configuration을 이 경계에서 관리한다. ## 포함 경로 @@ -39,6 +39,7 @@ last_rule_updated_at: 2026-05-28 - Keep process lifecycle in `cmd/alt-api`; keep reusable socket construction under `internal/socket`. - Keep ALT protobuf parser registration under `internal/contracts` and inject it into proto-socket server construction. - Treat proto-socket as the transport abstraction; ALT payload parsing should be derived from contracts. +- Keep client-facing command/query handling in API and route long-running execution or worker-owned reads through the worker boundary. ## 다른 도메인과의 경계 @@ -51,3 +52,4 @@ last_rule_updated_at: 2026-05-28 - Do not add direct Flutter/client assumptions to API internals. - Do not put scheduled job loops or heavy data processing in the API service. - Do not bypass proto-socket for the main client session path without an explicit architecture update. +- Do not require Flutter client or any external operator surface to connect directly to worker runtime surfaces. diff --git a/agent-ops/rules/project/domain/client/rules.md b/agent-ops/rules/project/domain/client/rules.md index f99563b..84122c6 100644 --- a/agent-ops/rules/project/domain/client/rules.md +++ b/agent-ops/rules/project/domain/client/rules.md @@ -45,6 +45,7 @@ ALT의 Flutter client surface를 담당한다. web/mobile/desktop targets, appli - Treat files under `lib/src/generated` as generated outputs; update them through codegen. - Use Material 3 components and responsive navigation patterns. - Test user-visible app shell behavior through Flutter tests. +- Use `services/api` as the only ALT-owned runtime endpoint. Do not connect directly to worker runtime surfaces from client code. ## 다른 도메인과의 경계 @@ -58,3 +59,4 @@ ALT의 Flutter client surface를 담당한다. web/mobile/desktop targets, appli - Do not hand-edit platform generated files unless the Flutter platform integration explicitly requires it. - Do not hand-edit generated protobuf Dart outputs. - Do not hard-code production endpoint assumptions in presentation widgets. +- Do not add direct worker, database, Redis, or ALT-owned non-API runtime connections to Flutter client code. diff --git a/agent-ops/rules/project/domain/worker/rules.md b/agent-ops/rules/project/domain/worker/rules.md index 0cfe816..ec67265 100644 --- a/agent-ops/rules/project/domain/worker/rules.md +++ b/agent-ops/rules/project/domain/worker/rules.md @@ -8,7 +8,7 @@ last_rule_updated_at: 2026-05-28 ## 목적 / 책임 -데이터 수집, 정규화, backtest 실행, scheduled jobs처럼 오래 걸리거나 비동기적인 작업의 runtime surface를 담당한다. PostgreSQL과 Redis 연결 정보는 이 경계에서 운영 관점으로 다룬다. +데이터 수집, 정규화, backtest 실행, scheduled jobs처럼 오래 걸리거나 비동기적인 작업의 runtime surface를 담당한다. PostgreSQL과 Redis 연결 정보는 이 경계에서 운영 관점으로 다룬다. worker는 client-facing surface가 아니며 API control plane을 통해 제어된다. ## 포함 경로 @@ -33,6 +33,7 @@ last_rule_updated_at: 2026-05-28 - Keep external service configuration explicit through `DATABASE_URL` and `REDIS_URL`. - Put job orchestration and worker-specific adapters under `services/worker/internal`. - Use `packages/domain` for shared business shapes instead of redefining market/backtest concepts. +- Expose worker-owned command/query/event runtime boundaries to `services/api` through proto-socket when crossing process boundaries. ## 다른 도메인과의 경계 @@ -45,3 +46,4 @@ last_rule_updated_at: 2026-05-28 - Do not make worker packages import API internals. - Do not hide required infrastructure in package-level globals. - Do not store generated contract code in the worker module. +- Do not expose worker as a direct Flutter/client runtime endpoint. diff --git a/agent-ops/rules/project/rules.md b/agent-ops/rules/project/rules.md index 180d46b..5c37cb5 100644 --- a/agent-ops/rules/project/rules.md +++ b/agent-ops/rules/project/rules.md @@ -32,6 +32,16 @@ ALT는 개인 quant system workspace다. Go 서비스와 도구는 루트 `go.wo - Flutter client uses Material 3, Riverpod, and `go_router`. - Local infrastructure uses Docker Compose with PostgreSQL 17 and Redis 7. +## 통신 규칙 + +- ALT가 소유한 runtime boundary 사이의 application message 통신은 proto-socket과 `packages/contracts/proto`의 ALT protobuf payload로 통일한다. +- 기본 runtime topology는 `client -> services/api -> services/worker`다. client는 worker를 직접 제어하지 않고 `services/api`를 단일 operator-facing control plane으로 사용한다. +- client-api, api-worker, worker-sidecar처럼 별도 process나 service 사이에서 ALT domain request/response, event, command를 주고받으면 기본값은 proto-socket이다. +- 같은 process 안의 함수 호출, Go interface, Dart provider 호출은 runtime 통신부로 보지 않는다. +- PostgreSQL SQL, Redis command/queue/cache, filesystem, process/stdout, local migration/codegen/dev tooling은 해당 목적의 native protocol을 사용한다. +- KIS, Mattermost, Firebase, Nexo plugin, OS platform channel처럼 ALT 외부 시스템이나 platform integration과 통신할 때는 해당 외부 protocol을 adapter 경계 안에서 사용한다. +- proto-socket이 아닌 ALT-owned runtime protocol을 새로 도입하려면 구현 전에 프로젝트 규칙 또는 명시적 architecture 문서를 갱신하고 예외 사유를 남긴다. + ## 프로젝트 컨벤션 - 루트 작업은 `bin/dev`, `bin/test`, `bin/lint`, `bin/build`를 우선 사용한다. @@ -39,6 +49,7 @@ ALT는 개인 quant system workspace다. Go 서비스와 도구는 루트 `go.wo - protobuf generated client/server 코드는 schema에서 생성하고 손으로 편집하지 않는다. - `packages/domain`은 business vocabulary와 value object를 담고, transport나 persistence detail에 의존하지 않는다. - `services/api`는 socket/session boundary를 담당하고, domain 계산이나 worker job 실행을 직접 떠안지 않는다. +- `services/api`는 얇은 control plane으로서 client-facing 요청을 받고 worker 실행/조회 경계로 중계한다. - `services/worker`는 데이터 수집, 정규화, backtest 실행, scheduled job처럼 오래 걸리거나 비동기적인 일을 담당한다. - Flutter client는 `lib/src/app`에 앱 shell/router를 두고, 기능 화면은 `lib/src/features/feature-name/presentation` 패턴 아래에 둔다. - runtime configuration은 환경변수를 우선하고 로컬 개발 fallback을 둔다. diff --git a/agent-roadmap/current.md b/agent-roadmap/current.md index e693606..24dd290 100644 --- a/agent-roadmap/current.md +++ b/agent-roadmap/current.md @@ -7,9 +7,9 @@ ## 활성 Milestone -- [계획] Flutter Operator Console +- [계획] API-Centered Proto-Socket Rail - Phase: `agent-roadmap/phase/operator-surface/PHASE.md` - - 경로: `agent-roadmap/phase/operator-surface/milestones/flutter-operator-console.md` + - 경로: `agent-roadmap/phase/operator-surface/milestones/api-centered-proto-socket-rail.md` ## 선택 규칙 diff --git a/agent-roadmap/phase/operator-surface/PHASE.md b/agent-roadmap/phase/operator-surface/PHASE.md index c6b73ac..626c7ec 100644 --- a/agent-roadmap/phase/operator-surface/PHASE.md +++ b/agent-roadmap/phase/operator-surface/PHASE.md @@ -6,13 +6,16 @@ ## 목표 -Flutter client를 ALT의 공식 operator surface로 삼아 market data 상태와 backtest 실행 및 결과를 조회할 수 있게 한다. client integration 복제와 push host integration 정리는 별도 프로젝트 산출물로 받고, 이 Phase에서는 web/mobile/desktop 단일 UI 표면을 유지하면서 API와 generated contract 기준을 맞춘다. +Flutter client를 ALT의 공식 operator surface로 삼아 market data 상태와 backtest 실행 및 결과를 조회할 수 있게 한다. 먼저 API 서버 중심의 proto-socket 내부 통신 rail을 정리하고, client integration 복제와 push host integration 정리는 별도 프로젝트 산출물로 받는다. 이 Phase에서는 web/mobile/desktop 단일 UI 표면을 유지하면서 API와 generated contract 기준을 맞춘다. ## Milestone 흐름 완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다. 완료, 검토중, 진행중, 계획 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다. +- [계획] API-Centered Proto-Socket Rail + - 경로: `agent-roadmap/phase/operator-surface/milestones/api-centered-proto-socket-rail.md` + - 요약: API 서버를 control plane으로 두고 client-api, api-worker 내부 통신을 proto-socket과 ALT protobuf contracts로 통일한다. - [계획] Flutter Operator Console - 경로: `agent-roadmap/phase/operator-surface/milestones/flutter-operator-console.md` - 요약: Flutter client에서 market data, backtest run, result를 조회하고 실행 요청할 수 있는 운영 화면을 만든다. @@ -21,5 +24,6 @@ Flutter client를 ALT의 공식 operator surface로 삼아 market data 상태와 - 이 Phase는 Flutter client의 운영 화면과 API/client 통신 표면에 집중한다. - 별도 TypeScript web app, 고급 charting 전체, mobile app store 배포 설정은 제외한다. +- API 서버를 operator-facing control plane으로 두고, client와 worker는 API를 기준으로 proto-socket rail에 연결한다. - UI는 generated/mapped contracts와 proto-socket client layer를 기준으로 API와 통신한다. - client skeleton, bootstrap convention, push host integration 복제는 이 프로젝트의 별도 Milestone으로 두지 않고 외부 프로젝트 산출물로 추후 반영한다. diff --git a/agent-roadmap/phase/operator-surface/milestones/api-centered-proto-socket-rail.md b/agent-roadmap/phase/operator-surface/milestones/api-centered-proto-socket-rail.md new file mode 100644 index 0000000..2931667 --- /dev/null +++ b/agent-roadmap/phase/operator-surface/milestones/api-centered-proto-socket-rail.md @@ -0,0 +1,73 @@ +# Milestone: API-Centered Proto-Socket Rail + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/operator-surface/PHASE.md` + +## 목표 + +ALT 내부 runtime boundary의 application message 통신을 API 서버 중심의 proto-socket rail로 정리한다. client는 API만 바라보고, API는 worker와 proto-socket 기반 command/query/event 경계를 가져서 operator console과 후속 자동화가 같은 통신 경로를 쓰게 한다. 별도 core 서버는 두지 않고 `services/api`가 얇은 control plane 역할을 맡는다. + +## 상태 + +[계획] + +## 구현 잠금 + +- 상태: 해제 +- 결정 필요: 없음 + +## 범위 + +- client-api proto-socket 단일 진입점 +- api-worker proto-socket 내부망 +- API 서버의 control plane 역할과 worker 실행자 경계 +- ALT protobuf contract와 parser map 정합성 +- 내부 command/query/event handler 등록 패턴 +- backtest run, result, market data 조회/실행 요청의 통신 레일 +- local smoke/integration 검증 경로 + +## 기능 + +### Epic: [proto-rail] API-centered proto-socket rail + +API 서버를 중심으로 client, worker, 후속 sidecar가 같은 proto-socket 규칙으로 붙을 수 있는 내부 통신 기반을 만든다. + +- [x] [topology] `client -> api -> worker`를 기본 topology로 고정하고 client가 worker를 직접 제어하지 않도록 경계를 문서와 코드 구조에 반영한다. +- [ ] [api-hub] `services/api`가 operator-facing command/query facade와 proto-socket handler registry를 가진다. 검증: API socket handler 테스트가 hello 외 주요 command/query handler 등록과 request-response 경로를 검증한다. +- [ ] [client-api] Flutter client의 ALT 내부 통신은 `AltSocketClient`와 generated/mapped contracts를 통해 API에만 연결된다. 검증: Flutter socket integration 테스트가 연결, hello, 주요 request wrapper를 검증한다. +- [ ] [api-worker] API와 worker 사이의 내부 command/query/event 통신을 proto-socket으로 연결한다. 검증: Go integration 또는 smoke 테스트가 API에서 worker rail로 hello와 대표 command/query를 왕복한다. +- [ ] [contracts] client-api와 api-worker가 공유할 ALT protobuf message, parser map, codegen 경로를 정리한다. 검증: `bin/contracts-check`와 parser map 테스트가 통과한다. +- [ ] [backtest-rail] backtest run list/detail/result/compare/start 요청이 API를 통해 worker/store 실행 경계로 이어진다. 검증: client가 worker endpoint를 직접 호출하지 않고 API proto-socket 요청만으로 대표 backtest flow를 확인한다. +- [ ] [market-rail] market instrument/bar/status 조회 요청이 API proto-socket handler에서 worker-side read boundary로 이어진다. 검증: API proto-socket 요청으로 market data 대표 조회가 가능하다. +- [x] [exceptions] PostgreSQL, Redis, filesystem, 외부 provider, Nexo/Mattermost/Firebase/platform channel은 adapter 예외로 유지하고 ALT-owned runtime message protocol로 확장하지 않는다. 검증: 새 ALT-owned HTTP/gRPC/direct-worker path가 추가되지 않았음을 코드 검색 또는 리뷰로 확인한다. + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 아직 기능 Task와 검증이 충족되지 않았다. +- 리뷰 필요: + - [ ] 사용자가 완료 결과를 확인했다 + - [ ] archive 이동을 승인했다 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- 별도 core 서버 신설 +- Flutter operator 화면 완성 +- 고급 charting과 UI polish +- worker의 장기 실행 작업 자체 구현 확대 +- PostgreSQL/Redis native protocol 대체 +- 외부 provider, push notification, platform channel protocol 대체 + +## 작업 컨텍스트 + +- 관련 경로: `services/api/`, `services/worker/`, `apps/client/`, `packages/contracts/` +- 표준선(선택): ALT-owned runtime boundary의 application message 통신은 proto-socket과 ALT protobuf payload를 기본값으로 삼고, `services/api`를 얇은 control plane으로 둔다. +- 즉시 처리: `topology`와 `exceptions`는 프로젝트/도메인 rule에 반영했고, 코드 검색으로 client direct-worker path와 새 ALT-owned HTTP/gRPC path가 없음을 확인했다. +- 대형 작업 plan: `agent-task/m-api-centered-proto-socket-rail/` +- 선행 작업: Nexo 최신화 및 테스트 완료, Socket Session Loop, Backtest Analysis Surface +- 후속 작업: Flutter Operator Console +- 확인 필요: 없음 diff --git a/agent-task/m-api-centered-proto-socket-rail/01_contracts_api_registry/CODE_REVIEW-cloud-G07.md b/agent-task/m-api-centered-proto-socket-rail/01_contracts_api_registry/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..6c28c70 --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/01_contracts_api_registry/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,25 @@ + +# CODE_REVIEW-cloud-G07: Contracts and API Handler Registry Foundation + +## 구현 에이전트 소유 섹션 +- 구현 요약: 미작성 +- 변경 파일: 미작성 +- 실행한 검증/명령: 미작성 +- 남은 위험/후속 작업: 미작성 + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 재개 조건: 없음 + +## 코드 리뷰어 소유 섹션 +- 리뷰 상태: 미작성 +- 주요 발견사항: 미작성 +- 테스트/검증 평가: 미작성 +- 판정: 미작성 diff --git a/agent-task/m-api-centered-proto-socket-rail/01_contracts_api_registry/PLAN-cloud-G07.md b/agent-task/m-api-centered-proto-socket-rail/01_contracts_api_registry/PLAN-cloud-G07.md new file mode 100644 index 0000000..320ab5d --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/01_contracts_api_registry/PLAN-cloud-G07.md @@ -0,0 +1,132 @@ + +# PLAN-cloud-G07: Contracts and API Handler Registry Foundation + +## 이 파일을 읽는 구현 에이전트에게 +이 계획은 `API-Centered Proto-Socket Rail` 마일스톤 첫 번째 에픽의 큰 작업 중 `contracts`와 `api-hub`의 기반만 다룬다. 프로세스 간 연결을 바로 완성하려고 범위를 키우지 말고, API가 여러 요청 핸들러를 안정적으로 등록하고 계약 ID/파서가 빠지지 않게 만드는 기초 레일부터 깐다. + +## 배경 +현재 API proto-socket 서버는 `services/api/internal/socket/server.go:18`에서 만들어지고 `registerSessionHandlers`는 `HelloRequest`만 등록한다. 반면 계약에는 backtest/market 요청 메시지가 이미 있고, API parser map도 `services/api/internal/contracts/parser_map.go:11`에 여러 메시지를 등록하고 있다. 이후 작업이 API, worker, client로 나뉘므로 이 단계에서 핸들러 등록 구조와 계약 누락 확인 기준을 먼저 고정해야 한다. + +## 사용자 리뷰 요청 흐름 +구현 중 새 public contract를 추가해야 하는데 기존 요청/응답 의미를 바꿔야 한다면 중단하고 `CODE_REVIEW-cloud-G07.md`의 사용자 리뷰 요청 섹션을 채운다. 단순 additive proto 필드/메시지는 기존 호환성을 유지하는 범위에서 진행한다. + +## 분석 결과 +### 읽은 파일 +- `agent-roadmap/phase/operator-surface/milestones/api-centered-proto-socket-rail.md` +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/project/domain/api/rules.md` +- `services/api/internal/socket/server.go` +- `services/api/internal/socket/server_test.go` +- `services/api/internal/contracts/parser_map.go` +- `services/api/internal/contracts/parser_map_test.go` +- `packages/contracts/proto/alt/v1/common.proto` +- `packages/contracts/proto/alt/v1/market.proto` +- `packages/contracts/proto/alt/v1/backtest.proto` +- `packages/contracts/README.md` +- `../proto-socket/go/communicator.go` + +### 테스트 커버리지 공백 +- API socket server 테스트는 hello handler 중심이라 다중 핸들러 등록 실패, 중복 등록, parser 누락을 직접 잡지 못한다. +- 계약 parser map 테스트는 메시지 파싱을 확인하지만 API handler coverage와 연결되어 있지 않다. +- 로컬 테스트 실행은 `agent-test/local/rules.md`에 따라 금지되어 있으므로 원격 검증 환경에서만 실행한다. + +### 심볼 참조 +- `NewServer`: `services/api/internal/socket/server.go:18` +- `registerSessionHandlers`: `services/api/internal/socket/server.go:32` +- `NewParserMap`: `services/api/internal/contracts/parser_map.go:11` +- `StartBacktestRequest`: `packages/contracts/proto/alt/v1/backtest.proto:35` +- `ListBacktestRunsRequest`: `packages/contracts/proto/alt/v1/backtest.proto:95` +- `ListInstrumentsRequest`: `packages/contracts/proto/alt/v1/market.proto:39` +- `ListBarsRequest`: `packages/contracts/proto/alt/v1/market.proto:48` +- `AddRequestListenerTyped`: `../proto-socket/go/communicator.go` + +### 분할 판단 +이 작업은 API 내부 등록 구조와 계약 누락 점검까지만 맡는다. 실제 worker socket server/client 구현은 `02+01_worker_socket_rail`, backtest/market 비즈니스 연결은 `04+02_backtest_rail`, `05+02_market_rail`에서 처리한다. + +### 범위 결정 근거 +- 지금 바로 필요한 것은 API가 hello 외 요청을 받을 수 있는 구조적 자리다. +- worker process 연결 없이 API 핸들러 인터페이스와 parser/contract 점검을 먼저 끝내면 후속 구현 충돌이 줄어든다. +- 새 프로토콜 도입은 금지하고, 기존 proto-socket과 `packages/contracts/proto`만 사용한다. + +### 빌드 등급 +- Build lane: `cloud-G07` +- Review lane: `cloud-G07` +- 근거: API runtime protocol surface와 contracts registry를 건드리며, 로컬 검증이 금지되어 원격 테스트가 필요하다. + +## 구현 체크리스트 +### [API-1] API socket handler registry 분리 +문제: +`registerSessionHandlers`가 hello만 직접 등록하는 구조라 후속 요청을 추가할 때 socket server가 계속 비대해진다. + +해결 방법: +API socket 패키지 안에 handler registration 단위를 분리한다. 예시는 `type HandlerRegistrar interface`나 `RegisterHandlers(ctx, session)` 형태 중 기존 proto-socket 사용 방식에 맞는 최소 구조를 선택한다. hello handler도 새 등록 흐름을 통해 붙여 기존 동작을 유지한다. + +수정 파일 및 체크리스트: +- `services/api/internal/socket/server.go` +- 필요 시 `services/api/internal/socket/handlers.go` +- `services/api/internal/socket/server_test.go` +- [ ] hello handler가 새 registry 경유로 등록된다. +- [ ] 중복/누락 등록이 테스트에서 드러난다. +- [ ] public API 함수 시그니처 변경은 최소화한다. + +테스트 작성: +- hello request/response 기존 테스트 유지. +- registry에 복수 handler를 등록하는 테스트 추가. +- handler 등록 실패 또는 nil handler 방어 테스트를 추가할지 판단한다. + +중간 검증: +- 원격 검증 환경에서만 `go test ./services/api/...` 실행. + +### [API-2] Contracts/parser 누락 점검 고정 +문제: +API parser map은 market/backtest 메시지를 등록하지만, 실제 handler registry와 연결된 coverage가 약하다. + +해결 방법: +현재 milestone에서 API가 받을 요청 메시지 목록을 명시하고 parser map 테스트가 그 목록을 검증하게 한다. 기존 메시지로 부족한 경우에만 proto에 additive schema를 추가한다. + +수정 파일 및 체크리스트: +- `services/api/internal/contracts/parser_map.go` +- `services/api/internal/contracts/parser_map_test.go` +- 필요 시 `packages/contracts/proto/alt/v1/*.proto` +- 필요 시 generated contract files +- [ ] backtest start/list/detail/result/compare 요청/응답 parser가 모두 확인된다. +- [ ] market instruments/bars/status에 필요한 요청/응답 parser가 확인된다. +- [ ] schema 추가 시 Go/Dart generated artifacts 갱신 계획과 함께 처리한다. + +테스트 작성: +- parser map의 필수 message ID 목록 테스트. +- missing parser가 실패로 드러나는 table test. + +중간 검증: +- 원격 검증 환경에서만 `bin/contracts-check` 실행. +- 원격 검증 환경에서만 `go test ./services/api/...` 실행. + +### [API-3] 마일스톤 문서와 rule 간 용어 정렬 +문제: +API가 core/control plane 역할을 맡는다는 결정이 룰에는 반영됐지만 구현 계획의 용어도 같은 단어를 써야 후속 작업이 흔들리지 않는다. + +해결 방법: +구현 중 파일/패키지/테스트 이름에서 `core server`를 새 프로세스로 만들지 않는다. 필요한 naming은 `api hub`, `control plane`, `worker client`로 통일한다. + +수정 파일 및 체크리스트: +- 필요 시 `agent-roadmap/phase/operator-surface/milestones/api-centered-proto-socket-rail.md` +- 필요 시 domain rule 문서 +- [ ] 별도 core service 생성 없음. +- [ ] API 기준 runtime topology 유지. + +테스트 작성: +- 문서만 바꾸는 경우 테스트 없음. +- 코드 naming 변경 시 관련 API tests만 원격에서 실행. + +중간 검증: +- 원격 검증 환경에서만 관련 smoke 명령을 실행한다. + +## 수정 파일 요약 +- 예상 코드: `services/api/internal/socket/**`, `services/api/internal/contracts/**` +- 조건부 코드: `packages/contracts/proto/alt/v1/**`, generated contracts +- 예상 문서: 필요 시 milestone/rule 문구 정렬 + +## 최종 검증 +- 원격 검증 환경에서만 `go test ./services/api/...` +- 원격 검증 환경에서만 `bin/contracts-check` +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-api-centered-proto-socket-rail/02+01_worker_socket_rail/CODE_REVIEW-cloud-G08.md b/agent-task/m-api-centered-proto-socket-rail/02+01_worker_socket_rail/CODE_REVIEW-cloud-G08.md new file mode 100644 index 0000000..f650b59 --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/02+01_worker_socket_rail/CODE_REVIEW-cloud-G08.md @@ -0,0 +1,25 @@ + +# CODE_REVIEW-cloud-G08: API to Worker Proto-Socket Rail + +## 구현 에이전트 소유 섹션 +- 구현 요약: 미작성 +- 변경 파일: 미작성 +- 실행한 검증/명령: 미작성 +- 남은 위험/후속 작업: 미작성 + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 재개 조건: 없음 + +## 코드 리뷰어 소유 섹션 +- 리뷰 상태: 미작성 +- 주요 발견사항: 미작성 +- 테스트/검증 평가: 미작성 +- 판정: 미작성 diff --git a/agent-task/m-api-centered-proto-socket-rail/02+01_worker_socket_rail/PLAN-cloud-G08.md b/agent-task/m-api-centered-proto-socket-rail/02+01_worker_socket_rail/PLAN-cloud-G08.md new file mode 100644 index 0000000..948ae99 --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/02+01_worker_socket_rail/PLAN-cloud-G08.md @@ -0,0 +1,139 @@ + +# PLAN-cloud-G08: API to Worker Proto-Socket Rail + +## 이 파일을 읽는 구현 에이전트에게 +이 계획은 API를 기준으로 worker와 내부망을 연결하는 기반 작업이다. client가 worker를 직접 제어하지 않는다는 프로젝트 룰을 지키며, API가 worker에 명령/조회 요청을 보낼 수 있는 proto-socket rail을 만든다. + +## 배경 +현재 `services/worker/cmd/alt-worker/main.go:10`은 runner와 builtin job registration만 수행하며 socket server가 없다. worker config도 `services/worker/internal/config/config.go:5` 기준 DB/Redis/queue 중심이고 runtime socket address가 없다. API 역시 worker client가 없고, `services/api/internal/socket/server.go:32`에서 hello 외 요청을 처리하지 않는다. 이 작업은 API와 worker 사이의 내부 프로세스 경계를 proto-socket으로 여는 일이다. + +## 사용자 리뷰 요청 흐름 +worker를 API가 아닌 client에 직접 노출해야 한다는 요구가 발견되면 중단한다. 이는 현재 프로젝트 룰과 충돌하므로 `CODE_REVIEW-cloud-G08.md` 사용자 리뷰 요청 섹션에 사유를 적고 결정을 받아야 한다. + +## 분석 결과 +### 읽은 파일 +- `services/worker/cmd/alt-worker/main.go` +- `services/worker/internal/config/config.go` +- `services/worker/internal/jobs/runner.go` +- `services/worker/internal/jobs/backtest_jobs.go` +- `services/worker/internal/storage/ports.go` +- `services/api/internal/socket/server.go` +- `services/api/internal/config/config.go` +- `services/api/cmd/alt-api/main.go` +- `services/api/go.mod` +- `services/worker/go.mod` +- `../proto-socket/go/ws_server.go` +- `../proto-socket/go/ws_client.go` +- `../proto-socket/go/communicator.go` + +### 테스트 커버리지 공백 +- worker에는 socket runtime 테스트가 없다. +- API에는 worker 연결 실패/timeout/retry behavior 테스트가 없다. +- 프로세스 간 smoke는 아직 문서 수준이며 로컬 실행은 금지되어 있다. + +### 심볼 참조 +- worker entrypoint: `services/worker/cmd/alt-worker/main.go:10` +- worker config: `services/worker/internal/config/config.go:5` +- job runner `Register`: `services/worker/internal/jobs/runner.go:27` +- job runner `Execute`: `services/worker/internal/jobs/runner.go:42` +- API config: `services/api/internal/config/config.go` +- proto-socket typed listener: `../proto-socket/go/communicator.go` +- proto-socket websocket server/client: `../proto-socket/go/ws_server.go`, `../proto-socket/go/ws_client.go` + +### 분할 판단 +이 작업은 worker socket server, API worker client, config, lifecycle만 구현한다. backtest와 market의 실제 business handler wiring은 각각 `04+02_backtest_rail`, `05+02_market_rail`에서 구현한다. + +### 범위 결정 근거 +- API가 control plane이고 worker가 execution plane이라는 결정이 이미 프로젝트 룰에 들어갔다. +- worker internal package는 API가 직접 import할 수 없고, import해서도 안 된다. 따라서 process boundary는 proto-socket이어야 한다. +- 초기 rail은 health/hello 또는 최소 ping 성격의 요청으로 연결성과 timeout을 검증하고, domain 요청은 후속 계획에 얹는다. + +### 빌드 등급 +- Build lane: `cloud-G08` +- Review lane: `cloud-G08` +- 근거: 두 Go service의 runtime boundary, config, lifecycle, network failure behavior를 함께 다룬다. + +## 구현 체크리스트 +### [WORKER-1] worker proto-socket server 추가 +문제: +worker process가 proto-socket 요청을 받을 surface가 없다. + +해결 방법: +`services/worker/internal/socket` 또는 기존 구조에 맞는 패키지를 만들고 proto-socket server를 시작한다. server는 worker-owned handlers만 등록하고, client-facing endpoint가 아님을 코드/테스트 구조로 드러낸다. + +수정 파일 및 체크리스트: +- `services/worker/cmd/alt-worker/main.go` +- `services/worker/internal/config/config.go` +- `services/worker/internal/socket/**` +- `services/worker/go.mod` +- [ ] worker listen address/env가 추가된다. +- [ ] worker socket server lifecycle이 main에서 시작된다. +- [ ] shutdown/context 처리가 기존 runner 구조를 해치지 않는다. +- [ ] worker가 API/client package를 import하지 않는다. + +테스트 작성: +- config env parsing test. +- worker socket handler registration test. +- 최소 hello/health request-response test. + +중간 검증: +- 원격 검증 환경에서만 `go test ./services/worker/...` 실행. + +### [WORKER-2] API worker proto-socket client 추가 +문제: +API가 worker에 요청을 보낼 client abstraction이 없다. + +해결 방법: +`services/api/internal/workerclient` 같은 패키지를 만들고 proto-socket Go client를 감싼다. 초기 메서드는 연결성 확인용으로 작게 시작하고, domain method는 후속 계획에서 추가한다. context timeout과 worker unavailable error mapping을 명확히 한다. + +수정 파일 및 체크리스트: +- `services/api/internal/config/config.go` +- `services/api/cmd/alt-api/main.go` +- `services/api/internal/workerclient/**` +- `services/api/go.mod` +- [ ] API worker socket URL/env가 추가된다. +- [ ] request timeout 기본값이 있다. +- [ ] worker unavailable이 client-facing proto error로 변환될 자리만 만든다. +- [ ] API가 `services/worker/internal/**`를 import하지 않는다. + +테스트 작성: +- API config env parsing test. +- fake proto-socket worker 또는 fake client 기반 unavailable/timeout test. +- workerclient request mapping unit test. + +중간 검증: +- 원격 검증 환경에서만 `go test ./services/api/...` 실행. + +### [WORKER-3] 양쪽 parser map 정렬 +문제: +worker도 proto-socket request를 받으려면 contract parser가 필요하지만 현재 parser map은 API 내부에만 있다. + +해결 방법: +가장 작은 안전한 선택을 한다. API 내부 parser map을 바로 공유하려고 internal 경계를 깨지 않는다. worker에 필요한 parser registration을 추가하거나, contracts module에 수동 helper를 둘 경우 generated artifact와 충돌하지 않는 위치인지 먼저 확인한다. + +수정 파일 및 체크리스트: +- `services/worker/internal/contracts/**` 또는 안전한 shared contracts helper +- `services/api/internal/contracts/**` 필요 시 정렬 +- [ ] worker에서 받을 request parser가 등록된다. +- [ ] API에서 worker response parser가 등록된다. +- [ ] API internal package를 worker가 import하지 않는다. + +테스트 작성: +- worker parser map 필수 메시지 목록 테스트. +- API/worker parser 누락 테스트. + +중간 검증: +- 원격 검증 환경에서만 `bin/contracts-check` 실행. +- 원격 검증 환경에서만 `go test ./services/api/... ./services/worker/...` 실행. + +## 수정 파일 요약 +- 예상 코드: `services/worker/internal/socket/**`, `services/worker/internal/config/config.go`, `services/worker/cmd/alt-worker/main.go` +- 예상 코드: `services/api/internal/workerclient/**`, `services/api/internal/config/config.go`, `services/api/cmd/alt-api/main.go` +- 예상 코드: API/worker parser map 관련 파일 +- 예상 모듈: `services/worker/go.mod`, `services/api/go.mod` + +## 최종 검증 +- 원격 검증 환경에서만 `go test ./services/api/...` +- 원격 검증 환경에서만 `go test ./services/worker/...` +- 원격 검증 환경에서만 `bin/contracts-check` +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-api-centered-proto-socket-rail/03+01_client_api_wrappers/CODE_REVIEW-cloud-G07.md b/agent-task/m-api-centered-proto-socket-rail/03+01_client_api_wrappers/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..fc7340d --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/03+01_client_api_wrappers/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,25 @@ + +# CODE_REVIEW-cloud-G07: Flutter Client API Proto-Socket Wrappers + +## 구현 에이전트 소유 섹션 +- 구현 요약: 미작성 +- 변경 파일: 미작성 +- 실행한 검증/명령: 미작성 +- 남은 위험/후속 작업: 미작성 + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 재개 조건: 없음 + +## 코드 리뷰어 소유 섹션 +- 리뷰 상태: 미작성 +- 주요 발견사항: 미작성 +- 테스트/검증 평가: 미작성 +- 판정: 미작성 diff --git a/agent-task/m-api-centered-proto-socket-rail/03+01_client_api_wrappers/PLAN-cloud-G07.md b/agent-task/m-api-centered-proto-socket-rail/03+01_client_api_wrappers/PLAN-cloud-G07.md new file mode 100644 index 0000000..d86cd1f --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/03+01_client_api_wrappers/PLAN-cloud-G07.md @@ -0,0 +1,128 @@ + +# PLAN-cloud-G07: Flutter Client API Proto-Socket Wrappers + +## 이 파일을 읽는 구현 에이전트에게 +이 계획은 Flutter client가 API 서버만 바라보는 proto-socket wrapper를 완성하는 작업이다. worker나 DB, Redis, 별도 HTTP runtime으로 우회하지 않는다. + +## 배경 +`AltSocketClient`는 `apps/client/lib/src/integrations/socket/alt_socket_client.dart:9`에서 proto-socket client를 감싸고 hello/listBacktestRuns/getBacktestRunDetail/getBacktestResult/compareBacktestRuns를 제공한다. 하지만 contracts에는 `StartBacktestRequest`, `ListInstrumentsRequest`, `ListBarsRequest`가 있고 client wrapper에는 아직 빠져 있다. UI는 socket state를 표시하지만 API 작업 surface가 충분히 열려 있지 않다. + +## 사용자 리뷰 요청 흐름 +client에서 worker 직접 연결이나 API 외 runtime endpoint가 필요하다는 요구가 나오면 중단하고 사용자 리뷰 요청을 남긴다. 프로젝트 룰상 client는 API만 바라봐야 한다. + +## 분석 결과 +### 읽은 파일 +- `apps/client/lib/src/integrations/socket/alt_socket_client.dart` +- `apps/client/lib/src/integrations/socket/socket_endpoint.dart` +- `apps/client/lib/src/integrations/socket/socket_connection_controller.dart` +- `apps/client/lib/src/contracts/alt_contracts.dart` +- `apps/client/test/integrations/socket/alt_socket_client_test.dart` +- `apps/client/test/contracts/alt_contracts_test.dart` +- `apps/client/integration_test/socket_runtime_smoke_test.dart` +- `apps/client/test_runtime/socket_web_runtime_smoke_test.dart` +- `packages/contracts/proto/alt/v1/backtest.proto` +- `packages/contracts/proto/alt/v1/market.proto` +- `../proto-socket/dart/lib/src/communicator.dart` + +### 테스트 커버리지 공백 +- client wrapper test는 일부 backtest 조회 요청만 확인한다. +- start backtest, market instruments/bars request wrapper coverage가 없다. +- 실제 runtime smoke는 원격/허용된 환경에서만 돌려야 한다. + +### 심볼 참조 +- `AltSocketClient`: `apps/client/lib/src/integrations/socket/alt_socket_client.dart:9` +- `connect`: `apps/client/lib/src/integrations/socket/alt_socket_client.dart:16` +- `hello`: `apps/client/lib/src/integrations/socket/alt_socket_client.dart:33` +- `listBacktestRuns`: `apps/client/lib/src/integrations/socket/alt_socket_client.dart:43` +- `getBacktestRunDetail`: `apps/client/lib/src/integrations/socket/alt_socket_client.dart:53` +- `getBacktestResult`: `apps/client/lib/src/integrations/socket/alt_socket_client.dart:64` +- `compareBacktestRuns`: `apps/client/lib/src/integrations/socket/alt_socket_client.dart:74` +- Dart proto-socket `sendRequest`: `../proto-socket/dart/lib/src/communicator.dart` + +### 분할 판단 +이 작업은 client wrapper와 tests만 다룬다. API가 실제 domain 응답을 worker에서 받아오는 구현은 `04+02_backtest_rail`, `05+02_market_rail`의 책임이다. + +### 범위 결정 근거 +- client 입장에서는 API proto-socket method surface가 먼저 안정화되어야 UI 작업이 가능하다. +- Nexo/Mattermost 등 외부 adapter는 예외로 두며, 이 작업은 ALT runtime API wrapper에만 손댄다. +- generated Dart contracts가 이미 있는 경우 wrapper만 추가하고, 없는 경우 contracts plan 결과에 맞춰 생성물을 갱신한다. + +### 빌드 등급 +- Build lane: `cloud-G07` +- Review lane: `cloud-G07` +- 근거: client runtime API surface와 generated protobuf 타입을 다루며, remote/web smoke 검증이 필요하다. + +## 구현 체크리스트 +### [CLIENT-1] backtest command wrapper 추가 +문제: +client가 backtest 조회는 일부 호출할 수 있지만 start command wrapper가 없다. + +해결 방법: +`AltSocketClient`에 `startBacktest`를 추가하고 generated `StartBacktestRequest/Response`를 그대로 사용한다. request ID/timeout 패턴은 기존 메서드와 동일하게 맞춘다. + +수정 파일 및 체크리스트: +- `apps/client/lib/src/integrations/socket/alt_socket_client.dart` +- `apps/client/test/integrations/socket/alt_socket_client_test.dart` +- [ ] method name과 request/response type이 proto와 일치한다. +- [ ] 기존 wrapper style과 timeout/error handling을 유지한다. +- [ ] API 외 endpoint는 추가하지 않는다. + +테스트 작성: +- fake communicator/mock transport로 message ID와 response type 검증. +- error path가 기존 wrapper와 같은 방식으로 전파되는지 확인. + +중간 검증: +- 원격 검증 환경에서만 `cd apps/client && flutter test` 실행. + +### [CLIENT-2] market query wrapper 추가 +문제: +contracts에는 instruments/bars 요청이 있지만 client wrapper에 없다. + +해결 방법: +`listInstruments`, `listBars` wrapper를 추가한다. status가 별도 contract로 존재하지 않으면 connection controller 상태 또는 common health contract를 우선 검토하고, 새 schema는 additive로만 추가한다. + +수정 파일 및 체크리스트: +- `apps/client/lib/src/integrations/socket/alt_socket_client.dart` +- `apps/client/test/integrations/socket/alt_socket_client_test.dart` +- 필요 시 `apps/client/test/contracts/alt_contracts_test.dart` +- [ ] instruments/bars request가 API socket으로만 나간다. +- [ ] status 의미가 connection status인지 market data status인지 테스트명에서 구분된다. +- [ ] 외부 HTTP/gRPC runtime dependency를 추가하지 않는다. + +테스트 작성: +- instruments request wrapper test. +- bars request wrapper test. +- status contract가 추가될 경우 contract export test. + +중간 검증: +- 원격 검증 환경에서만 `cd apps/client && flutter test` 실행. + +### [CLIENT-3] socket endpoint/control naming 정리 +문제: +client 쪽에서 API 서버가 유일한 ALT-owned runtime endpoint라는 결정이 코드 naming에 덜 드러날 수 있다. + +해결 방법: +기존 `socket_endpoint`와 `socket_connection_controller` 구조를 유지하되, 새 endpoint를 만들지 않는다. 필요한 경우 test description과 변수명에서 API socket임을 명확히 한다. + +수정 파일 및 체크리스트: +- `apps/client/lib/src/integrations/socket/socket_endpoint.dart` +- `apps/client/lib/src/integrations/socket/socket_connection_controller.dart` +- 관련 tests +- [ ] direct worker endpoint 없음. +- [ ] dashboard/UI 변경은 이 계획 범위 밖이다. + +테스트 작성: +- endpoint parsing/default test가 있으면 API URL 기준으로 보강. + +중간 검증: +- 원격 검증 환경에서만 client smoke 명령을 실행한다. + +## 수정 파일 요약 +- 예상 코드: `apps/client/lib/src/integrations/socket/alt_socket_client.dart` +- 예상 테스트: `apps/client/test/integrations/socket/alt_socket_client_test.dart`, 필요 시 contracts test +- 조건부 코드: generated Dart contracts export + +## 최종 검증 +- 원격 검증 환경에서만 `cd apps/client && flutter test` +- 원격 검증 환경에서만 client socket runtime smoke +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-api-centered-proto-socket-rail/04+02_backtest_rail/CODE_REVIEW-cloud-G08.md b/agent-task/m-api-centered-proto-socket-rail/04+02_backtest_rail/CODE_REVIEW-cloud-G08.md new file mode 100644 index 0000000..e74fc73 --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/04+02_backtest_rail/CODE_REVIEW-cloud-G08.md @@ -0,0 +1,25 @@ + +# CODE_REVIEW-cloud-G08: Backtest API to Worker Proto-Socket Rail + +## 구현 에이전트 소유 섹션 +- 구현 요약: 미작성 +- 변경 파일: 미작성 +- 실행한 검증/명령: 미작성 +- 남은 위험/후속 작업: 미작성 + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 재개 조건: 없음 + +## 코드 리뷰어 소유 섹션 +- 리뷰 상태: 미작성 +- 주요 발견사항: 미작성 +- 테스트/검증 평가: 미작성 +- 판정: 미작성 diff --git a/agent-task/m-api-centered-proto-socket-rail/04+02_backtest_rail/PLAN-cloud-G08.md b/agent-task/m-api-centered-proto-socket-rail/04+02_backtest_rail/PLAN-cloud-G08.md new file mode 100644 index 0000000..3430127 --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/04+02_backtest_rail/PLAN-cloud-G08.md @@ -0,0 +1,135 @@ + +# PLAN-cloud-G08: Backtest API to Worker Proto-Socket Rail + +## 이 파일을 읽는 구현 에이전트에게 +이 계획은 backtest 관련 client-facing 요청을 API에서 받아 worker로 넘기고, worker가 job/store 결과를 proto response로 돌려주는 레일을 만든다. `02+01_worker_socket_rail` 완료 후 진행한다. + +## 배경 +contracts에는 `StartBacktestRequest`, run 조회, result 조회, compare 요청이 있다. worker에는 `RunBacktestPayload`와 runner/store가 있고, postgres store에는 run/result/compare 조회 메서드가 있다. 하지만 API가 이 요청들을 handler로 받거나 worker에 전달하는 연결이 아직 없다. + +## 사용자 리뷰 요청 흐름 +backtest 실행을 API 프로세스 안에서 직접 수행해야 한다는 요구가 나오면 중단한다. 마일스톤 목표는 API 중심 제어와 worker 실행 분리이므로 범위 변경이다. + +## 분석 결과 +### 읽은 파일 +- `packages/contracts/proto/alt/v1/backtest.proto` +- `services/api/internal/socket/server.go` +- `services/api/internal/contracts/parser_map.go` +- `services/worker/internal/jobs/runner.go` +- `services/worker/internal/jobs/backtest_jobs.go` +- `services/worker/internal/storage/ports.go` +- `services/worker/internal/storage/postgres/store.go` +- `services/worker/internal/storage/postgres/mapping.go` +- `services/worker/internal/storage/postgres/queries/queries.sql` +- `services/worker/internal/storage/postgres/store_test.go` +- `services/worker/internal/jobs/backtest_jobs_test.go` + +### 테스트 커버리지 공백 +- backtest job tests는 worker 내부 실행 단위 중심이다. +- API handler -> worker client -> worker handler integration coverage가 없다. +- store mapping tests는 있지만 proto response mapping coverage는 약하다. + +### 심볼 참조 +- `StartBacktestRequest`: `packages/contracts/proto/alt/v1/backtest.proto:35` +- `StartBacktestResponse`: `packages/contracts/proto/alt/v1/backtest.proto:39` +- `ListBacktestRunsRequest`: `packages/contracts/proto/alt/v1/backtest.proto:95` +- `GetBacktestRunDetailRequest`: `packages/contracts/proto/alt/v1/backtest.proto:103` +- `CompareBacktestRunsRequest`: `packages/contracts/proto/alt/v1/backtest.proto:112` +- `RunBacktestPayload`: `services/worker/internal/jobs/backtest_jobs.go:20` +- `RegisterRunBacktestHandler`: `services/worker/internal/jobs/backtest_jobs.go:83` +- `ListRuns`: `services/worker/internal/storage/postgres/store.go:142` +- `GetRunDetail`: `services/worker/internal/storage/postgres/store.go:158` +- `CompareResults`: `services/worker/internal/storage/postgres/store.go:174` +- `GetResult`: `services/worker/internal/storage/postgres/store.go:197` + +### 분할 판단 +이 작업은 backtest domain 전체를 연결하므로 G08이다. market data는 `05+02_market_rail`에서 따로 처리한다. + +### 범위 결정 근거 +- backtest는 command(start)와 queries(list/detail/result/compare)가 섞여 있어 API/worker 양쪽 handler와 mapping을 함께 설계해야 한다. +- worker storage ownership을 유지해야 하므로 API는 store에 직접 접근하지 않는다. +- existing job runner를 재사용하고 새 execution engine을 만들지 않는다. + +### 빌드 등급 +- Build lane: `cloud-G08` +- Review lane: `cloud-G08` +- 근거: user-facing backtest behavior, worker execution, persistence read mapping, protocol errors를 모두 건드린다. + +## 구현 체크리스트 +### [BACKTEST-1] API backtest handlers 추가 +문제: +API socket server가 backtest 요청을 받지 않는다. + +해결 방법: +`01_contracts_api_registry`의 registry에 backtest handlers를 붙인다. handler는 request validation과 worker client 호출만 수행하고, execution/store logic을 API에 넣지 않는다. + +수정 파일 및 체크리스트: +- `services/api/internal/socket/**` +- `services/api/internal/workerclient/**` +- `services/api/internal/contracts/**` +- [ ] start/list/detail/result/compare handler가 등록된다. +- [ ] validation error와 worker unavailable error가 proto-socket response error로 일관되게 변환된다. +- [ ] API가 worker internal/storage package를 import하지 않는다. + +테스트 작성: +- fake worker client 기반 API handler unit tests. +- invalid request tests. +- worker failure mapping tests. + +중간 검증: +- 원격 검증 환경에서만 `go test ./services/api/...` 실행. + +### [BACKTEST-2] worker backtest socket handlers 추가 +문제: +worker 내부 job/store 기능은 있지만 proto-socket request와 연결되어 있지 않다. + +해결 방법: +worker socket server에 backtest command/query handlers를 추가한다. start는 runner를 통해 실행하고, queries는 worker-owned store port를 사용한다. + +수정 파일 및 체크리스트: +- `services/worker/internal/socket/**` +- `services/worker/internal/jobs/**` 필요 시 최소 보강 +- `services/worker/internal/storage/**` 필요 시 mapping helper +- [ ] start request가 `RunBacktestPayload`로 안전하게 변환된다. +- [ ] list/detail/result/compare request가 store port로 처리된다. +- [ ] not found/empty result semantics가 계약과 일치한다. + +테스트 작성: +- fake runner/fake store 기반 worker handler tests. +- request -> domain payload mapping tests. +- domain/store result -> proto response mapping tests. + +중간 검증: +- 원격 검증 환경에서만 `go test ./services/worker/...` 실행. + +### [BACKTEST-3] backtest response mapping 정리 +문제: +store/domain 모델과 proto response 사이의 mapping 책임이 흩어질 수 있다. + +해결 방법: +worker 안에 mapper를 두어 domain/store 모델을 proto message로 변환한다. API는 pass-through에 가깝게 유지한다. + +수정 파일 및 체크리스트: +- `services/worker/internal/socket/**` 또는 `services/worker/internal/contracts/**` +- 관련 tests +- [ ] timestamps, IDs, enum/status 값 변환이 테스트된다. +- [ ] nil/empty collection behavior가 명확하다. +- [ ] response message가 기존 client expectations와 맞는다. + +테스트 작성: +- mapper table tests. +- edge case tests for missing result/compare pair. + +중간 검증: +- 원격 검증 환경에서만 worker/API smoke를 실행한다. + +## 수정 파일 요약 +- 예상 API 코드: `services/api/internal/socket/**`, `services/api/internal/workerclient/**` +- 예상 worker 코드: `services/worker/internal/socket/**`, `services/worker/internal/jobs/**`, `services/worker/internal/storage/**` +- 예상 tests: API handler tests, worker handler/mapper tests + +## 최종 검증 +- 원격 검증 환경에서만 `go test ./services/api/...` +- 원격 검증 환경에서만 `go test ./services/worker/...` +- 원격 검증 환경에서만 API/worker smoke +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-api-centered-proto-socket-rail/05+02_market_rail/CODE_REVIEW-cloud-G07.md b/agent-task/m-api-centered-proto-socket-rail/05+02_market_rail/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..65a1f9a --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/05+02_market_rail/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,25 @@ + +# CODE_REVIEW-cloud-G07: Market Data API to Worker Proto-Socket Rail + +## 구현 에이전트 소유 섹션 +- 구현 요약: 미작성 +- 변경 파일: 미작성 +- 실행한 검증/명령: 미작성 +- 남은 위험/후속 작업: 미작성 + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 재개 조건: 없음 + +## 코드 리뷰어 소유 섹션 +- 리뷰 상태: 미작성 +- 주요 발견사항: 미작성 +- 테스트/검증 평가: 미작성 +- 판정: 미작성 diff --git a/agent-task/m-api-centered-proto-socket-rail/05+02_market_rail/PLAN-cloud-G07.md b/agent-task/m-api-centered-proto-socket-rail/05+02_market_rail/PLAN-cloud-G07.md new file mode 100644 index 0000000..2a5b9ea --- /dev/null +++ b/agent-task/m-api-centered-proto-socket-rail/05+02_market_rail/PLAN-cloud-G07.md @@ -0,0 +1,130 @@ + +# PLAN-cloud-G07: Market Data API to Worker Proto-Socket Rail + +## 이 파일을 읽는 구현 에이전트에게 +이 계획은 market data 조회와 status 성격의 요청을 API -> worker proto-socket rail 위에 얹는 작업이다. `02+01_worker_socket_rail` 완료 후 진행한다. + +## 배경 +`market.proto`에는 instruments와 bars 관련 요청/응답이 있고, worker storage에는 instrument/bar store port와 postgres 구현이 있다. API에는 market parser registration은 있지만 handler가 없고, client wrapper도 별도 계획에서 보강된다. + +## 사용자 리뷰 요청 흐름 +market data 조회를 위해 client가 worker 또는 DB에 직접 붙어야 한다는 요구가 나오면 중단한다. 이는 API-centered topology와 충돌한다. + +## 분석 결과 +### 읽은 파일 +- `packages/contracts/proto/alt/v1/market.proto` +- `packages/contracts/proto/alt/v1/common.proto` +- `services/api/internal/socket/server.go` +- `services/api/internal/contracts/parser_map.go` +- `services/worker/internal/storage/ports.go` +- `services/worker/internal/storage/postgres/store.go` +- `services/worker/internal/storage/postgres/mapping.go` +- `services/worker/internal/storage/postgres/queries/queries.sql` +- `services/worker/internal/storage/postgres/store_test.go` + +### 테스트 커버리지 공백 +- instrument/bar store tests는 있으나 proto response mapping tests가 없다. +- API market handler tests가 없다. +- status 의미가 connection health인지 market ingestion/data availability인지 코드상 고정되어 있지 않다. + +### 심볼 참조 +- `ListInstrumentsRequest`: `packages/contracts/proto/alt/v1/market.proto:39` +- `ListBarsRequest`: `packages/contracts/proto/alt/v1/market.proto:48` +- `InstrumentStore`: `services/worker/internal/storage/ports.go:15` +- `BarStore`: `services/worker/internal/storage/ports.go:21` +- `ListInstruments`: `services/worker/internal/storage/postgres/store.go:60` +- `GetBars`: `services/worker/internal/storage/postgres/store.go:87` +- API parser map: `services/api/internal/contracts/parser_map.go:11` + +### 분할 판단 +market rail은 조회 중심이라 backtest보다 작지만 API/worker 양쪽 protocol handler와 mapping이 필요하다. status schema 결정이 포함될 수 있어 G07로 둔다. + +### 범위 결정 근거 +- market data read ownership은 worker/store 쪽에 두고 API는 control plane facade로 유지한다. +- provider/KIS 같은 외부 프로토콜은 프로젝트 규칙의 예외이며 이 계획의 범위가 아니다. +- status는 기존 common health/status contract를 우선 재사용하고, 부족할 때만 additive schema를 추가한다. + +### 빌드 등급 +- Build lane: `cloud-G07` +- Review lane: `cloud-G07` +- 근거: protocol handler와 data mapping을 추가하지만 execution command보다 위험도는 낮다. + +## 구현 체크리스트 +### [MARKET-1] API market handlers 추가 +문제: +API socket server가 instruments/bars 요청을 받지 않는다. + +해결 방법: +API registry에 market handlers를 추가하고 worker client를 통해 worker로 전달한다. API는 query validation과 error mapping만 담당한다. + +수정 파일 및 체크리스트: +- `services/api/internal/socket/**` +- `services/api/internal/workerclient/**` +- `services/api/internal/contracts/**` +- [ ] list instruments handler 등록. +- [ ] list bars handler 등록. +- [ ] status 요청이 있다면 API/worker rail에서 의미가 명확히 분리된다. +- [ ] API가 worker storage를 직접 import하지 않는다. + +테스트 작성: +- fake worker client 기반 API handler tests. +- invalid symbol/time range tests. +- worker error mapping tests. + +중간 검증: +- 원격 검증 환경에서만 `go test ./services/api/...` 실행. + +### [MARKET-2] worker market handlers 추가 +문제: +worker store는 market data를 조회할 수 있지만 proto-socket request와 연결되어 있지 않다. + +해결 방법: +worker socket server에 market query handlers를 추가하고 `InstrumentStore`, `BarStore` port를 사용한다. + +수정 파일 및 체크리스트: +- `services/worker/internal/socket/**` +- `services/worker/internal/storage/**` 필요 시 mapping helper +- [ ] instruments response mapping 구현. +- [ ] bars response mapping 구현. +- [ ] empty data와 invalid range behavior가 계약과 일치한다. + +테스트 작성: +- fake store 기반 worker handler tests. +- domain/store result -> proto response mapper tests. +- empty list and missing instrument tests. + +중간 검증: +- 원격 검증 환경에서만 `go test ./services/worker/...` 실행. + +### [MARKET-3] status contract 의미 확정 +문제: +마일스톤에는 market data/status가 언급되지만 현재 status가 socket connectivity, worker health, data freshness 중 무엇인지 분명하지 않다. + +해결 방법: +기존 `common.proto` health/status 계열 메시지를 먼저 검토한다. data freshness나 ingestion status가 별도 개념이면 additive market status request/response를 추가하고 parser/client/API/worker에 반영한다. 단순 연결 상태라면 client connection controller와 worker health로 처리하고 새 schema를 만들지 않는다. + +수정 파일 및 체크리스트: +- 필요 시 `packages/contracts/proto/alt/v1/common.proto` +- 필요 시 `packages/contracts/proto/alt/v1/market.proto` +- 필요 시 generated contracts +- [ ] status 의미가 테스트명과 docs에 드러난다. +- [ ] 기존 schema 의미를 변경하지 않는다. +- [ ] parser map이 누락되지 않는다. + +테스트 작성: +- status schema가 추가될 경우 parser map/contracts tests. +- status handler가 추가될 경우 API/worker tests. + +중간 검증: +- 원격 검증 환경에서만 `bin/contracts-check` 실행. + +## 수정 파일 요약 +- 예상 API 코드: `services/api/internal/socket/**`, `services/api/internal/workerclient/**` +- 예상 worker 코드: `services/worker/internal/socket/**`, `services/worker/internal/storage/**` +- 조건부 contracts: `packages/contracts/proto/alt/v1/common.proto`, `packages/contracts/proto/alt/v1/market.proto`, generated outputs + +## 최종 검증 +- 원격 검증 환경에서만 `go test ./services/api/...` +- 원격 검증 환경에서만 `go test ./services/worker/...` +- 원격 검증 환경에서만 `bin/contracts-check` +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/client/.gitignore b/apps/client/.gitignore index 3820a95..7ed4e7c 100644 --- a/apps/client/.gitignore +++ b/apps/client/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Mattermost credentials +assets/mattermost_credentials.json diff --git a/apps/client/android/app/build.gradle.kts b/apps/client/android/app/build.gradle.kts index 8a94ec4..897e0c9 100644 --- a/apps/client/android/app/build.gradle.kts +++ b/apps/client/android/app/build.gradle.kts @@ -3,16 +3,18 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") } android { - namespace = "com.tokilabs.alt_client" + namespace = "com.tokilabs.mattermost" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -21,7 +23,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.tokilabs.alt_client" + applicationId = "com.tokilabs.mattermost" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -42,3 +44,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") +} diff --git a/apps/client/android/app/google-services.json b/apps/client/android/app/google-services.json new file mode 100644 index 0000000..24bda37 --- /dev/null +++ b/apps/client/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "1047648748539", + "project_id": "mattermost-6ac08", + "storage_bucket": "mattermost-6ac08.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1047648748539:android:818bf70bfbb3f9d070415e", + "android_client_info": { + "package_name": "com.tokilabs.mattermost" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAW6j_oPl9MVcm93qS3JgaEPD5ywp-TzZ0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/apps/client/android/app/src/main/AndroidManifest.xml b/apps/client/android/app/src/main/AndroidManifest.xml index ec2d54d..087be4d 100644 --- a/apps/client/android/app/src/main/AndroidManifest.xml +++ b/apps/client/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + main() => runAltClient(); diff --git a/apps/client/lib/src/app/app.dart b/apps/client/lib/src/app/app.dart index 308fc4b..37eb281 100644 --- a/apps/client/lib/src/app/app.dart +++ b/apps/client/lib/src/app/app.dart @@ -1,10 +1,15 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../integrations/mattermost/mattermost_push_host_integration.dart'; import 'router.dart'; class AltClientApp extends ConsumerWidget { - const AltClientApp({super.key}); + final MattermostPushHostIntegration? mattermostHost; + + const AltClientApp({super.key, this.mattermostHost}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -12,6 +17,12 @@ class AltClientApp extends ConsumerWidget { return MaterialApp.router( title: 'ALT', routerConfig: router, + builder: (context, child) { + return _MattermostNotificationOverlay( + mattermostHost: mattermostHost, + child: child ?? const SizedBox.shrink(), + ); + }, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF006C67)), useMaterial3: true, @@ -19,3 +30,68 @@ class AltClientApp extends ConsumerWidget { ); } } + +class _MattermostNotificationOverlay extends StatefulWidget { + final MattermostPushHostIntegration? mattermostHost; + final Widget child; + + const _MattermostNotificationOverlay({ + required this.mattermostHost, + required this.child, + }); + + @override + State<_MattermostNotificationOverlay> createState() => + _MattermostNotificationOverlayState(); +} + +class _MattermostNotificationOverlayState + extends State<_MattermostNotificationOverlay> { + StreamSubscription? _notificationSubscription; + + @override + void initState() { + super.initState(); + _subscribe(widget.mattermostHost); + } + + @override + void didUpdateWidget(_MattermostNotificationOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.mattermostHost != widget.mattermostHost) { + _notificationSubscription?.cancel(); + _subscribe(widget.mattermostHost); + } + } + + @override + void dispose() { + _notificationSubscription?.cancel(); + super.dispose(); + } + + void _subscribe(MattermostPushHostIntegration? host) { + if (host == null) return; + _notificationSubscription = host.onNotification.listen((data) { + if (data['type'] != 'message' || !mounted) return; + + final message = data['message'] as String? ?? ''; + final channel = data['channel_name'] as String? ?? ''; + final sender = data['sender_name'] as String? ?? ''; + final content = sender.isNotEmpty + ? '[$channel] $sender: $message' + : message; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(content, maxLines: 2, overflow: TextOverflow.ellipsis), + duration: const Duration(seconds: 4), + behavior: SnackBarBehavior.floating, + ), + ); + }); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/client/lib/src/app/bootstrap.dart b/apps/client/lib/src/app/bootstrap.dart new file mode 100644 index 0000000..ffaefe4 --- /dev/null +++ b/apps/client/lib/src/app/bootstrap.dart @@ -0,0 +1,35 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../integrations/mattermost/mattermost_push_host_integration.dart'; +import '../integrations/mattermost/mattermost_push_plugin_client.dart'; +import 'app.dart'; + +final _mattermostHost = MattermostPushHostIntegration( + pushClient: MattermostPushPluginClient(), +); + +Future applyFullscreenMode() async { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarDividerColor: Colors.transparent, + ), + ); +} + +Future bootstrapAltClient() async { + await applyFullscreenMode(); + await Firebase.initializeApp(); + await _mattermostHost.initialize(); +} + +Future runAltClient() async { + WidgetsFlutterBinding.ensureInitialized(); + await bootstrapAltClient(); + runApp(ProviderScope(child: AltClientApp(mattermostHost: _mattermostHost))); +} diff --git a/apps/client/lib/src/integrations/mattermost/mattermost_auth_service.dart b/apps/client/lib/src/integrations/mattermost/mattermost_auth_service.dart new file mode 100644 index 0000000..f25cbe9 --- /dev/null +++ b/apps/client/lib/src/integrations/mattermost/mattermost_auth_service.dart @@ -0,0 +1,161 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart' show FlutterError; +import 'package:flutter/services.dart' show rootBundle; +import 'package:http/http.dart' as http; + +import 'mattermost_push_client.dart'; + +/// Mattermost 자동 로그인 + FCM 디바이스 토큰 서버 등록 서비스. +class MattermostAuthService { + final MattermostPushClient _pushService; + + String? _serverUrl; + String? _serverIdentifier; + String? _authToken; + String? _userId; + String? _sessionId; + + String? get serverUrl => _serverUrl; + bool get isLoggedIn => _authToken != null; + + MattermostAuthService(this._pushService); + + /// assets/mattermost_credentials.json 로드 → 로그인 → FCM 등록까지 자동 수행. + Future autoLoginAndRegister() async { + final creds = await _loadCredentials(); + if (creds == null) { + print( + '[MattermostAuth] Credentials asset not found, skipping auto login.', + ); + return; + } + _serverUrl = creds['serverUrl']!; + _serverIdentifier = creds['serverId']; + + print('[MattermostAuth] Logging in to $_serverUrl ...'); + await _login(creds['loginId']!, creds['password']!); + + // FCM 토큰이 이미 있으면 바로 등록, 없으면 콜백으로 대기 + final existingToken = await _pushService.getDeviceToken(); + if (existingToken != null && existingToken.isNotEmpty) { + print('[MattermostAuth] FCM token already available, registering ...'); + await _registerDeviceToken(existingToken); + } else { + print( + '[MattermostAuth] FCM token not ready yet, waiting for callback ...', + ); + } + + // FCM 토큰 발급/갱신 시 자동으로 서버에 등록 + _pushService.onDeviceTokenReady = (deviceToken) async { + print('[MattermostAuth] FCM token ready, registering with server ...'); + await _registerDeviceToken(deviceToken); + }; + + print('[MattermostAuth] Auto login & FCM registration complete.'); + } + + Future?> _loadCredentials() async { + final String jsonStr; + try { + jsonStr = await rootBundle.loadString( + 'assets/mattermost_credentials.json', + ); + } on FlutterError { + return null; + } + final map = json.decode(jsonStr) as Map; + final serverId = (map['serverId'] ?? map['serverIdentifier']) as String?; + return { + 'serverUrl': map['serverUrl'] as String, + 'loginId': map['loginId'] as String, + 'password': map['password'] as String, + if (serverId != null && serverId.isNotEmpty) 'serverId': serverId, + }; + } + + /// POST /api/v4/users/login → Token 헤더에서 인증 토큰 추출. + Future _login(String loginId, String password) async { + final url = Uri.parse('$_serverUrl/api/v4/users/login'); + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: json.encode({'login_id': loginId, 'password': password}), + ); + + if (response.statusCode != 200) { + throw Exception( + '[MattermostAuth] Login failed (${response.statusCode}): ${response.body}', + ); + } + + _authToken = response.headers['token']; + if (_authToken == null) { + throw Exception('[MattermostAuth] Login response missing Token header'); + } + + final body = json.decode(response.body) as Map; + _userId = body['id'] as String?; + _sessionId = body['session_id'] as String? ?? body['id'] as String?; + + print('[MattermostAuth] Login OK - userId=$_userId sessionId=$_sessionId'); + + // 네이티브 측에 인증 토큰 저장 (ACK, 답장 등에 사용) + await _pushService.setAuthToken( + _serverUrl!, + _authToken!, + identifier: _serverIdentifier, + ); + + // 서버 config에서 signing key 가져와 네이티브에 저장 + await _fetchAndStoreSigningKey(); + } + + /// GET /api/v4/config/client?format=old → AsymmetricSigningPublicKey를 네이티브에 저장. + Future _fetchAndStoreSigningKey() async { + try { + final url = Uri.parse('$_serverUrl/api/v4/config/client?format=old'); + final response = await http.get( + url, + headers: {'Authorization': 'Bearer $_authToken'}, + ); + if (response.statusCode != 200) { + print( + '[MattermostAuth] Failed to fetch config (${response.statusCode})', + ); + return; + } + final config = json.decode(response.body) as Map; + final signingKey = config['AsymmetricSigningPublicKey'] as String?; + if (signingKey != null && signingKey.isNotEmpty) { + await _pushService.setSigningKey(_serverUrl!, signingKey); + print('[MattermostAuth] Signing key stored.'); + } else { + print('[MattermostAuth] No signing key in server config.'); + } + } catch (e) { + print('[MattermostAuth] Failed to fetch signing key: $e'); + } + } + + /// PUT /api/v4/users/sessions/device → device_id 등록. + Future _registerDeviceToken(String deviceToken) async { + final url = Uri.parse('$_serverUrl/api/v4/users/sessions/device'); + final response = await http.put( + url, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_authToken', + }, + body: json.encode({'device_id': deviceToken}), + ); + + if (response.statusCode == 200) { + print('[MattermostAuth] FCM device token registered successfully.'); + } else { + print( + '[MattermostAuth] FCM registration failed (${response.statusCode}): ${response.body}', + ); + } + } +} diff --git a/apps/client/lib/src/integrations/mattermost/mattermost_push_client.dart b/apps/client/lib/src/integrations/mattermost/mattermost_push_client.dart new file mode 100644 index 0000000..d160645 --- /dev/null +++ b/apps/client/lib/src/integrations/mattermost/mattermost_push_client.dart @@ -0,0 +1,25 @@ +/// Host-facing interface for the Mattermost push plugin. +/// +/// All production access to the platform plugin singleton is routed through +/// implementations of this interface. The host integration depends on this +/// abstraction so tests can inject fakes without booting Firebase/FCM. +abstract interface class MattermostPushClient { + Stream> get onNotification; + + Future initialize(); + Future getDeviceToken(); + Future setAuthToken( + String serverUrl, + String token, { + String? identifier, + }); + Future setSigningKey(String serverUrl, String signingKey); + + set onDeviceTokenReady(Future Function(String token)? callback); + set onNavigateToChannel( + void Function(String serverUrl, String channelId)? callback, + ); + set onNavigateToThread( + void Function(String serverUrl, String rootId)? callback, + ); +} diff --git a/apps/client/lib/src/integrations/mattermost/mattermost_push_host_integration.dart b/apps/client/lib/src/integrations/mattermost/mattermost_push_host_integration.dart new file mode 100644 index 0000000..a795b5b --- /dev/null +++ b/apps/client/lib/src/integrations/mattermost/mattermost_push_host_integration.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart' show debugPrint; + +import 'mattermost_auth_service.dart'; +import 'mattermost_push_client.dart'; + +/// Owns the Mattermost host/plugin responsibility boundary. +/// +/// The integration: +/// - initializes the push client exactly once, +/// - performs auto-login best-effort (skips silently when credentials are +/// unavailable, so app boot is not blocked), +/// - registers navigation callbacks in one place (not from a widget build), +/// - exposes the notification stream for app-level consumers. +class MattermostPushHostIntegration { + final MattermostPushClient pushClient; + final MattermostAuthService Function(MattermostPushClient client) + _authServiceFactory; + + bool _initialized = false; + + MattermostPushHostIntegration({ + required this.pushClient, + MattermostAuthService Function(MattermostPushClient client)? + authServiceFactory, + }) : _authServiceFactory = + authServiceFactory ?? + ((MattermostPushClient client) => MattermostAuthService(client)); + + Stream> get onNotification => pushClient.onNotification; + + bool get isInitialized => _initialized; + + /// Initialize plugin, perform auth handoff, and register navigation + /// callbacks. Safe to call once at bootstrap; subsequent calls are no-ops. + /// + /// [onNavigateToChannel] / [onNavigateToThread] are optional. When omitted, + /// the integration installs debug-logging fallbacks so the navigation path + /// remains observable without forcing the caller to wire routing. + Future initialize({ + void Function(String serverUrl, String channelId)? onNavigateToChannel, + void Function(String serverUrl, String rootId)? onNavigateToThread, + }) async { + if (_initialized) return; + _initialized = true; + + await pushClient.initialize(); + + final authService = _authServiceFactory(pushClient); + try { + await authService.autoLoginAndRegister(); + } catch (e) { + debugPrint('[MattermostHost] Mattermost auto-login failed: $e'); + } + + pushClient.onNavigateToChannel = + onNavigateToChannel ?? + (serverUrl, channelId) { + debugPrint( + '[MattermostHost] Navigate to channel: $channelId on $serverUrl', + ); + }; + pushClient.onNavigateToThread = + onNavigateToThread ?? + (serverUrl, rootId) { + debugPrint( + '[MattermostHost] Navigate to thread: $rootId on $serverUrl', + ); + }; + } +} diff --git a/apps/client/lib/src/integrations/mattermost/mattermost_push_plugin_client.dart b/apps/client/lib/src/integrations/mattermost/mattermost_push_plugin_client.dart new file mode 100644 index 0000000..f600f7d --- /dev/null +++ b/apps/client/lib/src/integrations/mattermost/mattermost_push_plugin_client.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:nexo_messaging/nexo_messaging.dart'; + +import 'mattermost_push_client.dart'; + +/// Production adapter wrapping the platform-channel singleton. +/// +/// This is the ONLY file in production that should reference +/// `NexoMessagingPlugin.instance`. Everything else depends on +/// [MattermostPushClient]. +class MattermostPushPluginClient implements MattermostPushClient { + final NexoMessagingPlugin _plugin; + + MattermostPushPluginClient({NexoMessagingPlugin? plugin}) + : _plugin = plugin ?? NexoMessagingPlugin.instance; + + @override + Stream> get onNotification => _plugin.onNotification; + + @override + Future initialize() => _plugin.initialize(); + + @override + Future getDeviceToken() => _plugin.getDeviceToken(); + + @override + Future setAuthToken( + String serverUrl, + String token, { + String? identifier, + }) => _plugin.setAuthToken(serverUrl, token, identifier: identifier); + + @override + Future setSigningKey(String serverUrl, String signingKey) => + _plugin.setSigningKey(serverUrl, signingKey); + + @override + set onDeviceTokenReady(Future Function(String token)? callback) { + _plugin.onDeviceTokenReady = callback == null + ? null + : (token) => unawaited(callback(token)); + } + + @override + set onNavigateToChannel( + void Function(String serverUrl, String channelId)? callback, + ) { + _plugin.onNavigateToChannel = callback; + } + + @override + set onNavigateToThread( + void Function(String serverUrl, String rootId)? callback, + ) { + _plugin.onNavigateToThread = callback; + } +} diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml index 36d0b9f..8cd1ab4 100644 --- a/apps/client/pubspec.yaml +++ b/apps/client/pubspec.yaml @@ -79,6 +79,11 @@ flutter: # the material Icons class. uses-material-design: true + assets: + # Local Mattermost smoke credentials live at assets/mattermost_credentials.json + # and are ignored by apps/client/.gitignore. + - assets/ + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/apps/client/test/integrations/mattermost_push_host_integration_test.dart b/apps/client/test/integrations/mattermost_push_host_integration_test.dart new file mode 100644 index 0000000..b9e8244 --- /dev/null +++ b/apps/client/test/integrations/mattermost_push_host_integration_test.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:alt_client/src/integrations/mattermost/mattermost_auth_service.dart'; +import 'package:alt_client/src/integrations/mattermost/mattermost_push_client.dart'; +import 'package:alt_client/src/integrations/mattermost/mattermost_push_host_integration.dart'; + +class _FakePushClient implements MattermostPushClient { + int initializeCalls = 0; + int navigateChannelAssignments = 0; + int navigateThreadAssignments = 0; + + final StreamController> _controller = + StreamController>.broadcast(); + + void Function(String serverUrl, String channelId)? _navigateChannel; + void Function(String serverUrl, String rootId)? _navigateThread; + + @override + Stream> get onNotification => _controller.stream; + + void emit(Map data) => _controller.add(data); + + Future dispose() => _controller.close(); + + @override + Future initialize() async { + initializeCalls += 1; + } + + @override + Future getDeviceToken() async => null; + + @override + Future setAuthToken( + String serverUrl, + String token, { + String? identifier, + }) async {} + + @override + Future setSigningKey(String serverUrl, String signingKey) async {} + + @override + set onDeviceTokenReady(Future Function(String token)? callback) {} + + @override + set onNavigateToChannel( + void Function(String serverUrl, String channelId)? callback, + ) { + navigateChannelAssignments += 1; + _navigateChannel = callback; + } + + @override + set onNavigateToThread( + void Function(String serverUrl, String rootId)? callback, + ) { + navigateThreadAssignments += 1; + _navigateThread = callback; + } + + void triggerNavigateChannel(String serverUrl, String channelId) { + _navigateChannel?.call(serverUrl, channelId); + } + + void triggerNavigateThread(String serverUrl, String rootId) { + _navigateThread?.call(serverUrl, rootId); + } +} + +class _NoopAuthService implements MattermostAuthService { + int autoLoginCalls = 0; + final bool throwOnLogin; + + _NoopAuthService({this.throwOnLogin = false}); + + @override + Future autoLoginAndRegister() async { + autoLoginCalls += 1; + if (throwOnLogin) { + throw StateError('credentials missing'); + } + } + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('initialize calls push client init exactly once', () async { + final push = _FakePushClient(); + final auth = _NoopAuthService(); + final host = MattermostPushHostIntegration( + pushClient: push, + authServiceFactory: (_) => auth, + ); + + await host.initialize(); + await host.initialize(); + + expect(push.initializeCalls, equals(1)); + expect(auth.autoLoginCalls, equals(1)); + expect(host.isInitialized, isTrue); + + await push.dispose(); + }); + + test('auto-login failure does not block initialize', () async { + final push = _FakePushClient(); + final auth = _NoopAuthService(throwOnLogin: true); + final host = MattermostPushHostIntegration( + pushClient: push, + authServiceFactory: (_) => auth, + ); + + await host.initialize(); + + expect(host.isInitialized, isTrue); + expect(push.initializeCalls, equals(1)); + + await push.dispose(); + }); + + test( + 'navigation callbacks are assigned exactly once during initialize', + () async { + final push = _FakePushClient(); + final host = MattermostPushHostIntegration( + pushClient: push, + authServiceFactory: (_) => _NoopAuthService(), + ); + + String? capturedChannel; + String? capturedThread; + await host.initialize( + onNavigateToChannel: (_, channelId) => capturedChannel = channelId, + onNavigateToThread: (_, rootId) => capturedThread = rootId, + ); + + expect(push.navigateChannelAssignments, equals(1)); + expect(push.navigateThreadAssignments, equals(1)); + + push.triggerNavigateChannel('https://srv', 'channel-1'); + push.triggerNavigateThread('https://srv', 'root-1'); + + expect(capturedChannel, equals('channel-1')); + expect(capturedThread, equals('root-1')); + + await push.dispose(); + }, + ); + + test('notification stream remains consumable through integration', () async { + final push = _FakePushClient(); + final host = MattermostPushHostIntegration( + pushClient: push, + authServiceFactory: (_) => _NoopAuthService(), + ); + await host.initialize(); + + final received = >[]; + final sub = host.onNotification.listen(received.add); + + push.emit(const {'type': 'message', 'message': 'hi'}); + await Future.delayed(Duration.zero); + + expect(received, hasLength(1)); + expect(received.first['message'], equals('hi')); + + await sub.cancel(); + await push.dispose(); + }); +} diff --git a/services/api/internal/socket/handlers.go b/services/api/internal/socket/handlers.go new file mode 100644 index 0000000..cd3acc2 --- /dev/null +++ b/services/api/internal/socket/handlers.go @@ -0,0 +1,66 @@ +package socket + +import ( + protoSocket "git.toki-labs.com/toki/proto-socket/go" + + altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" +) + +// sessionHandler is one request-response unit attached to a freshly connected +// client communicator. Splitting registration into independent units keeps the +// api hub control-plane surface thin: later command/query handlers attach +// through the same registry instead of growing a single registration function. +type sessionHandler struct { + // requestType is the ALT protobuf request type name the handler answers. + // It lets the registry detect duplicate or missing handler coverage without + // a live socket connection. + requestType string + // register attaches the handler onto the client communicator. + register func(*protoSocket.WsClient) +} + +// sessionHandlers returns the ordered set of handlers registered on every API +// client session. Hello is registered through this registry so its behaviour +// stays identical while gaining the shared registration path. +func sessionHandlers() []sessionHandler { + return []sessionHandler{ + helloHandler(), + } +} + +// registerSessionHandlers wires every session handler onto a newly connected +// client. nil registrars are skipped so a malformed registry entry cannot +// panic the connection setup path. +func registerSessionHandlers(client *protoSocket.WsClient) { + for _, handler := range sessionHandlers() { + if handler.register == nil { + continue + } + handler.register(client) + } +} + +func helloHandler() sessionHandler { + return sessionHandler{ + requestType: protoSocket.TypeNameOf(&altv1.HelloRequest{}), + register: func(client *protoSocket.WsClient) { + protoSocket.AddRequestListenerTyped[*altv1.HelloRequest, *altv1.HelloResponse](&client.Communicator, handleHello) + }, + } +} + +func handleHello(req *altv1.HelloRequest) (*altv1.HelloResponse, error) { + protocolVersion := req.GetAltProtocolVersion() + if protocolVersion == "" { + protocolVersion = defaultAltProtocolVersion + } + return &altv1.HelloResponse{ + ServerName: serverName, + ServerVersion: serverVersion, + AltProtocolVersion: protocolVersion, + Capabilities: []string{ + "hello", + "request-response", + }, + }, nil +} diff --git a/services/api/internal/socket/server.go b/services/api/internal/socket/server.go index 408362c..d352886 100644 --- a/services/api/internal/socket/server.go +++ b/services/api/internal/socket/server.go @@ -4,7 +4,6 @@ import ( protoSocket "git.toki-labs.com/toki/proto-socket/go" "nhooyr.io/websocket" - altv1 "git.toki-labs.com/toki/alt/packages/contracts/gen/go/alt/v1" "git.toki-labs.com/toki/alt/services/api/internal/config" apiContracts "git.toki-labs.com/toki/alt/services/api/internal/contracts" ) @@ -28,21 +27,3 @@ func NewServer(cfg config.Config) *protoSocket.WsServer { server.OnClientConnected = registerSessionHandlers return server } - -func registerSessionHandlers(client *protoSocket.WsClient) { - protoSocket.AddRequestListenerTyped[*altv1.HelloRequest, *altv1.HelloResponse](&client.Communicator, func(req *altv1.HelloRequest) (*altv1.HelloResponse, error) { - protocolVersion := req.GetAltProtocolVersion() - if protocolVersion == "" { - protocolVersion = defaultAltProtocolVersion - } - return &altv1.HelloResponse{ - ServerName: serverName, - ServerVersion: serverVersion, - AltProtocolVersion: protocolVersion, - Capabilities: []string{ - "hello", - "request-response", - }, - }, nil - }) -} diff --git a/services/api/internal/socket/server_test.go b/services/api/internal/socket/server_test.go index 7efa9f2..6e112d6 100644 --- a/services/api/internal/socket/server_test.go +++ b/services/api/internal/socket/server_test.go @@ -62,6 +62,55 @@ func TestServerRespondsToHelloRequest(t *testing.T) { } } +func TestSessionHandlersHaveUniqueRequestTypes(t *testing.T) { + seen := make(map[string]int) + for _, handler := range sessionHandlers() { + if handler.requestType == "" { + t.Errorf("session handler has empty request type") + continue + } + seen[handler.requestType]++ + } + + for requestType, count := range seen { + if count > 1 { + t.Errorf("request type %q registered %d times; duplicate handlers would panic the communicator", requestType, count) + } + } +} + +func TestSessionHandlersCoverRequiredRequests(t *testing.T) { + registered := make(map[string]bool) + for _, handler := range sessionHandlers() { + registered[handler.requestType] = true + } + + required := []string{ + protoSocket.TypeNameOf(&altv1.HelloRequest{}), + } + for _, requestType := range required { + if !registered[requestType] { + t.Errorf("required handler for %q is not registered", requestType) + } + } +} + +func TestRegisterSessionHandlersSkipsNilRegistrar(t *testing.T) { + // registerSessionHandlers must tolerate a malformed registry entry instead + // of panicking during connection setup. + defer func() { + if r := recover(); r != nil { + t.Fatalf("registerSessionHandlers panicked on nil registrar: %v", r) + } + }() + + handler := sessionHandler{requestType: "alt.v1.NilRegistrarProbe", register: nil} + if handler.register != nil { + t.Fatal("expected nil registrar for probe handler") + } + registerSessionHandlers(nil) +} + func freeTCPPort(t *testing.T) int { t.Helper()