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:
parent
e36d7f3141
commit
a8a0f691cf
35 changed files with 1663 additions and 34 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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을 둔다.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
## 선택 규칙
|
||||
|
||||
|
|
|
|||
|
|
@ -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으로 두지 않고 외부 프로젝트 산출물로 추후 반영한다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- 확인 필요: 없음
|
||||
|
|
@ -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`를 작성한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 코드 리뷰어 소유 섹션
|
||||
- 리뷰 상태: 미작성
|
||||
- 주요 발견사항: 미작성
|
||||
- 테스트/검증 평가: 미작성
|
||||
- 판정: 미작성
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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`를 작성한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 코드 리뷰어 소유 섹션
|
||||
- 리뷰 상태: 미작성
|
||||
- 주요 발견사항: 미작성
|
||||
- 테스트/검증 평가: 미작성
|
||||
- 판정: 미작성
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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`를 작성한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 코드 리뷰어 소유 섹션
|
||||
- 리뷰 상태: 미작성
|
||||
- 주요 발견사항: 미작성
|
||||
- 테스트/검증 평가: 미작성
|
||||
- 판정: 미작성
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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`를 작성한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 코드 리뷰어 소유 섹션
|
||||
- 리뷰 상태: 미작성
|
||||
- 주요 발견사항: 미작성
|
||||
- 테스트/검증 평가: 미작성
|
||||
- 판정: 미작성
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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`를 작성한다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 코드 리뷰어 소유 섹션
|
||||
- 리뷰 상태: 미작성
|
||||
- 주요 발견사항: 미작성
|
||||
- 테스트/검증 평가: 미작성
|
||||
- 판정: 미작성
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
3
apps/client/.gitignore
vendored
3
apps/client/.gitignore
vendored
|
|
@ -43,3 +43,6 @@ app.*.map.json
|
|||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Mattermost credentials
|
||||
assets/mattermost_credentials.json
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
29
apps/client/android/app/google-services.json
Normal file
29
apps/client/android/app/google-services.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.google.gms:google-services:4.4.2")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
|
|
|
|||
1
apps/client/assets/.gitkeep
Normal file
1
apps/client/assets/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
35
apps/client/lib/src/app/bootstrap.dart
Normal file
35
apps/client/lib/src/app/bootstrap.dart
Normal 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)));
|
||||
}
|
||||
|
|
@ -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}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
66
services/api/internal/socket/handlers.go
Normal file
66
services/api/internal/socket/handlers.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue