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
This commit is contained in:
toki 2026-05-30 18:34:50 +09:00
parent e36d7f3141
commit a8a0f691cf
35 changed files with 1663 additions and 34 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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을 둔다.

View file

@ -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`
## 선택 규칙

View file

@ -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으로 두지 않고 외부 프로젝트 산출물로 추후 반영한다.

View file

@ -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
- 확인 필요: 없음

View file

@ -0,0 +1,25 @@
<!-- task=m-api-centered-proto-socket-rail/01_contracts_api_registry plan=0 tag=API -->
# CODE_REVIEW-cloud-G07: Contracts and API Handler Registry Foundation
## 구현 에이전트 소유 섹션
- 구현 요약: 미작성
- 변경 파일: 미작성
- 실행한 검증/명령: 미작성
- 남은 위험/후속 작업: 미작성
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 코드 리뷰어 소유 섹션
- 리뷰 상태: 미작성
- 주요 발견사항: 미작성
- 테스트/검증 평가: 미작성
- 판정: 미작성

View file

@ -0,0 +1,132 @@
<!-- task=m-api-centered-proto-socket-rail/01_contracts_api_registry plan=0 tag=API -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,25 @@
<!-- task=m-api-centered-proto-socket-rail/02+01_worker_socket_rail plan=0 tag=WORKER -->
# CODE_REVIEW-cloud-G08: API to Worker Proto-Socket Rail
## 구현 에이전트 소유 섹션
- 구현 요약: 미작성
- 변경 파일: 미작성
- 실행한 검증/명령: 미작성
- 남은 위험/후속 작업: 미작성
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 코드 리뷰어 소유 섹션
- 리뷰 상태: 미작성
- 주요 발견사항: 미작성
- 테스트/검증 평가: 미작성
- 판정: 미작성

View file

@ -0,0 +1,139 @@
<!-- task=m-api-centered-proto-socket-rail/02+01_worker_socket_rail plan=0 tag=WORKER -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,25 @@
<!-- task=m-api-centered-proto-socket-rail/03+01_client_api_wrappers plan=0 tag=CLIENT -->
# CODE_REVIEW-cloud-G07: Flutter Client API Proto-Socket Wrappers
## 구현 에이전트 소유 섹션
- 구현 요약: 미작성
- 변경 파일: 미작성
- 실행한 검증/명령: 미작성
- 남은 위험/후속 작업: 미작성
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 코드 리뷰어 소유 섹션
- 리뷰 상태: 미작성
- 주요 발견사항: 미작성
- 테스트/검증 평가: 미작성
- 판정: 미작성

View file

@ -0,0 +1,128 @@
<!-- task=m-api-centered-proto-socket-rail/03+01_client_api_wrappers plan=0 tag=CLIENT -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,25 @@
<!-- task=m-api-centered-proto-socket-rail/04+02_backtest_rail plan=0 tag=BACKTEST -->
# CODE_REVIEW-cloud-G08: Backtest API to Worker Proto-Socket Rail
## 구현 에이전트 소유 섹션
- 구현 요약: 미작성
- 변경 파일: 미작성
- 실행한 검증/명령: 미작성
- 남은 위험/후속 작업: 미작성
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 코드 리뷰어 소유 섹션
- 리뷰 상태: 미작성
- 주요 발견사항: 미작성
- 테스트/검증 평가: 미작성
- 판정: 미작성

View file

@ -0,0 +1,135 @@
<!-- task=m-api-centered-proto-socket-rail/04+02_backtest_rail plan=0 tag=BACKTEST -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,25 @@
<!-- task=m-api-centered-proto-socket-rail/05+02_market_rail plan=0 tag=MARKET -->
# CODE_REVIEW-cloud-G07: Market Data API to Worker Proto-Socket Rail
## 구현 에이전트 소유 섹션
- 구현 요약: 미작성
- 변경 파일: 미작성
- 실행한 검증/명령: 미작성
- 남은 위험/후속 작업: 미작성
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 외부 환경 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. code-review가 이 내용을 검증해 `USER_REVIEW.md`를 작성한다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 코드 리뷰어 소유 섹션
- 리뷰 상태: 미작성
- 주요 발견사항: 미작성
- 테스트/검증 평가: 미작성
- 판정: 미작성

View file

@ -0,0 +1,130 @@
<!-- task=m-api-centered-proto-socket-rail/05+02_market_rail plan=0 tag=MARKET -->
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# Mattermost credentials
assets/mattermost_credentials.json

View file

@ -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")
}

View file

@ -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"
}

View file

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="ALT"
android:name="${applicationName}"

View file

@ -1,3 +1,13 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.google.gms:google-services:4.4.2")
}
}
allprojects {
repositories {
google()

View file

@ -0,0 +1 @@

View file

@ -1,8 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'src/app/bootstrap.dart';
import 'src/app/app.dart';
void main() {
runApp(const ProviderScope(child: AltClientApp()));
}
Future<void> main() => runAltClient();

View file

@ -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;
}

View file

@ -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<void> applyFullscreenMode() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarDividerColor: Colors.transparent,
),
);
}
Future<void> bootstrapAltClient() async {
await applyFullscreenMode();
await Firebase.initializeApp();
await _mattermostHost.initialize();
}
Future<void> runAltClient() async {
WidgetsFlutterBinding.ensureInitialized();
await bootstrapAltClient();
runApp(ProviderScope(child: AltClientApp(mattermostHost: _mattermostHost)));
}

View file

@ -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<void> 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<Map<String, String>?> _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<String, dynamic>;
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<void> _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<String, dynamic>;
_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<void> _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<String, dynamic>;
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<void> _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}',
);
}
}
}

View file

@ -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<Map<String, dynamic>> get onNotification;
Future<void> initialize();
Future<String?> getDeviceToken();
Future<void> setAuthToken(
String serverUrl,
String token, {
String? identifier,
});
Future<void> setSigningKey(String serverUrl, String signingKey);
set onDeviceTokenReady(Future<void> Function(String token)? callback);
set onNavigateToChannel(
void Function(String serverUrl, String channelId)? callback,
);
set onNavigateToThread(
void Function(String serverUrl, String rootId)? callback,
);
}

View file

@ -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<Map<String, dynamic>> 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<void> 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',
);
};
}
}

View file

@ -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<Map<String, dynamic>> get onNotification => _plugin.onNotification;
@override
Future<void> initialize() => _plugin.initialize();
@override
Future<String?> getDeviceToken() => _plugin.getDeviceToken();
@override
Future<void> setAuthToken(
String serverUrl,
String token, {
String? identifier,
}) => _plugin.setAuthToken(serverUrl, token, identifier: identifier);
@override
Future<void> setSigningKey(String serverUrl, String signingKey) =>
_plugin.setSigningKey(serverUrl, signingKey);
@override
set onDeviceTokenReady(Future<void> 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;
}
}

View file

@ -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

View file

@ -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<Map<String, dynamic>> _controller =
StreamController<Map<String, dynamic>>.broadcast();
void Function(String serverUrl, String channelId)? _navigateChannel;
void Function(String serverUrl, String rootId)? _navigateThread;
@override
Stream<Map<String, dynamic>> get onNotification => _controller.stream;
void emit(Map<String, dynamic> data) => _controller.add(data);
Future<void> dispose() => _controller.close();
@override
Future<void> initialize() async {
initializeCalls += 1;
}
@override
Future<String?> getDeviceToken() async => null;
@override
Future<void> setAuthToken(
String serverUrl,
String token, {
String? identifier,
}) async {}
@override
Future<void> setSigningKey(String serverUrl, String signingKey) async {}
@override
set onDeviceTokenReady(Future<void> 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<void> 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 = <Map<String, dynamic>>[];
final sub = host.onNotification.listen(received.add);
push.emit(const {'type': 'message', 'message': 'hi'});
await Future<void>.delayed(Duration.zero);
expect(received, hasLength(1));
expect(received.first['message'], equals('hi'));
await sub.cancel();
await push.dispose();
});
}

View file

@ -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
}

View file

@ -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
})
}

View file

@ -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()