| author | |
| committer | |
| log | 7d115334902046e42e843779f91e275ff1c96a27 |
| tree | ce2d86f4db4d05de06624138020529b25c48f12c |
| parent | 1031a226f8cf90d358d9768fc0842dcdae61a315 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>47 files changed, 2255 insertions(+), 624 deletions(-)
.docs/architecture.md+125-3| ... | @@ -5,17 +5,139 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS | ... | @@ -5,17 +5,139 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS |
| 5 | ``` | 5 | ``` |
| 6 | ┌─────────────────────────────────┐ | 6 | ┌─────────────────────────────────┐ |
| 7 | │ Browser (React + Vite) │ | 7 | │ Browser (React + Vite) │ |
| 8 | │ Connected via WebSocket │ | 8 | │ wsTransport (state machine) │ |
| 9 | │ Typed push decode at boundary │ | ||
| 9 | └──────────┬──────────────────────┘ | 10 | └──────────┬──────────────────────┘ |
| 10 | │ ws://localhost:3773 | 11 | │ ws://localhost:3773 |
| 11 | ┌──────────▼──────────────────────┐ | 12 | ┌──────────▼──────────────────────┐ |
| 12 | │ apps/server (Node.js) │ | 13 | │ apps/server (Node.js) │ |
| 13 | │ WebSocket + HTTP static server │ | 14 | │ WebSocket + HTTP static server │ |
| 14 | │ ProviderManager │ | 15 | │ ServerPushBus (ordered pushes) │ |
| 15 | │ CodexAppServerManager │ | 16 | │ ServerReadiness (startup gate) │ |
| 17 | │ OrchestrationEngine │ | ||
| 18 | │ ProviderService │ | ||
| 19 | │ CheckpointReactor │ | ||
| 20 | │ RuntimeReceiptBus │ | ||
| 16 | └──────────┬──────────────────────┘ | 21 | └──────────┬──────────────────────┘ |
| 17 | │ JSON-RPC over stdio | 22 | │ JSON-RPC over stdio |
| 18 | ┌──────────▼──────────────────────┐ | 23 | ┌──────────▼──────────────────────┐ |
| 19 | │ codex app-server │ | 24 | │ codex app-server │ |
| 20 | └─────────────────────────────────┘ | 25 | └─────────────────────────────────┘ |
| 21 | ``` | 26 | ``` |
| 27 | |||
| 28 | ## Components | ||
| 29 | |||
| 30 | - **Browser app**: The React app renders session state, owns the client-side WebSocket transport, and treats typed push events as the boundary between server runtime details and UI state. | ||
| 31 | |||
| 32 | - **Server**: `apps/server` is the main coordinator. It serves the web app, accepts WebSocket requests, waits for startup readiness before welcoming clients, and sends all outbound pushes through a single ordered push path. | ||
| 33 | |||
| 34 | - **Provider runtime**: `codex app-server` does the actual provider/session work. The server talks to it over JSON-RPC on stdio and translates those runtime events into the app's orchestration model. | ||
| 35 | |||
| 36 | - **Background workers**: Long-running async flows such as runtime ingestion, command reaction, and checkpoint processing run as queue-backed workers. This keeps work ordered, reduces timing races, and gives tests a deterministic way to wait for the system to go idle. | ||
| 37 | |||
| 38 | - **Runtime signals**: The server emits lightweight typed receipts when important async milestones finish, such as checkpoint capture, diff finalization, or a turn becoming fully quiescent. Tests and orchestration code wait on these signals instead of polling internal state. | ||
| 39 | |||
| 40 | ## Event Lifecycle | ||
| 41 | |||
| 42 | ### Startup and client connect | ||
| 43 | |||
| 44 | ```mermaid | ||
| 45 | sequenceDiagram | ||
| 46 | participant Browser | ||
| 47 | participant Transport as WsTransport | ||
| 48 | participant Server as wsServer | ||
| 49 | participant Layers as serverLayers | ||
| 50 | participant Ready as ServerReadiness | ||
| 51 | participant Push as ServerPushBus | ||
| 52 | |||
| 53 | Browser->>Transport: Load app and open WebSocket | ||
| 54 | Transport->>Server: Connect | ||
| 55 | Server->>Layers: Start runtime services | ||
| 56 | Server->>Ready: Wait for startup barriers | ||
| 57 | Ready-->>Server: Ready | ||
| 58 | Server->>Push: Publish server.welcome | ||
| 59 | Push-->>Transport: Ordered welcome push | ||
| 60 | Transport-->>Browser: Hydrate initial state | ||
| 61 | ``` | ||
| 62 | |||
| 63 | 1. The browser boots [`WsTransport`][1] and registers typed listeners in [`wsNativeApi`][2]. | ||
| 64 | 2. The server accepts the connection in [`wsServer`][3] and brings up the runtime graph defined in [`serverLayers`][7]. | ||
| 65 | 3. [`ServerReadiness`][4] waits until the key startup barriers are complete. | ||
| 66 | 4. Once the server is ready, [`wsServer`][3] sends `server.welcome` from the contracts in [`ws.ts`][6] through [`ServerPushBus`][5]. | ||
| 67 | 5. The browser receives that ordered push through [`WsTransport`][1], and [`wsNativeApi`][2] uses it to seed local client state. | ||
| 68 | |||
| 69 | ### User turn flow | ||
| 70 | |||
| 71 | ```mermaid | ||
| 72 | sequenceDiagram | ||
| 73 | participant Browser | ||
| 74 | participant Transport as WsTransport | ||
| 75 | participant Server as wsServer | ||
| 76 | participant Provider as ProviderService | ||
| 77 | participant Codex as codex app-server | ||
| 78 | participant Ingest as ProviderRuntimeIngestion | ||
| 79 | participant Engine as OrchestrationEngine | ||
| 80 | participant Push as ServerPushBus | ||
| 81 | |||
| 82 | Browser->>Transport: Send user action | ||
| 83 | Transport->>Server: Typed WebSocket request | ||
| 84 | Server->>Provider: Route request | ||
| 85 | Provider->>Codex: JSON-RPC over stdio | ||
| 86 | Codex-->>Ingest: Provider runtime events | ||
| 87 | Ingest->>Engine: Normalize into orchestration events | ||
| 88 | Engine-->>Server: Domain events | ||
| 89 | Server->>Push: Publish orchestration.domainEvent | ||
| 90 | Push-->>Browser: Typed push | ||
| 91 | ``` | ||
| 92 | |||
| 93 | 1. A user action in the browser becomes a typed request through [`WsTransport`][1] and the browser API layer in [`nativeApi`][12]. | ||
| 94 | 2. [`wsServer`][3] decodes that request using the shared WebSocket contracts in [`ws.ts`][6] and routes it to the right service. | ||
| 95 | 3. [`ProviderService`][8] starts or resumes a session and talks to `codex app-server` over JSON-RPC on stdio. | ||
| 96 | 4. Provider-native events are pulled back into the server by [`ProviderRuntimeIngestion`][9], which converts them into orchestration events. | ||
| 97 | 5. [`OrchestrationEngine`][10] persists those events, updates the read model, and exposes them as domain events. | ||
| 98 | 6. [`wsServer`][3] pushes those updates to the browser through [`ServerPushBus`][5] on channels defined in [`orchestration.ts`][11]. | ||
| 99 | |||
| 100 | ### Async completion flow | ||
| 101 | |||
| 102 | ```mermaid | ||
| 103 | sequenceDiagram | ||
| 104 | participant Server as wsServer | ||
| 105 | participant Worker as Queue-backed workers | ||
| 106 | participant Cmd as ProviderCommandReactor | ||
| 107 | participant Checkpoint as CheckpointReactor | ||
| 108 | participant Receipt as RuntimeReceiptBus | ||
| 109 | participant Push as ServerPushBus | ||
| 110 | participant Browser | ||
| 111 | |||
| 112 | Server->>Worker: Enqueue follow-up work | ||
| 113 | Worker->>Cmd: Process provider commands | ||
| 114 | Worker->>Checkpoint: Process checkpoint tasks | ||
| 115 | Checkpoint->>Receipt: Publish completion receipt | ||
| 116 | Cmd-->>Server: Produce orchestration changes | ||
| 117 | Checkpoint-->>Server: Produce orchestration changes | ||
| 118 | Server->>Push: Publish resulting state updates | ||
| 119 | Push-->>Browser: User-visible push | ||
| 120 | ``` | ||
| 121 | |||
| 122 | 1. Some work continues after the initial request returns, especially in [`ProviderRuntimeIngestion`][9], [`ProviderCommandReactor`][13], and [`CheckpointReactor`][14]. | ||
| 123 | 2. These flows run as queue-backed workers using [`DrainableWorker`][16], which helps keep side effects ordered and test synchronization deterministic. | ||
| 124 | 3. When a milestone completes, the server emits a typed receipt on [`RuntimeReceiptBus`][15], such as checkpoint completion or turn quiescence. | ||
| 125 | 4. Tests and orchestration code wait on those receipts instead of polling git state, projections, or timers. | ||
| 126 | 5. Any user-visible state changes produced by that async work still go back through [`wsServer`][3] and [`ServerPushBus`][5]. | ||
| 127 | |||
| 128 | [1]: ../apps/web/src/wsTransport.ts | ||
| 129 | [2]: ../apps/web/src/wsNativeApi.ts | ||
| 130 | [3]: ../apps/server/src/wsServer.ts | ||
| 131 | [4]: ../apps/server/src/wsServer/readiness.ts | ||
| 132 | [5]: ../apps/server/src/wsServer/pushBus.ts | ||
| 133 | [6]: ../packages/contracts/src/ws.ts | ||
| 134 | [7]: ../apps/server/src/serverLayers.ts | ||
| 135 | [8]: ../apps/server/src/provider/Layers/ProviderService.ts | ||
| 136 | [9]: ../apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts | ||
| 137 | [10]: ../apps/server/src/orchestration/Layers/OrchestrationEngine.ts | ||
| 138 | [11]: ../packages/contracts/src/orchestration.ts | ||
| 139 | [12]: ../apps/web/src/nativeApi.ts | ||
| 140 | [13]: ../apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | ||
| 141 | [14]: ../apps/server/src/orchestration/Layers/CheckpointReactor.ts | ||
| 142 | [15]: ../apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts | ||
| 143 | [16]: ../packages/shared/src/DrainableWorker.ts |
.docs/provider-architecture.md+17-1| ... | @@ -3,7 +3,9 @@ | ... | @@ -3,7 +3,9 @@ |
| 3 | The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: | 3 | The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: |
| 4 | 4 | ||
| 5 | - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` | 5 | - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` |
| 6 | - **Push events**: `{ type: "push", channel, data }` for orchestration read-model updates | 6 | - **Push events**: typed envelopes with `channel`, `sequence` (monotonic per connection), and channel-specific `data` |
| 7 | |||
| 8 | Push channels: `server.welcome`, `server.configUpdated`, `terminal.event`, `orchestration.domainEvent`. Payloads are schema-validated at the transport boundary (`wsTransport.ts`). Decode failures produce structured `WsDecodeDiagnostic` with `code`, `reason`, and path info. | ||
| 7 | 9 | ||
| 8 | Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: | 10 | Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: |
| 9 | 11 | ||
| ... | @@ -12,3 +14,17 @@ Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: | ... | @@ -12,3 +14,17 @@ Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: |
| 12 | - `shell.openInEditor`, `server.getConfig` | 14 | - `shell.openInEditor`, `server.getConfig` |
| 13 | 15 | ||
| 14 | Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. | 16 | Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. |
| 17 | |||
| 18 | ## Client transport | ||
| 19 | |||
| 20 | `wsTransport.ts` manages connection state: `connecting` → `open` → `reconnecting` → `closed` → `disposed`. Outbound requests are queued while disconnected and flushed on reconnect. Inbound pushes are decoded and validated at the boundary, then cached per channel. Subscribers can opt into `replayLatest` to receive the last push on subscribe. | ||
| 21 | |||
| 22 | ## Server-side orchestration layers | ||
| 23 | |||
| 24 | Provider runtime events flow through queue-based workers: | ||
| 25 | |||
| 26 | 1. **ProviderRuntimeIngestion** — consumes provider runtime streams, emits orchestration commands | ||
| 27 | 2. **ProviderCommandReactor** — reacts to orchestration intent events, dispatches provider calls | ||
| 28 | 3. **CheckpointReactor** — captures git checkpoints on turn start/complete, publishes runtime receipts | ||
| 29 | |||
| 30 | All three use `DrainableWorker` internally and expose `drain()` for deterministic test synchronization. |
.docs/workspace-layout.md+2-1| ... | @@ -3,4 +3,5 @@ | ... | @@ -3,4 +3,5 @@ |
| 3 | - `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built web app, and opens the browser on start. | 3 | - `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built web app, and opens the browser on start. |
| 4 | - `/apps/web`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. | 4 | - `/apps/web`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. |
| 5 | - `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared web app. | 5 | - `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared web app. |
| 6 | - `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. | 6 | - `/packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. |
| 7 | - `/packages/shared`: Shared runtime utilities consumed by both server and web. Uses explicit subpath exports (e.g. `@t3tools/shared/git`, `@t3tools/shared/DrainableWorker`) — no barrel index. |
.mise.toml created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | [tools] | ||
| 2 | node = "24.13.1" | ||
| 3 | bun = "1.3.9" | ||
.plans/17-provider-neutral-runtime-determinism.md created+109| ... | @@ -0,0 +1,109 @@ | ||
| 1 | # Plan: Provider-Neutral Runtime Determinism and Flake Elimination | ||
| 2 | |||
| 3 | ## Summary | ||
| 4 | Replace timing-sensitive websocket and orchestration behavior with explicit typed runtime boundaries, ordered push delivery, and server-owned completion receipts. The cutover is broad and single-shot: no compatibility shim, no mixed old/new transport. The design must reduce flakes without baking Codex-specific lifecycle semantics into generic runtime code. | ||
| 5 | |||
| 6 | ## Implementation Status | ||
| 7 | |||
| 8 | All 7 sections are implemented. CI passes (format, lint, typecheck, test, browser test, build). One deferred item remains: the shared `WsTestClient` helper from section 7 — tests use direct transport subscription and receipt-based waits instead. | ||
| 9 | |||
| 10 | ### New files | ||
| 11 | |||
| 12 | | File | Purpose | | ||
| 13 | |------|---------| | ||
| 14 | | `packages/shared/src/DrainableWorker.ts` | Queue-based Effect worker with deterministic `drain` signal | | ||
| 15 | | `packages/shared/src/schemaJson.ts` | Two-phase JSON→Schema decode helpers (`decodeJsonResult`, `formatSchemaError`) | | ||
| 16 | | `apps/server/src/wsServer/pushBus.ts` | `ServerPushBus` — ordered typed push pipeline with auto-incrementing sequence | | ||
| 17 | | `apps/server/src/wsServer/readiness.ts` | `ServerReadiness` — Deferred-based barriers for startup sequencing | | ||
| 18 | | `apps/server/src/orchestration/Services/RuntimeReceiptBus.ts` | Receipt schema union: checkpoint captured, diff finalized, turn quiesced | | ||
| 19 | | `apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts` | PubSub-backed receipt bus implementation | | ||
| 20 | | `apps/server/src/watchFileWithStatPolling.ts` | Stat-polling file watcher for containers where `fs.watch` is unreliable | | ||
| 21 | | `apps/server/vitest.config.ts` | Server-specific test config (timeout bumps) | | ||
| 22 | | `apps/server/src/wsServer/pushBus.test.ts` | Push bus serialization and welcome-gating tests | | ||
| 23 | | `packages/shared/src/DrainableWorker.test.ts` | Drainable worker enqueue/drain lifecycle tests | | ||
| 24 | |||
| 25 | ### Key modifications | ||
| 26 | |||
| 27 | | File | Change | | ||
| 28 | |------|--------| | ||
| 29 | | `packages/contracts/src/ws.ts` | Channel-indexed `WsPushPayloadByChannel` map, `WsPush` union schema, `WsPushSequence` | | ||
| 30 | | `apps/server/src/wsServer.ts` | Integrated `ServerPushBus` and `ServerReadiness`; welcome gated on readiness | | ||
| 31 | | `apps/server/src/keybindings.ts` | Explicit runtime with `start`/`ready`/`snapshot`; dual `fs.watch` + stat-polling watcher | | ||
| 32 | | `apps/web/src/wsTransport.ts` | Connection state machine (`connecting`→`open`→`reconnecting`→`closed`→`disposed`); two-phase decode at boundary; cached latest push by channel | | ||
| 33 | | `apps/web/src/wsNativeApi.ts` | Removed decode logic; delegates to pre-validated transport messages | | ||
| 34 | | `apps/server/src/orchestration/Layers/CheckpointReactor.ts` | Uses `DrainableWorker`; publishes completion receipts | | ||
| 35 | | `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` | Uses `DrainableWorker` for command processing | | ||
| 36 | | `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` | Uses `DrainableWorker` for event ingestion | | ||
| 37 | | `apps/server/integration/OrchestrationEngineHarness.integration.ts` | Receipt-based waits replace polling loops | | ||
| 38 | |||
| 39 | ## Key Changes | ||
| 40 | ### 1. Strengthen the generic boundaries, not the Codex boundary — DONE | ||
| 41 | - `ProviderRuntimeEvent` remains the canonical provider event contract; `ProviderService` remains the only cross-provider facade. | ||
| 42 | - Raw Codex payloads and event ordering stay isolated in `CodexAdapter.ts` and `codexAppServerManager.ts`. | ||
| 43 | - `ProviderKind` was not expanded. The runtime stays provider-neutral by contract. | ||
| 44 | |||
| 45 | ### 2. Replace loose websocket envelopes with channel-indexed typed pushes — DONE | ||
| 46 | - `packages/contracts/src/ws.ts` now derives push messages from a `WsPushPayloadByChannel` channel-to-schema map. `WsPush` is a union schema replacing `channel: string` + `data: unknown`. | ||
| 47 | - Every server push carries `sequence: number`, auto-incremented in `ServerPushBus`. | ||
| 48 | - `packages/shared/src/schemaJson.ts` provides structured decode diagnostics via `formatSchemaError`. | ||
| 49 | - `packages/contracts/src/ws.test.ts` covers typed push envelope validation and channel/payload mismatch rejection. | ||
| 50 | |||
| 51 | ### 3. Introduce explicit server readiness and a single push pipeline — DONE | ||
| 52 | - `apps/server/src/wsServer/pushBus.ts`: `ServerPushBus` with `publishAll` (broadcast) and `publishClient` (targeted) methods, backed by one ordered path. All pushes flow through it. | ||
| 53 | - `apps/server/src/wsServer/readiness.ts`: `ServerReadiness` with Deferred-based barriers for HTTP listening, push bus, keybindings, terminal subscriptions, and orchestration subscriptions. | ||
| 54 | - `server.welcome` is emitted only after connection-scoped and server-scoped readiness is complete. | ||
| 55 | - `wsServer.ts` no longer publishes directly from ad hoc background streams. | ||
| 56 | |||
| 57 | ### 4. Turn background watchers into explicit runtimes — DONE | ||
| 58 | - `apps/server/src/keybindings.ts` refactored as explicit `KeybindingsShape` service with `start`, `ready`, `snapshot` semantics. | ||
| 59 | - Initial config load, cache warmup, and dual watcher attachment (`fs.watch` + `watchFileWithStatPolling`) complete before `ready` resolves. | ||
| 60 | - `watchFileWithStatPolling.ts` is the thin adapter for environments where `fs.watch` is unreliable. | ||
| 61 | |||
| 62 | ### 5. Replace polling-based orchestration waiting with receipts — DONE | ||
| 63 | - `RuntimeReceiptBus` service defines three receipt types: `CheckpointBaselineCapturedReceipt`, `CheckpointDiffFinalizedReceipt` (with `status: "ready"|"missing"|"error"`), and `TurnProcessingQuiescedReceipt`. | ||
| 64 | - `CheckpointReactor`, `ProviderCommandReactor`, and `ProviderRuntimeIngestion` use `DrainableWorker` and publish receipts on completion. | ||
| 65 | - Integration harness and checkpoint tests await receipts instead of polling snapshots and git refs. | ||
| 66 | |||
| 67 | ### 6. Centralize client transport state and decoding — DONE | ||
| 68 | - `apps/web/src/wsTransport.ts` implements an explicit connection state machine: `connecting`, `open`, `reconnecting`, `closed`, `disposed`. | ||
| 69 | - Two-phase decode (JSON parse → Schema validate) happens at the transport boundary. `wsNativeApi.ts` receives pre-validated messages. | ||
| 70 | - Cached latest welcome/config modeled as explicit `latestPushByChannel` state. | ||
| 71 | |||
| 72 | ### 7. Replace ad hoc test helpers with semantic test clients — MOSTLY DONE | ||
| 73 | - `DrainableWorker` replaces timing-sensitive `Effect.sleep` with deterministic `drain()` across reactor tests. | ||
| 74 | - Orchestration harness waits on receipts/barriers instead of `waitForThread`, `waitForGitRef`, and retry loops. | ||
| 75 | - Behavioral assertions moved to deterministic unit-style harnesses; narrow integration tests kept for real filesystem/socket behavior. | ||
| 76 | - **Deferred:** Shared `WsTestClient` helper (connect, awaitSemanticWelcome, awaitTypedPush, trackSequence, matchRpcResponseById). Tests use direct transport subscription instead. | ||
| 77 | |||
| 78 | ## Provider-Coupling Guardrails | ||
| 79 | - No generic runtime API may depend on Codex-native event names, thread IDs, or request payload shapes. | ||
| 80 | - No readiness barrier may be defined as "Codex has emitted X." Readiness is owned by the server runtime, not by provider event order. | ||
| 81 | - No websocket channel payload may contain raw provider-native payloads unless the channel is explicitly debug/internal. | ||
| 82 | - Any provider-specific divergence must be exposed through provider capabilities from `ProviderService.getCapabilities()`, not `if provider === "codex"` branches in shared runtime code. | ||
| 83 | - Generic tests must use canonical `ProviderRuntimeEvent` fixtures. Codex-specific ordering and translation tests stay in adapter/app-server suites only. | ||
| 84 | - Keep UI/provider-specific knobs such as Codex-only options scoped to provider UX code. Do not pull them into generic transport or orchestration state. | ||
| 85 | |||
| 86 | ## Test Plan | ||
| 87 | - Contracts: | ||
| 88 | - schema tests for typed push envelopes and structured decode diagnostics | ||
| 89 | - ordering tests for `sequence` | ||
| 90 | - Server: | ||
| 91 | - readiness tests proving `server.welcome` cannot precede runtime readiness | ||
| 92 | - push bus tests proving terminal/config/orchestration pushes are serialized and typed | ||
| 93 | - keybindings runtime tests with fake watch source plus one real watcher integration test | ||
| 94 | - Orchestration: | ||
| 95 | - receipt tests proving checkpoint refs and projections are complete before completion signals resolve | ||
| 96 | - replacement of polling-based checkpoint/integration waits with receipt-based waits | ||
| 97 | - Web: | ||
| 98 | - transport tests for invalid JSON, invalid envelope, invalid payload, reconnect queue flushing, cached semantic state | ||
| 99 | - Validation gate: | ||
| 100 | - `bun run lint` | ||
| 101 | - `bun run typecheck` | ||
| 102 | - `mise exec -- bun run test` | ||
| 103 | - repeated full-suite run after cutover to confirm flake removal | ||
| 104 | |||
| 105 | ## Assumptions and Defaults | ||
| 106 | - This remains a single-provider product during the cutover, but the runtime contracts must stay provider-neutral. | ||
| 107 | - No backward-compatibility layer is required for old websocket push envelopes. | ||
| 108 | - The goal is deterministic runtime behavior first; reducing retries and sleeps in tests is a consequence, not the primary mechanism. | ||
| 109 | - If a completion signal cannot be expressed provider-neutrally, it does not belong in the shared runtime layer and must stay adapter-local. | ||
apps/server/integration/OrchestrationEngineHarness.integration.ts+50-1| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | Layer, | 15 | Layer, |
| 16 | ManagedRuntime, | 16 | ManagedRuntime, |
| 17 | Option, | 17 | Option, |
| 18 | Ref, | ||
| 18 | Schedule, | 19 | Schedule, |
| 19 | Schema, | 20 | Schema, |
| 20 | Scope, | 21 | Scope, |
| ... | @@ -45,6 +46,7 @@ import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointRea | ... | @@ -45,6 +46,7 @@ import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointRea |
| 45 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; | 46 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; |
| 46 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; | 47 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; |
| 47 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; | 48 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; |
| 49 | import { RuntimeReceiptBusLive } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; | ||
| 48 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; | 50 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; |
| 49 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; | 51 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; |
| 50 | import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; | 52 | import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; |
| ... | @@ -54,6 +56,10 @@ import { | ... | @@ -54,6 +56,10 @@ import { |
| 54 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; | 56 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; |
| 55 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; | 57 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; |
| 56 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; | 58 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 59 | import { | ||
| 60 | RuntimeReceiptBus, | ||
| 61 | type OrchestrationRuntimeReceipt, | ||
| 62 | } from "../src/orchestration/Services/RuntimeReceiptBus.ts"; | ||
| 57 | 63 | ||
| 58 | import { | 64 | import { |
| 59 | makeTestProviderAdapterHarness, | 65 | makeTestProviderAdapterHarness, |
| ... | @@ -114,7 +120,7 @@ function waitFor<A, E>( | ... | @@ -114,7 +120,7 @@ function waitFor<A, E>( |
| 114 | read: Effect.Effect<A, E>, | 120 | read: Effect.Effect<A, E>, |
| 115 | predicate: (value: A) => boolean, | 121 | predicate: (value: A) => boolean, |
| 116 | description: string, | 122 | description: string, |
| 117 | timeoutMs = 3000, | 123 | timeoutMs = 10_000, |
| 118 | ): Effect.Effect<A, never> { | 124 | ): Effect.Effect<A, never> { |
| 119 | const RETRY_SIGNAL = "wait_for_retry"; | 125 | const RETRY_SIGNAL = "wait_for_retry"; |
| 120 | const retryIntervalMs = 10; | 126 | const retryIntervalMs = 10; |
| ... | @@ -185,6 +191,16 @@ export interface OrchestrationIntegrationHarness { | ... | @@ -185,6 +191,16 @@ export interface OrchestrationIntegrationHarness { |
| 185 | }, | 191 | }, |
| 186 | never | 192 | never |
| 187 | >; | 193 | >; |
| 194 | readonly waitForReceipt: { | ||
| 195 | ( | ||
| 196 | predicate: (receipt: OrchestrationRuntimeReceipt) => boolean, | ||
| 197 | timeoutMs?: number, | ||
| 198 | ): Effect.Effect<OrchestrationRuntimeReceipt, never>; | ||
| 199 | <Receipt extends OrchestrationRuntimeReceipt>( | ||
| 200 | predicate: (receipt: OrchestrationRuntimeReceipt) => receipt is Receipt, | ||
| 201 | timeoutMs?: number, | ||
| 202 | ): Effect.Effect<Receipt, never>; | ||
| 203 | }; | ||
| 188 | readonly dispose: Effect.Effect<void, never>; | 204 | readonly dispose: Effect.Effect<void, never>; |
| 189 | } | 205 | } |
| 190 | 206 | ||
| ... | @@ -268,6 +284,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -268,6 +284,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 268 | ProjectionPendingApprovalRepositoryLive, | 284 | ProjectionPendingApprovalRepositoryLive, |
| 269 | CheckpointStoreLive, | 285 | CheckpointStoreLive, |
| 270 | providerLayer, | 286 | providerLayer, |
| 287 | RuntimeReceiptBusLive, | ||
| 271 | ); | 288 | ); |
| 272 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 289 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 273 | Layer.provideMerge(runtimeServicesLayer), | 290 | Layer.provideMerge(runtimeServicesLayer), |
| ... | @@ -322,11 +339,18 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -322,11 +339,18 @@ export const makeOrchestrationIntegrationHarness = ( |
| 322 | "load ProjectionPendingApprovalRepository service", | 339 | "load ProjectionPendingApprovalRepository service", |
| 323 | () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), | 340 | () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), |
| 324 | ).pipe(Effect.orDie); | 341 | ).pipe(Effect.orDie); |
| 342 | const runtimeReceiptBus = yield* tryRuntimePromise("load RuntimeReceiptBus service", () => | ||
| 343 | runtime.runPromise(Effect.service(RuntimeReceiptBus)), | ||
| 344 | ).pipe(Effect.orDie); | ||
| 325 | 345 | ||
| 326 | const scope = yield* Scope.make("sequential"); | 346 | const scope = yield* Scope.make("sequential"); |
| 327 | yield* tryRuntimePromise("start OrchestrationReactor", () => | 347 | yield* tryRuntimePromise("start OrchestrationReactor", () => |
| 328 | runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), | 348 | runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), |
| 329 | ).pipe(Effect.orDie); | 349 | ).pipe(Effect.orDie); |
| 350 | const receiptHistory = yield* Ref.make<ReadonlyArray<OrchestrationRuntimeReceipt>>([]); | ||
| 351 | yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) => | ||
| 352 | Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), | ||
| 353 | ).pipe(Effect.forkIn(scope)); | ||
| 330 | yield* sleep(10); | 354 | yield* sleep(10); |
| 331 | 355 | ||
| 332 | const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( | 356 | const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( |
| ... | @@ -398,6 +422,30 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -398,6 +422,30 @@ export const makeOrchestrationIntegrationHarness = ( |
| 398 | never | 422 | never |
| 399 | >; | 423 | >; |
| 400 | 424 | ||
| 425 | function waitForReceipt( | ||
| 426 | predicate: (receipt: OrchestrationRuntimeReceipt) => boolean, | ||
| 427 | timeoutMs?: number, | ||
| 428 | ): Effect.Effect<OrchestrationRuntimeReceipt, never>; | ||
| 429 | function waitForReceipt<Receipt extends OrchestrationRuntimeReceipt>( | ||
| 430 | predicate: (receipt: OrchestrationRuntimeReceipt) => receipt is Receipt, | ||
| 431 | timeoutMs?: number, | ||
| 432 | ): Effect.Effect<Receipt, never>; | ||
| 433 | function waitForReceipt( | ||
| 434 | predicate: (receipt: OrchestrationRuntimeReceipt) => boolean, | ||
| 435 | timeoutMs?: number, | ||
| 436 | ) { | ||
| 437 | const readMatchingReceipt = Ref.get(receiptHistory).pipe( | ||
| 438 | Effect.map((history) => history.find(predicate)), | ||
| 439 | ); | ||
| 440 | |||
| 441 | return waitFor( | ||
| 442 | readMatchingReceipt, | ||
| 443 | (receipt): receipt is OrchestrationRuntimeReceipt => receipt !== undefined, | ||
| 444 | "runtime receipt", | ||
| 445 | timeoutMs, | ||
| 446 | ); | ||
| 447 | } | ||
| 448 | |||
| 401 | let disposed = false; | 449 | let disposed = false; |
| 402 | const dispose = Effect.gen(function* () { | 450 | const dispose = Effect.gen(function* () { |
| 403 | if (disposed) { | 451 | if (disposed) { |
| ... | @@ -443,6 +491,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -443,6 +491,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 443 | waitForThread, | 491 | waitForThread, |
| 444 | waitForDomainEvent, | 492 | waitForDomainEvent, |
| 445 | waitForPendingApproval, | 493 | waitForPendingApproval, |
| 494 | waitForReceipt, | ||
| 446 | dispose, | 495 | dispose, |
| 447 | } satisfies OrchestrationIntegrationHarness; | 496 | } satisfies OrchestrationIntegrationHarness; |
| 448 | }); | 497 | }); |
apps/server/integration/orchestrationEngine.integration.test.ts+42-2| ... | @@ -21,6 +21,10 @@ import { | ... | @@ -21,6 +21,10 @@ import { |
| 21 | type OrchestrationIntegrationHarness, | 21 | type OrchestrationIntegrationHarness, |
| 22 | } from "./OrchestrationEngineHarness.integration.ts"; | 22 | } from "./OrchestrationEngineHarness.integration.ts"; |
| 23 | import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts"; | 23 | import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts"; |
| 24 | import type { | ||
| 25 | CheckpointDiffFinalizedReceipt, | ||
| 26 | TurnProcessingQuiescedReceipt, | ||
| 27 | } from "../src/orchestration/Services/RuntimeReceiptBus.ts"; | ||
| 24 | 28 | ||
| 25 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | 29 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 26 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | 30 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| ... | @@ -187,6 +191,22 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + | ... | @@ -187,6 +191,22 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + |
| 187 | messageId: "msg-user-single", | 191 | messageId: "msg-user-single", |
| 188 | text: "Say hello", | 192 | text: "Say hello", |
| 189 | }); | 193 | }); |
| 194 | const finalizedReceipt = yield* harness.waitForReceipt( | ||
| 195 | (receipt): receipt is CheckpointDiffFinalizedReceipt => | ||
| 196 | receipt.type === "checkpoint.diff.finalized" && | ||
| 197 | receipt.threadId === THREAD_ID && | ||
| 198 | receipt.checkpointTurnCount === 1, | ||
| 199 | ); | ||
| 200 | if (finalizedReceipt.type !== "checkpoint.diff.finalized") { | ||
| 201 | throw new Error("Expected checkpoint.diff.finalized receipt."); | ||
| 202 | } | ||
| 203 | assert.equal(finalizedReceipt.status, "ready"); | ||
| 204 | yield* harness.waitForReceipt( | ||
| 205 | (receipt): receipt is TurnProcessingQuiescedReceipt => | ||
| 206 | receipt.type === "turn.processing.quiesced" && | ||
| 207 | receipt.threadId === THREAD_ID && | ||
| 208 | receipt.checkpointTurnCount === 1, | ||
| 209 | ); | ||
| 190 | 210 | ||
| 191 | const thread = yield* harness.waitForThread( | 211 | const thread = yield* harness.waitForThread( |
| 192 | THREAD_ID, | 212 | THREAD_ID, |
| ... | @@ -208,8 +228,6 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + | ... | @@ -208,8 +228,6 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + |
| 208 | assert.equal(checkpointRows[0]?.status, "ready"); | 228 | assert.equal(checkpointRows[0]?.status, "ready"); |
| 209 | assert.deepEqual(checkpointRows[0]?.files, []); | 229 | assert.deepEqual(checkpointRows[0]?.files, []); |
| 210 | 230 | ||
| 211 | yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed"); | ||
| 212 | |||
| 213 | const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0); | 231 | const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0); |
| 214 | const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1); | 232 | const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1); |
| 215 | assert.equal(gitRefExists(harness.workspaceDir, ref0), true); | 233 | assert.equal(gitRefExists(harness.workspaceDir, ref0), true); |
| ... | @@ -367,6 +385,12 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => | ... | @@ -367,6 +385,12 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 367 | messageId: "msg-user-multi-1", | 385 | messageId: "msg-user-multi-1", |
| 368 | text: "Make first edit", | 386 | text: "Make first edit", |
| 369 | }); | 387 | }); |
| 388 | yield* harness.waitForReceipt( | ||
| 389 | (receipt): receipt is CheckpointDiffFinalizedReceipt => | ||
| 390 | receipt.type === "checkpoint.diff.finalized" && | ||
| 391 | receipt.threadId === THREAD_ID && | ||
| 392 | receipt.checkpointTurnCount === 1, | ||
| 393 | ); | ||
| 370 | 394 | ||
| 371 | yield* harness.waitForThread( | 395 | yield* harness.waitForThread( |
| 372 | THREAD_ID, | 396 | THREAD_ID, |
| ... | @@ -408,6 +432,22 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => | ... | @@ -408,6 +432,22 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 408 | messageId: "msg-user-multi-2", | 432 | messageId: "msg-user-multi-2", |
| 409 | text: "Make second edit", | 433 | text: "Make second edit", |
| 410 | }); | 434 | }); |
| 435 | const secondReceipt = yield* harness.waitForReceipt( | ||
| 436 | (receipt): receipt is CheckpointDiffFinalizedReceipt => | ||
| 437 | receipt.type === "checkpoint.diff.finalized" && | ||
| 438 | receipt.threadId === THREAD_ID && | ||
| 439 | receipt.checkpointTurnCount === 2, | ||
| 440 | ); | ||
| 441 | if (secondReceipt.type !== "checkpoint.diff.finalized") { | ||
| 442 | throw new Error("Expected checkpoint.diff.finalized receipt."); | ||
| 443 | } | ||
| 444 | assert.equal(secondReceipt.status, "ready"); | ||
| 445 | yield* harness.waitForReceipt( | ||
| 446 | (receipt): receipt is TurnProcessingQuiescedReceipt => | ||
| 447 | receipt.type === "turn.processing.quiesced" && | ||
| 448 | receipt.threadId === THREAD_ID && | ||
| 449 | receipt.checkpointTurnCount === 2, | ||
| 450 | ); | ||
| 411 | 451 | ||
| 412 | const secondTurnThread = yield* harness.waitForThread( | 452 | const secondTurnThread = yield* harness.waitForThread( |
| 413 | THREAD_ID, | 453 | THREAD_ID, |
apps/server/src/git/Layers/GitCore.ts+2-2| ... | @@ -654,7 +654,7 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -654,7 +654,7 @@ const makeGitCore = Effect.gen(function* () { |
| 654 | 654 | ||
| 655 | const statusDetails: GitCoreShape["statusDetails"] = (cwd) => | 655 | const statusDetails: GitCoreShape["statusDetails"] = (cwd) => |
| 656 | Effect.gen(function* () { | 656 | Effect.gen(function* () { |
| 657 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void)); | 657 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); |
| 658 | 658 | ||
| 659 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( | 659 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( |
| 660 | [ | 660 | [ |
| ... | @@ -1365,7 +1365,7 @@ const makeGitCore = Effect.gen(function* () { | ... | @@ -1365,7 +1365,7 @@ const makeGitCore = Effect.gen(function* () { |
| 1365 | 1365 | ||
| 1366 | // Refresh upstream refs in the background so checkout remains responsive. | 1366 | // Refresh upstream refs in the background so checkout remains responsive. |
| 1367 | yield* Effect.forkScoped( | 1367 | yield* Effect.forkScoped( |
| 1368 | refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)), | 1368 | refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })), |
| 1369 | ); | 1369 | ); |
| 1370 | }); | 1370 | }); |
| 1371 | 1371 |
apps/server/src/keybindings.ts+98-41| ... | @@ -22,7 +22,9 @@ import { | ... | @@ -22,7 +22,9 @@ import { |
| 22 | Array, | 22 | Array, |
| 23 | Cache, | 23 | Cache, |
| 24 | Cause, | 24 | Cause, |
| 25 | Deferred, | ||
| 25 | Effect, | 26 | Effect, |
| 27 | Exit, | ||
| 26 | FileSystem, | 28 | FileSystem, |
| 27 | Path, | 29 | Path, |
| 28 | Layer, | 30 | Layer, |
| ... | @@ -33,7 +35,9 @@ import { | ... | @@ -33,7 +35,9 @@ import { |
| 33 | SchemaGetter, | 35 | SchemaGetter, |
| 34 | SchemaIssue, | 36 | SchemaIssue, |
| 35 | SchemaTransformation, | 37 | SchemaTransformation, |
| 38 | Ref, | ||
| 36 | ServiceMap, | 39 | ServiceMap, |
| 40 | Scope, | ||
| 37 | Stream, | 41 | Stream, |
| 38 | } from "effect"; | 42 | } from "effect"; |
| 39 | import * as Semaphore from "effect/Semaphore"; | 43 | import * as Semaphore from "effect/Semaphore"; |
| ... | @@ -422,6 +426,7 @@ export interface KeybindingsConfigState { | ... | @@ -422,6 +426,7 @@ export interface KeybindingsConfigState { |
| 422 | } | 426 | } |
| 423 | 427 | ||
| 424 | export interface KeybindingsChangeEvent { | 428 | export interface KeybindingsChangeEvent { |
| 429 | readonly keybindings: ResolvedKeybindingsConfig; | ||
| 425 | readonly issues: readonly ServerConfigIssue[]; | 430 | readonly issues: readonly ServerConfigIssue[]; |
| 426 | } | 431 | } |
| 427 | 432 | ||
| ... | @@ -468,6 +473,22 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve | ... | @@ -468,6 +473,22 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve |
| 468 | * KeybindingsShape - Service API for keybinding configuration operations. | 473 | * KeybindingsShape - Service API for keybinding configuration operations. |
| 469 | */ | 474 | */ |
| 470 | export interface KeybindingsShape { | 475 | export interface KeybindingsShape { |
| 476 | /** | ||
| 477 | * Start the keybindings runtime and attach file watching. | ||
| 478 | * | ||
| 479 | * Safe to call multiple times. The first successful call establishes the | ||
| 480 | * runtime; later calls await the same startup. | ||
| 481 | */ | ||
| 482 | readonly start: Effect.Effect<void, KeybindingsConfigError>; | ||
| 483 | |||
| 484 | /** | ||
| 485 | * Await keybindings runtime readiness. | ||
| 486 | * | ||
| 487 | * Readiness means the config directory exists, the watcher is attached, the | ||
| 488 | * startup sync has completed, and the current snapshot has been loaded. | ||
| 489 | */ | ||
| 490 | readonly ready: Effect.Effect<void, KeybindingsConfigError>; | ||
| 491 | |||
| 471 | /** | 492 | /** |
| 472 | * Ensure the on-disk keybindings file exists and includes all default | 493 | * Ensure the on-disk keybindings file exists and includes all default |
| 473 | * commands so newly-added defaults are backfilled on startup. | 494 | * commands so newly-added defaults are backfilled on startup. |
| ... | @@ -480,9 +501,14 @@ export interface KeybindingsShape { | ... | @@ -480,9 +501,14 @@ export interface KeybindingsShape { |
| 480 | readonly loadConfigState: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>; | 501 | readonly loadConfigState: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>; |
| 481 | 502 | ||
| 482 | /** | 503 | /** |
| 483 | * Stream keybindings config change events. | 504 | * Read the latest keybindings snapshot from cache/disk. |
| 505 | */ | ||
| 506 | readonly getSnapshot: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>; | ||
| 507 | |||
| 508 | /** | ||
| 509 | * Stream of keybindings config change events. | ||
| 484 | */ | 510 | */ |
| 485 | readonly changes: Stream.Stream<KeybindingsChangeEvent>; | 511 | readonly streamChanges: Stream.Stream<KeybindingsChangeEvent>; |
| 486 | 512 | ||
| 487 | /** | 513 | /** |
| 488 | * Upsert a keybinding rule and persist the resulting configuration. | 514 | * Upsert a keybinding rule and persist the resulting configuration. |
| ... | @@ -509,9 +535,12 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -509,9 +535,12 @@ const makeKeybindings = Effect.gen(function* () { |
| 509 | const upsertSemaphore = yield* Semaphore.make(1); | 535 | const upsertSemaphore = yield* Semaphore.make(1); |
| 510 | const resolvedConfigCacheKey = "resolved" as const; | 536 | const resolvedConfigCacheKey = "resolved" as const; |
| 511 | const changesPubSub = yield* PubSub.unbounded<KeybindingsChangeEvent>(); | 537 | const changesPubSub = yield* PubSub.unbounded<KeybindingsChangeEvent>(); |
| 512 | 538 | const startedRef = yield* Ref.make(false); | |
| 513 | const emitChange = (issues: readonly ServerConfigIssue[]) => | 539 | const startedDeferred = yield* Deferred.make<void, KeybindingsConfigError>(); |
| 514 | PubSub.publish(changesPubSub, { issues }).pipe(Effect.asVoid); | 540 | const watcherScope = yield* Scope.make("sequential"); |
| 541 | yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); | ||
| 542 | const emitChange = (configState: KeybindingsConfigState) => | ||
| 543 | PubSub.publish(changesPubSub, configState).pipe(Effect.asVoid); | ||
| 515 | 544 | ||
| 516 | const readConfigExists = fs.exists(keybindingsConfigPath).pipe( | 545 | const readConfigExists = fs.exists(keybindingsConfigPath).pipe( |
| 517 | Effect.mapError( | 546 | Effect.mapError( |
| ... | @@ -676,43 +705,10 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -676,43 +705,10 @@ const makeKeybindings = Effect.gen(function* () { |
| 676 | Effect.gen(function* () { | 705 | Effect.gen(function* () { |
| 677 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | 706 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); |
| 678 | const configState = yield* loadConfigStateFromCacheOrDisk; | 707 | const configState = yield* loadConfigStateFromCacheOrDisk; |
| 679 | yield* emitChange(configState.issues); | 708 | yield* emitChange(configState); |
| 680 | }), | 709 | }), |
| 681 | ); | 710 | ); |
| 682 | 711 | ||
| 683 | const keybindingsConfigDir = path.dirname(keybindingsConfigPath); | ||
| 684 | const keybindingsConfigFile = path.basename(keybindingsConfigPath); | ||
| 685 | const keybindingsConfigPathResolved = path.resolve(keybindingsConfigPath); | ||
| 686 | yield* fs | ||
| 687 | .makeDirectory(keybindingsConfigDir, { recursive: true }) | ||
| 688 | .pipe(Effect.orElseSucceed(() => undefined)); | ||
| 689 | yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => { | ||
| 690 | const isTargetConfigEvent = | ||
| 691 | event.path === keybindingsConfigFile || | ||
| 692 | event.path === keybindingsConfigPath || | ||
| 693 | path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved; | ||
| 694 | if (!isTargetConfigEvent) { | ||
| 695 | return Effect.void; | ||
| 696 | } | ||
| 697 | return revalidateAndEmit.pipe( | ||
| 698 | Effect.catch((error) => | ||
| 699 | Effect.logWarning("failed to revalidate keybindings config after file update", { | ||
| 700 | path: keybindingsConfigPath, | ||
| 701 | detail: error.detail, | ||
| 702 | cause: error.cause, | ||
| 703 | }), | ||
| 704 | ), | ||
| 705 | ); | ||
| 706 | }).pipe( | ||
| 707 | Effect.catch((cause) => | ||
| 708 | Effect.logWarning("keybindings config watcher stopped unexpectedly", { | ||
| 709 | path: keybindingsConfigPath, | ||
| 710 | cause, | ||
| 711 | }), | ||
| 712 | ), | ||
| 713 | Effect.forkScoped, | ||
| 714 | ); | ||
| 715 | |||
| 716 | const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)( | 712 | const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)( |
| 717 | Effect.gen(function* () { | 713 | Effect.gen(function* () { |
| 718 | const configExists = yield* readConfigExists; | 714 | const configExists = yield* readConfigExists; |
| ... | @@ -803,10 +799,68 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -803,10 +799,68 @@ const makeKeybindings = Effect.gen(function* () { |
| 803 | }), | 799 | }), |
| 804 | ); | 800 | ); |
| 805 | 801 | ||
| 802 | const startWatcher = Effect.gen(function* () { | ||
| 803 | const keybindingsConfigDir = path.dirname(keybindingsConfigPath); | ||
| 804 | const keybindingsConfigFile = path.basename(keybindingsConfigPath); | ||
| 805 | const keybindingsConfigPathResolved = path.resolve(keybindingsConfigPath); | ||
| 806 | |||
| 807 | yield* fs.makeDirectory(keybindingsConfigDir, { recursive: true }).pipe( | ||
| 808 | Effect.mapError( | ||
| 809 | (cause) => | ||
| 810 | new KeybindingsConfigError({ | ||
| 811 | configPath: keybindingsConfigPath, | ||
| 812 | detail: "failed to prepare keybindings config directory", | ||
| 813 | cause, | ||
| 814 | }), | ||
| 815 | ), | ||
| 816 | ); | ||
| 817 | |||
| 818 | const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); | ||
| 819 | |||
| 820 | yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => { | ||
| 821 | const isTargetConfigEvent = | ||
| 822 | event.path === keybindingsConfigFile || | ||
| 823 | event.path === keybindingsConfigPath || | ||
| 824 | path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved; | ||
| 825 | if (!isTargetConfigEvent) { | ||
| 826 | return Effect.void; | ||
| 827 | } | ||
| 828 | return revalidateAndEmitSafely; | ||
| 829 | }).pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid); | ||
| 830 | }); | ||
| 831 | |||
| 832 | const start = Effect.gen(function* () { | ||
| 833 | const alreadyStarted = yield* Ref.get(startedRef); | ||
| 834 | if (alreadyStarted) { | ||
| 835 | return yield* Deferred.await(startedDeferred); | ||
| 836 | } | ||
| 837 | |||
| 838 | yield* Ref.set(startedRef, true); | ||
| 839 | const startup = Effect.gen(function* () { | ||
| 840 | yield* startWatcher; | ||
| 841 | yield* syncDefaultKeybindingsOnStartup; | ||
| 842 | yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); | ||
| 843 | yield* loadConfigStateFromCacheOrDisk; | ||
| 844 | }); | ||
| 845 | |||
| 846 | const startupExit = yield* Effect.exit(startup); | ||
| 847 | if (startupExit._tag === "Failure") { | ||
| 848 | yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie); | ||
| 849 | return yield* Effect.failCause(startupExit.cause); | ||
| 850 | } | ||
| 851 | |||
| 852 | yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); | ||
| 853 | }); | ||
| 854 | |||
| 806 | return { | 855 | return { |
| 856 | start, | ||
| 857 | ready: Deferred.await(startedDeferred), | ||
| 807 | syncDefaultKeybindingsOnStartup, | 858 | syncDefaultKeybindingsOnStartup, |
| 808 | loadConfigState: loadConfigStateFromCacheOrDisk, | 859 | loadConfigState: loadConfigStateFromCacheOrDisk, |
| 809 | changes: Stream.fromPubSub(changesPubSub), | 860 | getSnapshot: loadConfigStateFromCacheOrDisk, |
| 861 | get streamChanges() { | ||
| 862 | return Stream.fromPubSub(changesPubSub); | ||
| 863 | }, | ||
| 810 | upsertKeybindingRule: (rule) => | 864 | upsertKeybindingRule: (rule) => |
| 811 | upsertSemaphore.withPermits(1)( | 865 | upsertSemaphore.withPermits(1)( |
| 812 | Effect.gen(function* () { | 866 | Effect.gen(function* () { |
| ... | @@ -833,7 +887,10 @@ const makeKeybindings = Effect.gen(function* () { | ... | @@ -833,7 +887,10 @@ const makeKeybindings = Effect.gen(function* () { |
| 833 | keybindings: nextResolved, | 887 | keybindings: nextResolved, |
| 834 | issues: [], | 888 | issues: [], |
| 835 | }); | 889 | }); |
| 836 | yield* emitChange([]); | 890 | yield* emitChange({ |
| 891 | keybindings: nextResolved, | ||
| 892 | issues: [], | ||
| 893 | }); | ||
| 837 | return nextResolved; | 894 | return nextResolved; |
| 838 | }), | 895 | }), |
| 839 | ), | 896 | ), |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+9-6| ... | @@ -22,6 +22,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts | ... | @@ -22,6 +22,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts |
| 22 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; | 22 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; |
| 23 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 23 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 24 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 24 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 25 | import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; | ||
| 25 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | 26 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 26 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | 27 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 27 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 28 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| ... | @@ -112,7 +113,7 @@ async function waitForThread( | ... | @@ -112,7 +113,7 @@ async function waitForThread( |
| 112 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; | 113 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; |
| 113 | activities: ReadonlyArray<{ kind: string }>; | 114 | activities: ReadonlyArray<{ kind: string }>; |
| 114 | }) => boolean, | 115 | }) => boolean, |
| 115 | timeoutMs = 2000, | 116 | timeoutMs = 5000, |
| 116 | ) { | 117 | ) { |
| 117 | const deadline = Date.now() + timeoutMs; | 118 | const deadline = Date.now() + timeoutMs; |
| 118 | const poll = async (): Promise<{ | 119 | const poll = async (): Promise<{ |
| ... | @@ -137,7 +138,7 @@ async function waitForThread( | ... | @@ -137,7 +138,7 @@ async function waitForThread( |
| 137 | async function waitForEvent( | 138 | async function waitForEvent( |
| 138 | engine: OrchestrationEngineShape, | 139 | engine: OrchestrationEngineShape, |
| 139 | predicate: (event: { type: string }) => boolean, | 140 | predicate: (event: { type: string }) => boolean, |
| 140 | timeoutMs = 2000, | 141 | timeoutMs = 5000, |
| 141 | ) { | 142 | ) { |
| 142 | const deadline = Date.now() + timeoutMs; | 143 | const deadline = Date.now() + timeoutMs; |
| 143 | const poll = async () => { | 144 | const poll = async () => { |
| ... | @@ -188,7 +189,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { | ... | @@ -188,7 +189,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { |
| 188 | return runGit(cwd, ["show", `${ref}:${filePath}`]); | 189 | return runGit(cwd, ["show", `${ref}:${filePath}`]); |
| 189 | } | 190 | } |
| 190 | 191 | ||
| 191 | async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) { | 192 | async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 5000) { |
| 192 | const deadline = Date.now() + timeoutMs; | 193 | const deadline = Date.now() + timeoutMs; |
| 193 | const poll = async (): Promise<void> => { | 194 | const poll = async (): Promise<void> => { |
| 194 | if (gitRefExists(cwd, ref)) { | 195 | if (gitRefExists(cwd, ref)) { |
| ... | @@ -252,6 +253,7 @@ describe("CheckpointReactor", () => { | ... | @@ -252,6 +253,7 @@ describe("CheckpointReactor", () => { |
| 252 | 253 | ||
| 253 | const layer = CheckpointReactorLive.pipe( | 254 | const layer = CheckpointReactorLive.pipe( |
| 254 | Layer.provideMerge(orchestrationLayer), | 255 | Layer.provideMerge(orchestrationLayer), |
| 256 | Layer.provideMerge(RuntimeReceiptBusLive), | ||
| 255 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | 257 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 256 | Layer.provideMerge(CheckpointStoreLive), | 258 | Layer.provideMerge(CheckpointStoreLive), |
| 257 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | 259 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), |
| ... | @@ -264,7 +266,7 @@ describe("CheckpointReactor", () => { | ... | @@ -264,7 +266,7 @@ describe("CheckpointReactor", () => { |
| 264 | const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); | 266 | const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore)); |
| 265 | scope = await Effect.runPromise(Scope.make("sequential")); | 267 | scope = await Effect.runPromise(Scope.make("sequential")); |
| 266 | await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); | 268 | await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); |
| 267 | await Effect.runPromise(Effect.sleep("10 millis")); | 269 | const drain = () => Effect.runPromise(reactor.drain); |
| 268 | 270 | ||
| 269 | const createdAt = new Date().toISOString(); | 271 | const createdAt = new Date().toISOString(); |
| 270 | await Effect.runPromise( | 272 | await Effect.runPromise( |
| ... | @@ -321,6 +323,7 @@ describe("CheckpointReactor", () => { | ... | @@ -321,6 +323,7 @@ describe("CheckpointReactor", () => { |
| 321 | engine, | 323 | engine, |
| 322 | provider, | 324 | provider, |
| 323 | cwd, | 325 | cwd, |
| 326 | drain, | ||
| 324 | }; | 327 | }; |
| 325 | } | 328 | } |
| 326 | 329 | ||
| ... | @@ -449,7 +452,7 @@ describe("CheckpointReactor", () => { | ... | @@ -449,7 +452,7 @@ describe("CheckpointReactor", () => { |
| 449 | payload: { state: "completed" }, | 452 | payload: { state: "completed" }, |
| 450 | }); | 453 | }); |
| 451 | 454 | ||
| 452 | await Effect.runPromise(Effect.sleep("40 millis")); | 455 | await harness.drain(); |
| 453 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | 456 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 454 | const midThread = midReadModel.threads.find( | 457 | const midThread = midReadModel.threads.find( |
| 455 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), | 458 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), |
| ... | @@ -643,7 +646,7 @@ describe("CheckpointReactor", () => { | ... | @@ -643,7 +646,7 @@ describe("CheckpointReactor", () => { |
| 643 | status: "completed", | 646 | status: "completed", |
| 644 | }); | 647 | }); |
| 645 | 648 | ||
| 646 | await Effect.runPromise(Effect.sleep("40 millis")); | 649 | await harness.drain(); |
| 647 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | 650 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 648 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | 651 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); |
| 649 | expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( | 652 | expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+39-10| ... | @@ -7,7 +7,8 @@ import { | ... | @@ -7,7 +7,8 @@ import { |
| 7 | type OrchestrationEvent, | 7 | type OrchestrationEvent, |
| 8 | type ProviderRuntimeEvent, | 8 | type ProviderRuntimeEvent, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { Cause, Effect, Layer, Option, Queue, Stream } from "effect"; | 10 | import { Cause, Effect, Layer, Option, Stream } from "effect"; |
| 11 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | ||
| 11 | 12 | ||
| 12 | import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; | 13 | import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; |
| 13 | import { | 14 | import { |
| ... | @@ -19,6 +20,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts | ... | @@ -19,6 +20,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts |
| 19 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 20 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 20 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; | 21 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; |
| 21 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 22 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 23 | import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; | ||
| 22 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; | 24 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 23 | import { OrchestrationDispatchError } from "../Errors.ts"; | 25 | import { OrchestrationDispatchError } from "../Errors.ts"; |
| 24 | import { isGitRepository } from "../../git/isRepo.ts"; | 26 | import { isGitRepository } from "../../git/isRepo.ts"; |
| ... | @@ -64,6 +66,7 @@ const make = Effect.gen(function* () { | ... | @@ -64,6 +66,7 @@ const make = Effect.gen(function* () { |
| 64 | const orchestrationEngine = yield* OrchestrationEngineService; | 66 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 65 | const providerService = yield* ProviderService; | 67 | const providerService = yield* ProviderService; |
| 66 | const checkpointStore = yield* CheckpointStore; | 68 | const checkpointStore = yield* CheckpointStore; |
| 69 | const receiptBus = yield* RuntimeReceiptBus; | ||
| 67 | 70 | ||
| 68 | const appendRevertFailureActivity = (input: { | 71 | const appendRevertFailureActivity = (input: { |
| 69 | readonly threadId: ThreadId; | 72 | readonly threadId: ThreadId; |
| ... | @@ -277,6 +280,22 @@ const make = Effect.gen(function* () { | ... | @@ -277,6 +280,22 @@ const make = Effect.gen(function* () { |
| 277 | checkpointTurnCount: nextTurnCount, | 280 | checkpointTurnCount: nextTurnCount, |
| 278 | createdAt: now, | 281 | createdAt: now, |
| 279 | }); | 282 | }); |
| 283 | yield* receiptBus.publish({ | ||
| 284 | type: "checkpoint.diff.finalized", | ||
| 285 | threadId: thread.id, | ||
| 286 | turnId, | ||
| 287 | checkpointTurnCount: nextTurnCount, | ||
| 288 | checkpointRef: targetCheckpointRef, | ||
| 289 | status: checkpointStatusFromRuntime(event.payload.state), | ||
| 290 | createdAt: now, | ||
| 291 | }); | ||
| 292 | yield* receiptBus.publish({ | ||
| 293 | type: "turn.processing.quiesced", | ||
| 294 | threadId: thread.id, | ||
| 295 | turnId, | ||
| 296 | checkpointTurnCount: nextTurnCount, | ||
| 297 | createdAt: now, | ||
| 298 | }); | ||
| 280 | 299 | ||
| 281 | yield* orchestrationEngine.dispatch({ | 300 | yield* orchestrationEngine.dispatch({ |
| 282 | type: "thread.activity.append", | 301 | type: "thread.activity.append", |
| ... | @@ -350,6 +369,13 @@ const make = Effect.gen(function* () { | ... | @@ -350,6 +369,13 @@ const make = Effect.gen(function* () { |
| 350 | cwd: checkpointCwd, | 369 | cwd: checkpointCwd, |
| 351 | checkpointRef: baselineCheckpointRef, | 370 | checkpointRef: baselineCheckpointRef, |
| 352 | }); | 371 | }); |
| 372 | yield* receiptBus.publish({ | ||
| 373 | type: "checkpoint.baseline.captured", | ||
| 374 | threadId: thread.id, | ||
| 375 | checkpointTurnCount: currentTurnCount, | ||
| 376 | checkpointRef: baselineCheckpointRef, | ||
| 377 | createdAt: event.createdAt, | ||
| 378 | }); | ||
| 353 | }); | 379 | }); |
| 354 | 380 | ||
| 355 | const ensurePreTurnBaselineFromDomainTurnStart = Effect.fnUntraced(function* ( | 381 | const ensurePreTurnBaselineFromDomainTurnStart = Effect.fnUntraced(function* ( |
| ... | @@ -412,6 +438,13 @@ const make = Effect.gen(function* () { | ... | @@ -412,6 +438,13 @@ const make = Effect.gen(function* () { |
| 412 | cwd: checkpointCwd, | 438 | cwd: checkpointCwd, |
| 413 | checkpointRef: baselineCheckpointRef, | 439 | checkpointRef: baselineCheckpointRef, |
| 414 | }); | 440 | }); |
| 441 | yield* receiptBus.publish({ | ||
| 442 | type: "checkpoint.baseline.captured", | ||
| 443 | threadId, | ||
| 444 | checkpointTurnCount: currentTurnCount, | ||
| 445 | checkpointRef: baselineCheckpointRef, | ||
| 446 | createdAt: event.occurredAt, | ||
| 447 | }); | ||
| 415 | }); | 448 | }); |
| 416 | 449 | ||
| 417 | const handleRevertRequested = Effect.fnUntraced(function* ( | 450 | const handleRevertRequested = Effect.fnUntraced(function* ( |
| ... | @@ -603,14 +636,9 @@ const make = Effect.gen(function* () { | ... | @@ -603,14 +636,9 @@ const make = Effect.gen(function* () { |
| 603 | }), | 636 | }), |
| 604 | ); | 637 | ); |
| 605 | 638 | ||
| 606 | const start: CheckpointReactorShape["start"] = Effect.gen(function* () { | 639 | const worker = yield* makeDrainableWorker(processInputSafely); |
| 607 | const queue = yield* Queue.unbounded<ReactorInput>(); | ||
| 608 | yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); | ||
| 609 | |||
| 610 | yield* Effect.forkScoped( | ||
| 611 | Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processInputSafely))), | ||
| 612 | ); | ||
| 613 | 640 | ||
| 641 | const start: CheckpointReactorShape["start"] = Effect.gen(function* () { | ||
| 614 | yield* Effect.forkScoped( | 642 | yield* Effect.forkScoped( |
| 615 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | 643 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { |
| 616 | if ( | 644 | if ( |
| ... | @@ -620,7 +648,7 @@ const make = Effect.gen(function* () { | ... | @@ -620,7 +648,7 @@ const make = Effect.gen(function* () { |
| 620 | ) { | 648 | ) { |
| 621 | return Effect.void; | 649 | return Effect.void; |
| 622 | } | 650 | } |
| 623 | return Queue.offer(queue, { source: "domain", event }).pipe(Effect.asVoid); | 651 | return worker.enqueue({ source: "domain", event }); |
| 624 | }), | 652 | }), |
| 625 | ); | 653 | ); |
| 626 | 654 | ||
| ... | @@ -629,13 +657,14 @@ const make = Effect.gen(function* () { | ... | @@ -629,13 +657,14 @@ const make = Effect.gen(function* () { |
| 629 | if (event.type !== "turn.started" && event.type !== "turn.completed") { | 657 | if (event.type !== "turn.started" && event.type !== "turn.completed") { |
| 630 | return Effect.void; | 658 | return Effect.void; |
| 631 | } | 659 | } |
| 632 | return Queue.offer(queue, { source: "runtime", event }).pipe(Effect.asVoid); | 660 | return worker.enqueue({ source: "runtime", event }); |
| 633 | }), | 661 | }), |
| 634 | ); | 662 | ); |
| 635 | }); | 663 | }); |
| 636 | 664 | ||
| 637 | return { | 665 | return { |
| 638 | start, | 666 | start, |
| 667 | drain: worker.drain, | ||
| 639 | } satisfies CheckpointReactorShape; | 668 | } satisfies CheckpointReactorShape; |
| 640 | }); | 669 | }); |
| 641 | 670 |
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+3| ... | @@ -27,6 +27,7 @@ describe("OrchestrationReactor", () => { | ... | @@ -27,6 +27,7 @@ describe("OrchestrationReactor", () => { |
| 27 | start: Effect.sync(() => { | 27 | start: Effect.sync(() => { |
| 28 | started.push("provider-runtime-ingestion"); | 28 | started.push("provider-runtime-ingestion"); |
| 29 | }), | 29 | }), |
| 30 | drain: Effect.void, | ||
| 30 | }), | 31 | }), |
| 31 | ), | 32 | ), |
| 32 | Layer.provideMerge( | 33 | Layer.provideMerge( |
| ... | @@ -34,6 +35,7 @@ describe("OrchestrationReactor", () => { | ... | @@ -34,6 +35,7 @@ describe("OrchestrationReactor", () => { |
| 34 | start: Effect.sync(() => { | 35 | start: Effect.sync(() => { |
| 35 | started.push("provider-command-reactor"); | 36 | started.push("provider-command-reactor"); |
| 36 | }), | 37 | }), |
| 38 | drain: Effect.void, | ||
| 37 | }), | 39 | }), |
| 38 | ), | 40 | ), |
| 39 | Layer.provideMerge( | 41 | Layer.provideMerge( |
| ... | @@ -41,6 +43,7 @@ describe("OrchestrationReactor", () => { | ... | @@ -41,6 +43,7 @@ describe("OrchestrationReactor", () => { |
| 41 | start: Effect.sync(() => { | 43 | start: Effect.sync(() => { |
| 42 | started.push("checkpoint-reactor"); | 44 | started.push("checkpoint-reactor"); |
| 43 | }), | 45 | }), |
| 46 | drain: Effect.void, | ||
| 44 | }), | 47 | }), |
| 45 | ), | 48 | ), |
| 46 | ), | 49 | ), |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3-2| ... | @@ -219,7 +219,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -219,7 +219,7 @@ describe("ProviderCommandReactor", () => { |
| 219 | const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); | 219 | const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); |
| 220 | scope = await Effect.runPromise(Scope.make("sequential")); | 220 | scope = await Effect.runPromise(Scope.make("sequential")); |
| 221 | await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); | 221 | await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); |
| 222 | await Effect.runPromise(Effect.sleep("10 millis")); | 222 | const drain = () => Effect.runPromise(reactor.drain); |
| 223 | 223 | ||
| 224 | await Effect.runPromise( | 224 | await Effect.runPromise( |
| 225 | engine.dispatch({ | 225 | engine.dispatch({ |
| ... | @@ -259,6 +259,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -259,6 +259,7 @@ describe("ProviderCommandReactor", () => { |
| 259 | renameBranch, | 259 | renameBranch, |
| 260 | generateBranchName, | 260 | generateBranchName, |
| 261 | stateDir, | 261 | stateDir, |
| 262 | drain, | ||
| 262 | }; | 263 | }; |
| 263 | } | 264 | } |
| 264 | 265 | ||
| ... | @@ -577,7 +578,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -577,7 +578,7 @@ describe("ProviderCommandReactor", () => { |
| 577 | return thread?.runtimeMode === "approval-required"; | 578 | return thread?.runtimeMode === "approval-required"; |
| 578 | }); | 579 | }); |
| 579 | await waitFor(() => harness.startSession.mock.calls.length === 2); | 580 | await waitFor(() => harness.startSession.mock.calls.length === 2); |
| 580 | await Effect.runPromise(Effect.sleep("30 millis")); | 581 | await harness.drain(); |
| 581 | 582 | ||
| 582 | expect(harness.stopSession.mock.calls.length).toBe(0); | 583 | expect(harness.stopSession.mock.calls.length).toBe(0); |
| 583 | expect(harness.sendTurn.mock.calls.length).toBe(1); | 584 | expect(harness.sendTurn.mock.calls.length).toBe(1); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+28-35| ... | @@ -12,7 +12,8 @@ import { | ... | @@ -12,7 +12,8 @@ import { |
| 12 | type RuntimeMode, | 12 | type RuntimeMode, |
| 13 | type TurnId, | 13 | type TurnId, |
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Schema, Stream } from "effect"; | 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect"; |
| 16 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | ||
| 16 | 17 | ||
| 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | 18 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 18 | import { GitCore } from "../../git/Services/GitCore.ts"; | 19 | import { GitCore } from "../../git/Services/GitCore.ts"; |
| ... | @@ -573,16 +574,14 @@ const make = Effect.gen(function* () { | ... | @@ -573,16 +574,14 @@ const make = Effect.gen(function* () { |
| 573 | }) | 574 | }) |
| 574 | .pipe( | 575 | .pipe( |
| 575 | Effect.catchCause((cause) => | 576 | Effect.catchCause((cause) => |
| 576 | Effect.gen(function* () { | 577 | appendProviderFailureActivity({ |
| 577 | yield* appendProviderFailureActivity({ | 578 | threadId: event.payload.threadId, |
| 578 | threadId: event.payload.threadId, | 579 | kind: "provider.user-input.respond.failed", |
| 579 | kind: "provider.user-input.respond.failed", | 580 | summary: "Provider user input response failed", |
| 580 | summary: "Provider user input response failed", | 581 | detail: Cause.pretty(cause), |
| 581 | detail: Cause.pretty(cause), | 582 | turnId: null, |
| 582 | turnId: null, | 583 | createdAt: event.payload.createdAt, |
| 583 | createdAt: event.payload.createdAt, | 584 | requestId: event.payload.requestId, |
| 584 | requestId: event.payload.requestId, | ||
| 585 | }); | ||
| 586 | }), | 585 | }), |
| 587 | ), | 586 | ), |
| 588 | ); | 587 | ); |
| ... | @@ -665,34 +664,28 @@ const make = Effect.gen(function* () { | ... | @@ -665,34 +664,28 @@ const make = Effect.gen(function* () { |
| 665 | }), | 664 | }), |
| 666 | ); | 665 | ); |
| 667 | 666 | ||
| 668 | const start: ProviderCommandReactorShape["start"] = Effect.gen(function* () { | 667 | const worker = yield* makeDrainableWorker(processDomainEventSafely); |
| 669 | const queue = yield* Queue.unbounded<ProviderIntentEvent>(); | 668 | |
| 670 | yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); | 669 | const start: ProviderCommandReactorShape["start"] = Effect.forkScoped( |
| 671 | 670 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | |
| 672 | yield* Effect.forkScoped( | 671 | if ( |
| 673 | Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processDomainEventSafely))), | 672 | event.type !== "thread.runtime-mode-set" && |
| 674 | ); | 673 | event.type !== "thread.turn-start-requested" && |
| 675 | 674 | event.type !== "thread.turn-interrupt-requested" && | |
| 676 | yield* Effect.forkScoped( | 675 | event.type !== "thread.approval-response-requested" && |
| 677 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | 676 | event.type !== "thread.user-input-response-requested" && |
| 678 | if ( | 677 | event.type !== "thread.session-stop-requested" |
| 679 | event.type !== "thread.runtime-mode-set" && | 678 | ) { |
| 680 | event.type !== "thread.turn-start-requested" && | 679 | return Effect.void; |
| 681 | event.type !== "thread.turn-interrupt-requested" && | 680 | } |
| 682 | event.type !== "thread.approval-response-requested" && | ||
| 683 | event.type !== "thread.user-input-response-requested" && | ||
| 684 | event.type !== "thread.session-stop-requested" | ||
| 685 | ) { | ||
| 686 | return Effect.void; | ||
| 687 | } | ||
| 688 | 681 | ||
| 689 | return Queue.offer(queue, event).pipe(Effect.asVoid); | 682 | return worker.enqueue(event); |
| 690 | }), | 683 | }), |
| 691 | ); | 684 | ).pipe(Effect.asVoid); |
| 692 | }); | ||
| 693 | 685 | ||
| 694 | return { | 686 | return { |
| 695 | start, | 687 | start, |
| 688 | drain: worker.drain, | ||
| 696 | } satisfies ProviderCommandReactorShape; | 689 | } satisfies ProviderCommandReactorShape; |
| 697 | }); | 690 | }); |
| 698 | 691 |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+7-6| ... | @@ -159,7 +159,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -159,7 +159,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 159 | const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)); | 159 | const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)); |
| 160 | scope = await Effect.runPromise(Scope.make("sequential")); | 160 | scope = await Effect.runPromise(Scope.make("sequential")); |
| 161 | await Effect.runPromise(ingestion.start.pipe(Scope.provide(scope))); | 161 | await Effect.runPromise(ingestion.start.pipe(Scope.provide(scope))); |
| 162 | await Effect.runPromise(Effect.sleep("10 millis")); | 162 | const drain = () => Effect.runPromise(ingestion.drain); |
| 163 | 163 | ||
| 164 | const createdAt = new Date().toISOString(); | 164 | const createdAt = new Date().toISOString(); |
| 165 | await Effect.runPromise( | 165 | await Effect.runPromise( |
| ... | @@ -209,6 +209,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -209,6 +209,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 209 | return { | 209 | return { |
| 210 | engine, | 210 | engine, |
| 211 | emit: provider.emit, | 211 | emit: provider.emit, |
| 212 | drain, | ||
| 212 | }; | 213 | }; |
| 213 | } | 214 | } |
| 214 | 215 | ||
| ... | @@ -377,7 +378,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -377,7 +378,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 377 | threadId: asThreadId("thread-1"), | 378 | threadId: asThreadId("thread-1"), |
| 378 | }); | 379 | }); |
| 379 | 380 | ||
| 380 | await Effect.runPromise(Effect.sleep("40 millis")); | 381 | await harness.drain(); |
| 381 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | 382 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 382 | const midThread = midReadModel.threads.find( | 383 | const midThread = midReadModel.threads.find( |
| 383 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), | 384 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), |
| ... | @@ -430,7 +431,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -430,7 +431,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 430 | status: "completed", | 431 | status: "completed", |
| 431 | }); | 432 | }); |
| 432 | 433 | ||
| 433 | await Effect.runPromise(Effect.sleep("40 millis")); | 434 | await harness.drain(); |
| 434 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | 435 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 435 | const midThread = midReadModel.threads.find( | 436 | const midThread = midReadModel.threads.find( |
| 436 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), | 437 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), |
| ... | @@ -484,7 +485,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -484,7 +485,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 484 | status: "completed", | 485 | status: "completed", |
| 485 | }); | 486 | }); |
| 486 | 487 | ||
| 487 | await Effect.runPromise(Effect.sleep("40 millis")); | 488 | await harness.drain(); |
| 488 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | 489 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 489 | const midThread = midReadModel.threads.find( | 490 | const midThread = midReadModel.threads.find( |
| 490 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), | 491 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), |
| ... | @@ -725,7 +726,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -725,7 +726,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 725 | }, | 726 | }, |
| 726 | }); | 727 | }); |
| 727 | 728 | ||
| 728 | await Effect.runPromise(Effect.sleep("30 millis")); | 729 | await harness.drain(); |
| 729 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | 730 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 730 | const midThread = midReadModel.threads.find( | 731 | const midThread = midReadModel.threads.find( |
| 731 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), | 732 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), |
| ... | @@ -784,7 +785,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -784,7 +785,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 784 | createdAt: now, | 785 | createdAt: now, |
| 785 | }), | 786 | }), |
| 786 | ); | 787 | ); |
| 787 | await Effect.runPromise(Effect.sleep("30 millis")); | 788 | await harness.drain(); |
| 788 | 789 | ||
| 789 | harness.emit({ | 790 | harness.emit({ |
| 790 | type: "turn.started", | 791 | type: "turn.started", |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+7-9| ... | @@ -10,7 +10,8 @@ import { | ... | @@ -10,7 +10,8 @@ import { |
| 10 | type OrchestrationThreadActivity, | 10 | type OrchestrationThreadActivity, |
| 11 | type ProviderRuntimeEvent, | 11 | type ProviderRuntimeEvent, |
| 12 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 13 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect"; | 13 | import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect"; |
| 14 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | ||
| 14 | 15 | ||
| 15 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 16 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 16 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| ... | @@ -1118,16 +1119,12 @@ const make = Effect.gen(function* () { | ... | @@ -1118,16 +1119,12 @@ const make = Effect.gen(function* () { |
| 1118 | }), | 1119 | }), |
| 1119 | ); | 1120 | ); |
| 1120 | 1121 | ||
| 1121 | const start: ProviderRuntimeIngestionShape["start"] = Effect.gen(function* () { | 1122 | const worker = yield* makeDrainableWorker(processInputSafely); |
| 1122 | const inputQueue = yield* Queue.unbounded<RuntimeIngestionInput>(); | ||
| 1123 | yield* Effect.addFinalizer(() => Queue.shutdown(inputQueue).pipe(Effect.asVoid)); | ||
| 1124 | 1123 | ||
| 1125 | yield* Effect.forkScoped( | 1124 | const start: ProviderRuntimeIngestionShape["start"] = Effect.gen(function* () { |
| 1126 | Effect.forever(Queue.take(inputQueue).pipe(Effect.flatMap(processInputSafely))), | ||
| 1127 | ); | ||
| 1128 | yield* Effect.forkScoped( | 1125 | yield* Effect.forkScoped( |
| 1129 | Stream.runForEach(providerService.streamEvents, (event) => | 1126 | Stream.runForEach(providerService.streamEvents, (event) => |
| 1130 | Queue.offer(inputQueue, { source: "runtime", event }).pipe(Effect.asVoid), | 1127 | worker.enqueue({ source: "runtime", event }), |
| 1131 | ), | 1128 | ), |
| 1132 | ); | 1129 | ); |
| 1133 | yield* Effect.forkScoped( | 1130 | yield* Effect.forkScoped( |
| ... | @@ -1135,13 +1132,14 @@ const make = Effect.gen(function* () { | ... | @@ -1135,13 +1132,14 @@ const make = Effect.gen(function* () { |
| 1135 | if (event.type !== "thread.turn-start-requested") { | 1132 | if (event.type !== "thread.turn-start-requested") { |
| 1136 | return Effect.void; | 1133 | return Effect.void; |
| 1137 | } | 1134 | } |
| 1138 | return Queue.offer(inputQueue, { source: "domain", event }).pipe(Effect.asVoid); | 1135 | return worker.enqueue({ source: "domain", event }); |
| 1139 | }), | 1136 | }), |
| 1140 | ); | 1137 | ); |
| 1141 | }); | 1138 | }); |
| 1142 | 1139 | ||
| 1143 | return { | 1140 | return { |
| 1144 | start, | 1141 | start, |
| 1142 | drain: worker.drain, | ||
| 1145 | } satisfies ProviderRuntimeIngestionShape; | 1143 | } satisfies ProviderRuntimeIngestionShape; |
| 1146 | }); | 1144 | }); |
| 1147 | 1145 |
apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | import { Effect, Layer, PubSub, Stream } from "effect"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | RuntimeReceiptBus, | ||
| 5 | type RuntimeReceiptBusShape, | ||
| 6 | type OrchestrationRuntimeReceipt, | ||
| 7 | } from "../Services/RuntimeReceiptBus.ts"; | ||
| 8 | |||
| 9 | const makeRuntimeReceiptBus = Effect.gen(function* () { | ||
| 10 | const pubSub = yield* PubSub.unbounded<OrchestrationRuntimeReceipt>(); | ||
| 11 | |||
| 12 | return { | ||
| 13 | publish: (receipt) => PubSub.publish(pubSub, receipt).pipe(Effect.asVoid), | ||
| 14 | stream: Stream.fromPubSub(pubSub), | ||
| 15 | } satisfies RuntimeReceiptBusShape; | ||
| 16 | }); | ||
| 17 | |||
| 18 | export const RuntimeReceiptBusLive = Layer.effect(RuntimeReceiptBus, makeRuntimeReceiptBus); | ||
apps/server/src/orchestration/Services/CheckpointReactor.ts+6| ... | @@ -23,6 +23,12 @@ export interface CheckpointReactorShape { | ... | @@ -23,6 +23,12 @@ export interface CheckpointReactorShape { |
| 23 | * internal queue. | 23 | * internal queue. |
| 24 | */ | 24 | */ |
| 25 | readonly start: Effect.Effect<void, never, Scope.Scope>; | 25 | readonly start: Effect.Effect<void, never, Scope.Scope>; |
| 26 | |||
| 27 | /** | ||
| 28 | * Resolves when the internal processing queue is empty and idle. | ||
| 29 | * Intended for test use to replace timing-sensitive sleeps. | ||
| 30 | */ | ||
| 31 | readonly drain: Effect.Effect<void>; | ||
| 26 | } | 32 | } |
| 27 | 33 | ||
| 28 | /** | 34 | /** |
apps/server/src/orchestration/Services/ProviderCommandReactor.ts+6| ... | @@ -23,6 +23,12 @@ export interface ProviderCommandReactorShape { | ... | @@ -23,6 +23,12 @@ export interface ProviderCommandReactorShape { |
| 23 | * processing. | 23 | * processing. |
| 24 | */ | 24 | */ |
| 25 | readonly start: Effect.Effect<void, never, Scope.Scope>; | 25 | readonly start: Effect.Effect<void, never, Scope.Scope>; |
| 26 | |||
| 27 | /** | ||
| 28 | * Resolves when the internal processing queue is empty and idle. | ||
| 29 | * Intended for test use to replace timing-sensitive sleeps. | ||
| 30 | */ | ||
| 31 | readonly drain: Effect.Effect<void>; | ||
| 26 | } | 32 | } |
| 27 | 33 | ||
| 28 | /** | 34 | /** |
apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts+6| ... | @@ -23,6 +23,12 @@ export interface ProviderRuntimeIngestionShape { | ... | @@ -23,6 +23,12 @@ export interface ProviderRuntimeIngestionShape { |
| 23 | * logging warnings. | 23 | * logging warnings. |
| 24 | */ | 24 | */ |
| 25 | readonly start: Effect.Effect<void, never, Scope.Scope>; | 25 | readonly start: Effect.Effect<void, never, Scope.Scope>; |
| 26 | |||
| 27 | /** | ||
| 28 | * Resolves when the internal processing queue is empty and idle. | ||
| 29 | * Intended for test use to replace timing-sensitive sleeps. | ||
| 30 | */ | ||
| 31 | readonly drain: Effect.Effect<void>; | ||
| 26 | } | 32 | } |
| 27 | 33 | ||
| 28 | /** | 34 | /** |
apps/server/src/orchestration/Services/RuntimeReceiptBus.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import { CheckpointRef, IsoDateTime, NonNegativeInt, ThreadId, TurnId } from "@t3tools/contracts"; | ||
| 2 | import { Schema, ServiceMap } from "effect"; | ||
| 3 | import type { Effect, Stream } from "effect"; | ||
| 4 | |||
| 5 | export const CheckpointBaselineCapturedReceipt = Schema.Struct({ | ||
| 6 | type: Schema.Literal("checkpoint.baseline.captured"), | ||
| 7 | threadId: ThreadId, | ||
| 8 | checkpointTurnCount: NonNegativeInt, | ||
| 9 | checkpointRef: CheckpointRef, | ||
| 10 | createdAt: IsoDateTime, | ||
| 11 | }); | ||
| 12 | export type CheckpointBaselineCapturedReceipt = typeof CheckpointBaselineCapturedReceipt.Type; | ||
| 13 | |||
| 14 | export const CheckpointDiffFinalizedReceipt = Schema.Struct({ | ||
| 15 | type: Schema.Literal("checkpoint.diff.finalized"), | ||
| 16 | threadId: ThreadId, | ||
| 17 | turnId: TurnId, | ||
| 18 | checkpointTurnCount: NonNegativeInt, | ||
| 19 | checkpointRef: CheckpointRef, | ||
| 20 | status: Schema.Literals(["ready", "missing", "error"]), | ||
| 21 | createdAt: IsoDateTime, | ||
| 22 | }); | ||
| 23 | export type CheckpointDiffFinalizedReceipt = typeof CheckpointDiffFinalizedReceipt.Type; | ||
| 24 | |||
| 25 | export const TurnProcessingQuiescedReceipt = Schema.Struct({ | ||
| 26 | type: Schema.Literal("turn.processing.quiesced"), | ||
| 27 | threadId: ThreadId, | ||
| 28 | turnId: TurnId, | ||
| 29 | checkpointTurnCount: NonNegativeInt, | ||
| 30 | createdAt: IsoDateTime, | ||
| 31 | }); | ||
| 32 | export type TurnProcessingQuiescedReceipt = typeof TurnProcessingQuiescedReceipt.Type; | ||
| 33 | |||
| 34 | export const OrchestrationRuntimeReceipt = Schema.Union([ | ||
| 35 | CheckpointBaselineCapturedReceipt, | ||
| 36 | CheckpointDiffFinalizedReceipt, | ||
| 37 | TurnProcessingQuiescedReceipt, | ||
| 38 | ]); | ||
| 39 | export type OrchestrationRuntimeReceipt = typeof OrchestrationRuntimeReceipt.Type; | ||
| 40 | |||
| 41 | export interface RuntimeReceiptBusShape { | ||
| 42 | readonly publish: (receipt: OrchestrationRuntimeReceipt) => Effect.Effect<void>; | ||
| 43 | readonly stream: Stream.Stream<OrchestrationRuntimeReceipt>; | ||
| 44 | } | ||
| 45 | |||
| 46 | export class RuntimeReceiptBus extends ServiceMap.Service< | ||
| 47 | RuntimeReceiptBus, | ||
| 48 | RuntimeReceiptBusShape | ||
| 49 | >()("t3/orchestration/Services/RuntimeReceiptBus") {} | ||
apps/server/src/provider/Layers/ProviderService.ts+1-3| ... | @@ -292,9 +292,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -292,9 +292,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 292 | } | 292 | } |
| 293 | 293 | ||
| 294 | yield* upsertSessionBinding(session, threadId, { | 294 | yield* upsertSessionBinding(session, threadId, { |
| 295 | ...(input.providerOptions !== undefined | 295 | providerOptions: input.providerOptions, |
| 296 | ? { providerOptions: input.providerOptions } | ||
| 297 | : {}), | ||
| 298 | }); | 296 | }); |
| 299 | yield* analytics.record("provider.session.started", { | 297 | yield* analytics.record("provider.session.started", { |
| 300 | provider: session.provider, | 298 | provider: session.provider, |
apps/server/src/serverLayers.ts+2| ... | @@ -17,6 +17,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma | ... | @@ -17,6 +17,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma |
| 17 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; | 17 | import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; |
| 18 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; | 18 | import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; |
| 19 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; | 19 | import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; |
| 20 | import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; | ||
| 20 | import { ProviderUnsupportedError } from "./provider/Errors"; | 21 | import { ProviderUnsupportedError } from "./provider/Errors"; |
| 21 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; | 22 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; |
| 22 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; | 23 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; |
| ... | @@ -87,6 +88,7 @@ export function makeServerRuntimeServicesLayer() { | ... | @@ -87,6 +88,7 @@ export function makeServerRuntimeServicesLayer() { |
| 87 | OrchestrationProjectionSnapshotQueryLive, | 88 | OrchestrationProjectionSnapshotQueryLive, |
| 88 | CheckpointStoreLive, | 89 | CheckpointStoreLive, |
| 89 | checkpointDiffQueryLayer, | 90 | checkpointDiffQueryLayer, |
| 91 | RuntimeReceiptBusLive, | ||
| 90 | ); | 92 | ); |
| 91 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 93 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| 92 | Layer.provideMerge(runtimeServicesLayer), | 94 | Layer.provideMerge(runtimeServicesLayer), |
apps/server/src/wsServer.test.ts+201-158| ... | @@ -27,6 +27,8 @@ import { | ... | @@ -27,6 +27,8 @@ import { |
| 27 | type ServerProviderStatus, | 27 | type ServerProviderStatus, |
| 28 | type KeybindingsConfig, | 28 | type KeybindingsConfig, |
| 29 | type ResolvedKeybindingsConfig, | 29 | type ResolvedKeybindingsConfig, |
| 30 | type WsPushChannel, | ||
| 31 | type WsPushMessage, | ||
| 30 | type WsPush, | 32 | type WsPush, |
| 31 | } from "@t3tools/contracts"; | 33 | } from "@t3tools/contracts"; |
| 32 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; | 34 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; |
| ... | @@ -52,13 +54,6 @@ import { GitCommandError, GitManagerError } from "./git/Errors.ts"; | ... | @@ -52,13 +54,6 @@ import { GitCommandError, GitManagerError } from "./git/Errors.ts"; |
| 52 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; | 54 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; |
| 53 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | 55 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 54 | 56 | ||
| 55 | interface PendingMessages { | ||
| 56 | queue: unknown[]; | ||
| 57 | waiters: Array<(message: unknown) => void>; | ||
| 58 | } | ||
| 59 | |||
| 60 | const pendingBySocket = new WeakMap<WebSocket, PendingMessages>(); | ||
| 61 | |||
| 62 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | 57 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); |
| 63 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | 58 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); |
| 64 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | 59 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); |
| ... | @@ -214,21 +209,97 @@ class MockTerminalManager implements TerminalManagerShape { | ... | @@ -214,21 +209,97 @@ class MockTerminalManager implements TerminalManagerShape { |
| 214 | readonly dispose: TerminalManagerShape["dispose"] = Effect.void; | 209 | readonly dispose: TerminalManagerShape["dispose"] = Effect.void; |
| 215 | } | 210 | } |
| 216 | 211 | ||
| 217 | function connectWs(port: number, token?: string): Promise<WebSocket> { | 212 | // --------------------------------------------------------------------------- |
| 213 | // WebSocket test harness | ||
| 214 | // | ||
| 215 | // Incoming messages are split into two channels: | ||
| 216 | // - pushChannel: server push envelopes (type === "push") | ||
| 217 | // - responseChannel: request/response envelopes (have an "id" field) | ||
| 218 | // | ||
| 219 | // This means sendRequest never has to skip push messages and waitForPush | ||
| 220 | // never has to skip response messages, eliminating a class of ordering bugs. | ||
| 221 | // --------------------------------------------------------------------------- | ||
| 222 | |||
| 223 | interface MessageChannel<T> { | ||
| 224 | queue: T[]; | ||
| 225 | waiters: Array<{ | ||
| 226 | resolve: (value: T) => void; | ||
| 227 | reject: (error: Error) => void; | ||
| 228 | timeoutId: ReturnType<typeof setTimeout> | null; | ||
| 229 | }>; | ||
| 230 | } | ||
| 231 | |||
| 232 | interface SocketChannels { | ||
| 233 | push: MessageChannel<WsPush>; | ||
| 234 | response: MessageChannel<WebSocketResponse>; | ||
| 235 | } | ||
| 236 | |||
| 237 | const channelsBySocket = new WeakMap<WebSocket, SocketChannels>(); | ||
| 238 | |||
| 239 | function enqueue<T>(channel: MessageChannel<T>, item: T) { | ||
| 240 | const waiter = channel.waiters.shift(); | ||
| 241 | if (waiter) { | ||
| 242 | if (waiter.timeoutId !== null) clearTimeout(waiter.timeoutId); | ||
| 243 | waiter.resolve(item); | ||
| 244 | return; | ||
| 245 | } | ||
| 246 | channel.queue.push(item); | ||
| 247 | } | ||
| 248 | |||
| 249 | function dequeue<T>(channel: MessageChannel<T>, timeoutMs: number): Promise<T> { | ||
| 250 | const queued = channel.queue.shift(); | ||
| 251 | if (queued !== undefined) { | ||
| 252 | return Promise.resolve(queued); | ||
| 253 | } | ||
| 254 | |||
| 255 | return new Promise((resolve, reject) => { | ||
| 256 | const waiter = { | ||
| 257 | resolve, | ||
| 258 | reject, | ||
| 259 | timeoutId: setTimeout(() => { | ||
| 260 | const index = channel.waiters.indexOf(waiter); | ||
| 261 | if (index >= 0) channel.waiters.splice(index, 1); | ||
| 262 | reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`)); | ||
| 263 | }, timeoutMs) as ReturnType<typeof setTimeout>, | ||
| 264 | }; | ||
| 265 | channel.waiters.push(waiter); | ||
| 266 | }); | ||
| 267 | } | ||
| 268 | |||
| 269 | function isWsPushEnvelope(message: unknown): message is WsPush { | ||
| 270 | if (typeof message !== "object" || message === null) return false; | ||
| 271 | if (!("type" in message) || !("channel" in message)) return false; | ||
| 272 | return (message as { type?: unknown }).type === "push"; | ||
| 273 | } | ||
| 274 | |||
| 275 | function asWebSocketResponse(message: unknown): WebSocketResponse | null { | ||
| 276 | if (typeof message !== "object" || message === null) return null; | ||
| 277 | if (!("id" in message)) return null; | ||
| 278 | const id = (message as { id?: unknown }).id; | ||
| 279 | if (typeof id !== "string") return null; | ||
| 280 | return message as WebSocketResponse; | ||
| 281 | } | ||
| 282 | |||
| 283 | function connectWsOnce(port: number, token?: string): Promise<WebSocket> { | ||
| 218 | return new Promise((resolve, reject) => { | 284 | return new Promise((resolve, reject) => { |
| 219 | const query = token ? `?token=${encodeURIComponent(token)}` : ""; | 285 | const query = token ? `?token=${encodeURIComponent(token)}` : ""; |
| 220 | const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`); | 286 | const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`); |
| 221 | const pending: PendingMessages = { queue: [], waiters: [] }; | 287 | const channels: SocketChannels = { |
| 222 | pendingBySocket.set(ws, pending); | 288 | push: { queue: [], waiters: [] }, |
| 289 | response: { queue: [], waiters: [] }, | ||
| 290 | }; | ||
| 291 | channelsBySocket.set(ws, channels); | ||
| 223 | 292 | ||
| 224 | ws.on("message", (raw) => { | 293 | ws.on("message", (raw) => { |
| 225 | const parsed = JSON.parse(String(raw)); | 294 | const parsed = JSON.parse(String(raw)); |
| 226 | const waiter = pending.waiters.shift(); | 295 | if (isWsPushEnvelope(parsed)) { |
| 227 | if (waiter) { | 296 | enqueue(channels.push, parsed); |
| 228 | waiter(parsed); | 297 | } else { |
| 229 | return; | 298 | const response = asWebSocketResponse(parsed); |
| 299 | if (response) { | ||
| 300 | enqueue(channels.response, response); | ||
| 301 | } | ||
| 230 | } | 302 | } |
| 231 | pending.queue.push(parsed); | ||
| 232 | }); | 303 | }); |
| 233 | 304 | ||
| 234 | ws.once("open", () => resolve(ws)); | 305 | ws.once("open", () => resolve(ws)); |
| ... | @@ -236,28 +307,31 @@ function connectWs(port: number, token?: string): Promise<WebSocket> { | ... | @@ -236,28 +307,31 @@ function connectWs(port: number, token?: string): Promise<WebSocket> { |
| 236 | }); | 307 | }); |
| 237 | } | 308 | } |
| 238 | 309 | ||
| 239 | function waitForMessage(ws: WebSocket): Promise<unknown> { | 310 | async function connectWs(port: number, token?: string, attempts = 5): Promise<WebSocket> { |
| 240 | const pending = pendingBySocket.get(ws); | 311 | let lastError: unknown = new Error("WebSocket connection failed"); |
| 241 | if (!pending) { | ||
| 242 | return Promise.reject(new Error("WebSocket not initialized")); | ||
| 243 | } | ||
| 244 | 312 | ||
| 245 | const queued = pending.queue.shift(); | 313 | for (let attempt = 0; attempt < attempts; attempt += 1) { |
| 246 | if (queued !== undefined) { | 314 | try { |
| 247 | return Promise.resolve(queued); | 315 | return await connectWsOnce(port, token); |
| 316 | } catch (error) { | ||
| 317 | lastError = error; | ||
| 318 | if (attempt < attempts - 1) { | ||
| 319 | await new Promise((resolve) => setTimeout(resolve, 25)); | ||
| 320 | } | ||
| 321 | } | ||
| 248 | } | 322 | } |
| 249 | 323 | ||
| 250 | return new Promise((resolve) => { | 324 | throw lastError; |
| 251 | pending.waiters.push(resolve); | ||
| 252 | }); | ||
| 253 | } | 325 | } |
| 254 | 326 | ||
| 255 | function asWebSocketResponse(message: unknown): WebSocketResponse | null { | 327 | /** Connect and wait for the server.welcome push. Returns [ws, welcomeData]. */ |
| 256 | if (typeof message !== "object" || message === null) return null; | 328 | async function connectAndAwaitWelcome( |
| 257 | if (!("id" in message)) return null; | 329 | port: number, |
| 258 | const id = (message as { id?: unknown }).id; | 330 | token?: string, |
| 259 | if (typeof id !== "string") return null; | 331 | ): Promise<[WebSocket, WsPushMessage<typeof WS_CHANNELS.serverWelcome>]> { |
| 260 | return message as WebSocketResponse; | 332 | const ws = await connectWs(port, token); |
| 333 | const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome); | ||
| 334 | return [ws, welcome]; | ||
| 261 | } | 335 | } |
| 262 | 336 | ||
| 263 | async function sendRequest( | 337 | async function sendRequest( |
| ... | @@ -265,6 +339,9 @@ async function sendRequest( | ... | @@ -265,6 +339,9 @@ async function sendRequest( |
| 265 | method: string, | 339 | method: string, |
| 266 | params?: unknown, | 340 | params?: unknown, |
| 267 | ): Promise<WebSocketResponse> { | 341 | ): Promise<WebSocketResponse> { |
| 342 | const channels = channelsBySocket.get(ws); | ||
| 343 | if (!channels) throw new Error("WebSocket not initialized"); | ||
| 344 | |||
| 268 | const id = crypto.randomUUID(); | 345 | const id = crypto.randomUUID(); |
| 269 | const body = | 346 | const body = |
| 270 | method === ORCHESTRATION_WS_METHODS.dispatchCommand | 347 | method === ORCHESTRATION_WS_METHODS.dispatchCommand |
| ... | @@ -272,44 +349,53 @@ async function sendRequest( | ... | @@ -272,44 +349,53 @@ async function sendRequest( |
| 272 | : params && typeof params === "object" && !Array.isArray(params) | 349 | : params && typeof params === "object" && !Array.isArray(params) |
| 273 | ? { _tag: method, ...(params as Record<string, unknown>) } | 350 | ? { _tag: method, ...(params as Record<string, unknown>) } |
| 274 | : { _tag: method }; | 351 | : { _tag: method }; |
| 275 | const message = JSON.stringify({ id, body }); | 352 | ws.send(JSON.stringify({ id, body })); |
| 276 | ws.send(message); | ||
| 277 | 353 | ||
| 278 | // Wait for response with matching id | 354 | // Response channel only contains responses — no push filtering needed |
| 279 | while (true) { | 355 | while (true) { |
| 280 | const parsed = asWebSocketResponse(await waitForMessage(ws)); | 356 | const response = await dequeue(channels.response, 60_000); |
| 281 | if (!parsed) { | 357 | if (response.id === id || response.id === "unknown") { |
| 282 | continue; | 358 | return response; |
| 283 | } | ||
| 284 | if (parsed.id === id) { | ||
| 285 | return parsed; | ||
| 286 | } | ||
| 287 | if (parsed.id === "unknown") { | ||
| 288 | return parsed; | ||
| 289 | } | 359 | } |
| 290 | } | 360 | } |
| 291 | } | 361 | } |
| 292 | 362 | ||
| 293 | async function waitForPush( | 363 | async function waitForPush<C extends WsPushChannel>( |
| 294 | ws: WebSocket, | 364 | ws: WebSocket, |
| 295 | channel: string, | 365 | channel: C, |
| 296 | predicate?: (push: WsPush) => boolean, | 366 | predicate?: (push: WsPushMessage<C>) => boolean, |
| 297 | maxMessages = 120, | 367 | maxMessages = 120, |
| 298 | ): Promise<WsPush> { | 368 | idleTimeoutMs = 5_000, |
| 299 | const take = async (remaining: number): Promise<WsPush> => { | 369 | ): Promise<WsPushMessage<C>> { |
| 300 | if (remaining <= 0) { | 370 | const channels = channelsBySocket.get(ws); |
| 301 | throw new Error(`Timed out waiting for push on ${channel}`); | 371 | if (!channels) throw new Error("WebSocket not initialized"); |
| 302 | } | 372 | |
| 303 | const message = (await waitForMessage(ws)) as WsPush; | 373 | for (let remaining = maxMessages; remaining > 0; remaining--) { |
| 304 | if (message.type !== "push" || message.channel !== channel) { | 374 | const push = await dequeue(channels.push, idleTimeoutMs); |
| 305 | return take(remaining - 1); | 375 | if (push.channel !== channel) continue; |
| 306 | } | 376 | const typed = push as WsPushMessage<C>; |
| 307 | if (!predicate || predicate(message)) { | 377 | if (!predicate || predicate(typed)) return typed; |
| 308 | return message; | 378 | } |
| 379 | throw new Error(`Timed out waiting for push on ${channel}`); | ||
| 380 | } | ||
| 381 | |||
| 382 | async function rewriteKeybindingsAndWaitForPush( | ||
| 383 | ws: WebSocket, | ||
| 384 | keybindingsPath: string, | ||
| 385 | contents: string, | ||
| 386 | predicate: (push: WsPushMessage<typeof WS_CHANNELS.serverConfigUpdated>) => boolean, | ||
| 387 | attempts = 3, | ||
| 388 | ): Promise<WsPushMessage<typeof WS_CHANNELS.serverConfigUpdated>> { | ||
| 389 | let lastError: unknown; | ||
| 390 | for (let attempt = 0; attempt < attempts; attempt++) { | ||
| 391 | fs.writeFileSync(keybindingsPath, contents, "utf8"); | ||
| 392 | try { | ||
| 393 | return await waitForPush(ws, WS_CHANNELS.serverConfigUpdated, predicate, 20, 3_000); | ||
| 394 | } catch (error) { | ||
| 395 | lastError = error; | ||
| 309 | } | 396 | } |
| 310 | return take(remaining - 1); | 397 | } |
| 311 | }; | 398 | throw lastError; |
| 312 | return take(maxMessages); | ||
| 313 | } | 399 | } |
| 314 | 400 | ||
| 315 | async function requestPath( | 401 | async function requestPath( |
| ... | @@ -489,18 +575,15 @@ describe("WebSocket Server", () => { | ... | @@ -489,18 +575,15 @@ describe("WebSocket Server", () => { |
| 489 | 575 | ||
| 490 | it("sends welcome message on connect", async () => { | 576 | it("sends welcome message on connect", async () => { |
| 491 | server = await createTestServer({ cwd: "/test/project" }); | 577 | server = await createTestServer({ cwd: "/test/project" }); |
| 492 | // Get the actual port after listen | ||
| 493 | const addr = server.address(); | 578 | const addr = server.address(); |
| 494 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 579 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 495 | expect(port).toBeGreaterThan(0); | 580 | expect(port).toBeGreaterThan(0); |
| 496 | 581 | ||
| 497 | const ws = await connectWs(port); | 582 | const [ws, welcome] = await connectAndAwaitWelcome(port); |
| 498 | connections.push(ws); | 583 | connections.push(ws); |
| 499 | 584 | ||
| 500 | const message = (await waitForMessage(ws)) as WsPush; | 585 | expect(welcome.type).toBe("push"); |
| 501 | expect(message.type).toBe("push"); | 586 | expect(welcome.data).toEqual({ |
| 502 | expect(message.channel).toBe(WS_CHANNELS.serverWelcome); | ||
| 503 | expect(message.data).toEqual({ | ||
| 504 | cwd: "/test/project", | 587 | cwd: "/test/project", |
| 505 | projectName: "project", | 588 | projectName: "project", |
| 506 | }); | 589 | }); |
| ... | @@ -589,10 +672,8 @@ describe("WebSocket Server", () => { | ... | @@ -589,10 +672,8 @@ describe("WebSocket Server", () => { |
| 589 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 672 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 590 | expect(port).toBeGreaterThan(0); | 673 | expect(port).toBeGreaterThan(0); |
| 591 | 674 | ||
| 592 | const ws = await connectWs(port); | 675 | const [ws, welcome] = await connectAndAwaitWelcome(port); |
| 593 | connections.push(ws); | 676 | connections.push(ws); |
| 594 | const welcome = (await waitForMessage(ws)) as WsPush; // welcome | ||
| 595 | expect(welcome.channel).toBe(WS_CHANNELS.serverWelcome); | ||
| 596 | expect(welcome.data).toEqual( | 677 | expect(welcome.data).toEqual( |
| 597 | expect.objectContaining({ | 678 | expect.objectContaining({ |
| 598 | cwd: "/test/bootstrap-workspace", | 679 | cwd: "/test/bootstrap-workspace", |
| ... | @@ -666,9 +747,8 @@ describe("WebSocket Server", () => { | ... | @@ -666,9 +747,8 @@ describe("WebSocket Server", () => { |
| 666 | let port = typeof addr === "object" && addr !== null ? addr.port : 0; | 747 | let port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 667 | expect(port).toBeGreaterThan(0); | 748 | expect(port).toBeGreaterThan(0); |
| 668 | 749 | ||
| 669 | const firstWs = await connectWs(port); | 750 | const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port); |
| 670 | connections.push(firstWs); | 751 | connections.push(firstWs); |
| 671 | const firstWelcome = (await waitForMessage(firstWs)) as WsPush; | ||
| 672 | const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string }) | 752 | const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string }) |
| 673 | .bootstrapProjectId; | 753 | .bootstrapProjectId; |
| 674 | const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string }) | 754 | const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string }) |
| ... | @@ -690,10 +770,8 @@ describe("WebSocket Server", () => { | ... | @@ -690,10 +770,8 @@ describe("WebSocket Server", () => { |
| 690 | port = typeof addr === "object" && addr !== null ? addr.port : 0; | 770 | port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 691 | expect(port).toBeGreaterThan(0); | 771 | expect(port).toBeGreaterThan(0); |
| 692 | 772 | ||
| 693 | const secondWs = await connectWs(port); | 773 | const [secondWs, secondWelcome] = await connectAndAwaitWelcome(port); |
| 694 | connections.push(secondWs); | 774 | connections.push(secondWs); |
| 695 | const secondWelcome = (await waitForMessage(secondWs)) as WsPush; | ||
| 696 | expect(secondWelcome.channel).toBe(WS_CHANNELS.serverWelcome); | ||
| 697 | expect(secondWelcome.data).toEqual( | 775 | expect(secondWelcome.data).toEqual( |
| 698 | expect.objectContaining({ | 776 | expect.objectContaining({ |
| 699 | cwd, | 777 | cwd, |
| ... | @@ -717,9 +795,8 @@ describe("WebSocket Server", () => { | ... | @@ -717,9 +795,8 @@ describe("WebSocket Server", () => { |
| 717 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 795 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 718 | expect(port).toBeGreaterThan(0); | 796 | expect(port).toBeGreaterThan(0); |
| 719 | 797 | ||
| 720 | const ws = await connectWs(port); | 798 | const [ws] = await connectAndAwaitWelcome(port); |
| 721 | connections.push(ws); | 799 | connections.push(ws); |
| 722 | await waitForMessage(ws); | ||
| 723 | 800 | ||
| 724 | expect( | 801 | expect( |
| 725 | logSpy.mock.calls.some(([message]) => { | 802 | logSpy.mock.calls.some(([message]) => { |
| ... | @@ -742,12 +819,9 @@ describe("WebSocket Server", () => { | ... | @@ -742,12 +819,9 @@ describe("WebSocket Server", () => { |
| 742 | const addr = server.address(); | 819 | const addr = server.address(); |
| 743 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 820 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 744 | 821 | ||
| 745 | const ws = await connectWs(port); | 822 | const [ws] = await connectAndAwaitWelcome(port); |
| 746 | connections.push(ws); | 823 | connections.push(ws); |
| 747 | 824 | ||
| 748 | // Consume welcome message | ||
| 749 | await waitForMessage(ws); | ||
| 750 | |||
| 751 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | 825 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 752 | expect(response.error).toBeUndefined(); | 826 | expect(response.error).toBeUndefined(); |
| 753 | expect(response.result).toEqual({ | 827 | expect(response.result).toEqual({ |
| ... | @@ -770,9 +844,8 @@ describe("WebSocket Server", () => { | ... | @@ -770,9 +844,8 @@ describe("WebSocket Server", () => { |
| 770 | const addr = server.address(); | 844 | const addr = server.address(); |
| 771 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 845 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 772 | 846 | ||
| 773 | const ws = await connectWs(port); | 847 | const [ws] = await connectAndAwaitWelcome(port); |
| 774 | connections.push(ws); | 848 | connections.push(ws); |
| 775 | await waitForMessage(ws); | ||
| 776 | 849 | ||
| 777 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | 850 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 778 | expect(response.error).toBeUndefined(); | 851 | expect(response.error).toBeUndefined(); |
| ... | @@ -801,9 +874,8 @@ describe("WebSocket Server", () => { | ... | @@ -801,9 +874,8 @@ describe("WebSocket Server", () => { |
| 801 | const addr = server.address(); | 874 | const addr = server.address(); |
| 802 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 875 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 803 | 876 | ||
| 804 | const ws = await connectWs(port); | 877 | const [ws] = await connectAndAwaitWelcome(port); |
| 805 | connections.push(ws); | 878 | connections.push(ws); |
| 806 | await waitForMessage(ws); | ||
| 807 | 879 | ||
| 808 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | 880 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 809 | expect(response.error).toBeUndefined(); | 881 | expect(response.error).toBeUndefined(); |
| ... | @@ -841,9 +913,8 @@ describe("WebSocket Server", () => { | ... | @@ -841,9 +913,8 @@ describe("WebSocket Server", () => { |
| 841 | const addr = server.address(); | 913 | const addr = server.address(); |
| 842 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 914 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 843 | 915 | ||
| 844 | const ws = await connectWs(port); | 916 | const [ws] = await connectAndAwaitWelcome(port); |
| 845 | connections.push(ws); | 917 | connections.push(ws); |
| 846 | await waitForMessage(ws); | ||
| 847 | 918 | ||
| 848 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | 919 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 849 | expect(response.error).toBeUndefined(); | 920 | expect(response.error).toBeUndefined(); |
| ... | @@ -885,32 +956,28 @@ describe("WebSocket Server", () => { | ... | @@ -885,32 +956,28 @@ describe("WebSocket Server", () => { |
| 885 | const addr = server.address(); | 956 | const addr = server.address(); |
| 886 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 957 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 887 | 958 | ||
| 888 | const ws = await connectWs(port); | 959 | const [ws] = await connectAndAwaitWelcome(port); |
| 889 | connections.push(ws); | 960 | connections.push(ws); |
| 890 | await waitForMessage(ws); | ||
| 891 | 961 | ||
| 892 | fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); | 962 | const malformedPush = await rewriteKeybindingsAndWaitForPush( |
| 893 | const malformedPush = await waitForPush( | ||
| 894 | ws, | 963 | ws, |
| 895 | WS_CHANNELS.serverConfigUpdated, | 964 | keybindingsPath, |
| 965 | "{ not-json", | ||
| 896 | (push) => | 966 | (push) => |
| 897 | Array.isArray((push.data as { issues?: unknown[] }).issues) && | 967 | Array.isArray(push.data.issues) && |
| 898 | Boolean((push.data as { issues: Array<{ kind: string }> }).issues[0]) && | 968 | Boolean(push.data.issues[0]) && |
| 899 | (push.data as { issues: Array<{ kind: string }> }).issues[0]!.kind === | 969 | push.data.issues[0]!.kind === "keybindings.malformed-config", |
| 900 | "keybindings.malformed-config", | ||
| 901 | ); | 970 | ); |
| 902 | expect(malformedPush.data).toEqual({ | 971 | expect(malformedPush.data).toEqual({ |
| 903 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], | 972 | issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], |
| 904 | providers: defaultProviderStatuses, | 973 | providers: defaultProviderStatuses, |
| 905 | }); | 974 | }); |
| 906 | 975 | ||
| 907 | fs.writeFileSync(keybindingsPath, "[]", "utf8"); | 976 | const successPush = await rewriteKeybindingsAndWaitForPush( |
| 908 | const successPush = await waitForPush( | ||
| 909 | ws, | 977 | ws, |
| 910 | WS_CHANNELS.serverConfigUpdated, | 978 | keybindingsPath, |
| 911 | (push) => | 979 | "[]", |
| 912 | Array.isArray((push.data as { issues?: unknown[] }).issues) && | 980 | (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, |
| 913 | (push.data as { issues: unknown[] }).issues.length === 0, | ||
| 914 | ); | 981 | ); |
| 915 | expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); | 982 | expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); |
| 916 | }); | 983 | }); |
| ... | @@ -929,9 +996,8 @@ describe("WebSocket Server", () => { | ... | @@ -929,9 +996,8 @@ describe("WebSocket Server", () => { |
| 929 | const addr = server.address(); | 996 | const addr = server.address(); |
| 930 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 997 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 931 | 998 | ||
| 932 | const ws = await connectWs(port); | 999 | const [ws] = await connectAndAwaitWelcome(port); |
| 933 | connections.push(ws); | 1000 | connections.push(ws); |
| 934 | await waitForMessage(ws); | ||
| 935 | 1001 | ||
| 936 | const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, { | 1002 | const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, { |
| 937 | cwd: "/my/workspace", | 1003 | cwd: "/my/workspace", |
| ... | @@ -957,11 +1023,9 @@ describe("WebSocket Server", () => { | ... | @@ -957,11 +1023,9 @@ describe("WebSocket Server", () => { |
| 957 | const addr = server.address(); | 1023 | const addr = server.address(); |
| 958 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1024 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 959 | 1025 | ||
| 960 | const ws = await connectWs(port); | 1026 | const [ws] = await connectAndAwaitWelcome(port); |
| 961 | connections.push(ws); | 1027 | connections.push(ws); |
| 962 | 1028 | ||
| 963 | await waitForMessage(ws); | ||
| 964 | |||
| 965 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); | 1029 | const response = await sendRequest(ws, WS_METHODS.serverGetConfig); |
| 966 | expect(response.error).toBeUndefined(); | 1030 | expect(response.error).toBeUndefined(); |
| 967 | const persistedConfig = JSON.parse( | 1031 | const persistedConfig = JSON.parse( |
| ... | @@ -991,9 +1055,8 @@ describe("WebSocket Server", () => { | ... | @@ -991,9 +1055,8 @@ describe("WebSocket Server", () => { |
| 991 | const addr = server.address(); | 1055 | const addr = server.address(); |
| 992 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1056 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 993 | 1057 | ||
| 994 | const ws = await connectWs(port); | 1058 | const [ws] = await connectAndAwaitWelcome(port); |
| 995 | connections.push(ws); | 1059 | connections.push(ws); |
| 996 | await waitForMessage(ws); | ||
| 997 | 1060 | ||
| 998 | const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { | 1061 | const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { |
| 999 | key: "mod+shift+r", | 1062 | key: "mod+shift+r", |
| ... | @@ -1033,12 +1096,9 @@ describe("WebSocket Server", () => { | ... | @@ -1033,12 +1096,9 @@ describe("WebSocket Server", () => { |
| 1033 | const addr = server.address(); | 1096 | const addr = server.address(); |
| 1034 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1097 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1035 | 1098 | ||
| 1036 | const ws = await connectWs(port); | 1099 | const [ws] = await connectAndAwaitWelcome(port); |
| 1037 | connections.push(ws); | 1100 | connections.push(ws); |
| 1038 | 1101 | ||
| 1039 | // Consume welcome push | ||
| 1040 | await waitForMessage(ws); | ||
| 1041 | |||
| 1042 | const response = await sendRequest(ws, "nonexistent.method"); | 1102 | const response = await sendRequest(ws, "nonexistent.method"); |
| 1043 | expect(response.error).toBeDefined(); | 1103 | expect(response.error).toBeDefined(); |
| 1044 | expect(response.error!.message).toContain("Invalid request format"); | 1104 | expect(response.error!.message).toContain("Invalid request format"); |
| ... | @@ -1049,9 +1109,8 @@ describe("WebSocket Server", () => { | ... | @@ -1049,9 +1109,8 @@ describe("WebSocket Server", () => { |
| 1049 | const addr = server.address(); | 1109 | const addr = server.address(); |
| 1050 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1110 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1051 | 1111 | ||
| 1052 | const ws = await connectWs(port); | 1112 | const [ws] = await connectAndAwaitWelcome(port); |
| 1053 | connections.push(ws); | 1113 | connections.push(ws); |
| 1054 | await waitForMessage(ws); | ||
| 1055 | 1114 | ||
| 1056 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | 1115 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { |
| 1057 | threadId: "thread-missing", | 1116 | threadId: "thread-missing", |
| ... | @@ -1067,9 +1126,8 @@ describe("WebSocket Server", () => { | ... | @@ -1067,9 +1126,8 @@ describe("WebSocket Server", () => { |
| 1067 | const addr = server.address(); | 1126 | const addr = server.address(); |
| 1068 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1127 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1069 | 1128 | ||
| 1070 | const ws = await connectWs(port); | 1129 | const [ws] = await connectAndAwaitWelcome(port); |
| 1071 | connections.push(ws); | 1130 | connections.push(ws); |
| 1072 | await waitForMessage(ws); | ||
| 1073 | 1131 | ||
| 1074 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { | 1132 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { |
| 1075 | threadId: "thread-any", | 1133 | threadId: "thread-any", |
| ... | @@ -1087,9 +1145,8 @@ describe("WebSocket Server", () => { | ... | @@ -1087,9 +1145,8 @@ describe("WebSocket Server", () => { |
| 1087 | const addr = server.address(); | 1145 | const addr = server.address(); |
| 1088 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1146 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1089 | 1147 | ||
| 1090 | const ws = await connectWs(port); | 1148 | const [ws] = await connectAndAwaitWelcome(port); |
| 1091 | connections.push(ws); | 1149 | connections.push(ws); |
| 1092 | await waitForMessage(ws); | ||
| 1093 | 1150 | ||
| 1094 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, { | 1151 | const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, { |
| 1095 | threadId: "thread-missing", | 1152 | threadId: "thread-missing", |
| ... | @@ -1104,9 +1161,8 @@ describe("WebSocket Server", () => { | ... | @@ -1104,9 +1161,8 @@ describe("WebSocket Server", () => { |
| 1104 | const addr = server.address(); | 1161 | const addr = server.address(); |
| 1105 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1162 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1106 | 1163 | ||
| 1107 | const ws = await connectWs(port); | 1164 | const [ws] = await connectAndAwaitWelcome(port); |
| 1108 | connections.push(ws); | 1165 | connections.push(ws); |
| 1109 | await waitForMessage(ws); | ||
| 1110 | 1166 | ||
| 1111 | const workspaceRoot = makeTempDir("t3code-ws-diff-project-"); | 1167 | const workspaceRoot = makeTempDir("t3code-ws-diff-project-"); |
| 1112 | const createdAt = new Date().toISOString(); | 1168 | const createdAt = new Date().toISOString(); |
| ... | @@ -1183,9 +1239,8 @@ describe("WebSocket Server", () => { | ... | @@ -1183,9 +1239,8 @@ describe("WebSocket Server", () => { |
| 1183 | const addr = server.address(); | 1239 | const addr = server.address(); |
| 1184 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1240 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1185 | 1241 | ||
| 1186 | const ws = await connectWs(port); | 1242 | const [ws] = await connectAndAwaitWelcome(port); |
| 1187 | connections.push(ws); | 1243 | connections.push(ws); |
| 1188 | await waitForMessage(ws); | ||
| 1189 | 1244 | ||
| 1190 | const workspaceRoot = makeTempDir("t3code-ws-project-"); | 1245 | const workspaceRoot = makeTempDir("t3code-ws-project-"); |
| 1191 | const createdAt = new Date().toISOString(); | 1246 | const createdAt = new Date().toISOString(); |
| ... | @@ -1276,9 +1331,8 @@ describe("WebSocket Server", () => { | ... | @@ -1276,9 +1331,8 @@ describe("WebSocket Server", () => { |
| 1276 | const addr = server.address(); | 1331 | const addr = server.address(); |
| 1277 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1332 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1278 | 1333 | ||
| 1279 | const ws = await connectWs(port); | 1334 | const [ws] = await connectAndAwaitWelcome(port); |
| 1280 | connections.push(ws); | 1335 | connections.push(ws); |
| 1281 | await waitForMessage(ws); | ||
| 1282 | 1336 | ||
| 1283 | const open = await sendRequest(ws, WS_METHODS.terminalOpen, { | 1337 | const open = await sendRequest(ws, WS_METHODS.terminalOpen, { |
| 1284 | threadId: "thread-1", | 1338 | threadId: "thread-1", |
| ... | @@ -1331,8 +1385,13 @@ describe("WebSocket Server", () => { | ... | @@ -1331,8 +1385,13 @@ describe("WebSocket Server", () => { |
| 1331 | }; | 1385 | }; |
| 1332 | terminalManager.emitEvent(manualEvent); | 1386 | terminalManager.emitEvent(manualEvent); |
| 1333 | 1387 | ||
| 1334 | const push = await waitForPush(ws, WS_CHANNELS.terminalEvent); | 1388 | const push = await waitForPush( |
| 1335 | expect((push.data as TerminalEvent).type).toBe("output"); | 1389 | ws, |
| 1390 | WS_CHANNELS.terminalEvent, | ||
| 1391 | (candidate) => (candidate.data as TerminalEvent).type === "output", | ||
| 1392 | ); | ||
| 1393 | expect(push.type).toBe("push"); | ||
| 1394 | expect(push.channel).toBe(WS_CHANNELS.terminalEvent); | ||
| 1336 | }); | 1395 | }); |
| 1337 | 1396 | ||
| 1338 | it("detaches terminal event listener on stop for injected manager", async () => { | 1397 | it("detaches terminal event listener on stop for injected manager", async () => { |
| ... | @@ -1355,9 +1414,8 @@ describe("WebSocket Server", () => { | ... | @@ -1355,9 +1414,8 @@ describe("WebSocket Server", () => { |
| 1355 | const addr = server.address(); | 1414 | const addr = server.address(); |
| 1356 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1415 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1357 | 1416 | ||
| 1358 | const ws = await connectWs(port); | 1417 | const [ws] = await connectAndAwaitWelcome(port); |
| 1359 | connections.push(ws); | 1418 | connections.push(ws); |
| 1360 | await waitForMessage(ws); | ||
| 1361 | 1419 | ||
| 1362 | const response = await sendRequest(ws, WS_METHODS.terminalOpen, { | 1420 | const response = await sendRequest(ws, WS_METHODS.terminalOpen, { |
| 1363 | threadId: "", | 1421 | threadId: "", |
| ... | @@ -1373,21 +1431,17 @@ describe("WebSocket Server", () => { | ... | @@ -1373,21 +1431,17 @@ describe("WebSocket Server", () => { |
| 1373 | const addr = server.address(); | 1431 | const addr = server.address(); |
| 1374 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1432 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1375 | 1433 | ||
| 1376 | const ws = await connectWs(port); | 1434 | const [ws] = await connectAndAwaitWelcome(port); |
| 1377 | connections.push(ws); | 1435 | connections.push(ws); |
| 1378 | 1436 | ||
| 1379 | // Consume welcome | ||
| 1380 | await waitForMessage(ws); | ||
| 1381 | |||
| 1382 | // Send garbage | 1437 | // Send garbage |
| 1383 | ws.send("not json at all"); | 1438 | ws.send("not json at all"); |
| 1384 | 1439 | ||
| 1440 | // Error response goes to the response channel | ||
| 1441 | const channels = channelsBySocket.get(ws)!; | ||
| 1385 | let response: WebSocketResponse | null = null; | 1442 | let response: WebSocketResponse | null = null; |
| 1386 | for (let attempt = 0; attempt < 5; attempt += 1) { | 1443 | for (let attempt = 0; attempt < 5; attempt += 1) { |
| 1387 | const message = asWebSocketResponse(await waitForMessage(ws)); | 1444 | const message = await dequeue(channels.response, 5_000); |
| 1388 | if (!message) { | ||
| 1389 | continue; | ||
| 1390 | } | ||
| 1391 | if (message.id === "unknown") { | 1445 | if (message.id === "unknown") { |
| 1392 | response = message; | 1446 | response = message; |
| 1393 | break; | 1447 | break; |
| ... | @@ -1420,9 +1474,8 @@ describe("WebSocket Server", () => { | ... | @@ -1420,9 +1474,8 @@ describe("WebSocket Server", () => { |
| 1420 | const addr = server.address(); | 1474 | const addr = server.address(); |
| 1421 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1475 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1422 | 1476 | ||
| 1423 | const ws = await connectWs(port); | 1477 | const [ws] = await connectAndAwaitWelcome(port); |
| 1424 | connections.push(ws); | 1478 | connections.push(ws); |
| 1425 | await waitForMessage(ws); | ||
| 1426 | 1479 | ||
| 1427 | ws.send( | 1480 | ws.send( |
| 1428 | JSON.stringify({ | 1481 | JSON.stringify({ |
| ... | @@ -1466,9 +1519,8 @@ describe("WebSocket Server", () => { | ... | @@ -1466,9 +1519,8 @@ describe("WebSocket Server", () => { |
| 1466 | const addr = server.address(); | 1519 | const addr = server.address(); |
| 1467 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1520 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1468 | 1521 | ||
| 1469 | const ws = await connectWs(port); | 1522 | const [ws] = await connectAndAwaitWelcome(port); |
| 1470 | connections.push(ws); | 1523 | connections.push(ws); |
| 1471 | await waitForMessage(ws); | ||
| 1472 | 1524 | ||
| 1473 | const listResponse = await sendRequest(ws, WS_METHODS.projectsList); | 1525 | const listResponse = await sendRequest(ws, WS_METHODS.projectsList); |
| 1474 | expect(listResponse.result).toBeUndefined(); | 1526 | expect(listResponse.result).toBeUndefined(); |
| ... | @@ -1503,9 +1555,8 @@ describe("WebSocket Server", () => { | ... | @@ -1503,9 +1555,8 @@ describe("WebSocket Server", () => { |
| 1503 | const addr = server.address(); | 1555 | const addr = server.address(); |
| 1504 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1556 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1505 | 1557 | ||
| 1506 | const ws = await connectWs(port); | 1558 | const [ws] = await connectAndAwaitWelcome(port); |
| 1507 | connections.push(ws); | 1559 | connections.push(ws); |
| 1508 | await waitForMessage(ws); | ||
| 1509 | 1560 | ||
| 1510 | const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { | 1561 | const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { |
| 1511 | cwd: workspace, | 1562 | cwd: workspace, |
| ... | @@ -1529,9 +1580,8 @@ describe("WebSocket Server", () => { | ... | @@ -1529,9 +1580,8 @@ describe("WebSocket Server", () => { |
| 1529 | const addr = server.address(); | 1580 | const addr = server.address(); |
| 1530 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1581 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1531 | 1582 | ||
| 1532 | const ws = await connectWs(port); | 1583 | const [ws] = await connectAndAwaitWelcome(port); |
| 1533 | connections.push(ws); | 1584 | connections.push(ws); |
| 1534 | await waitForMessage(ws); | ||
| 1535 | 1585 | ||
| 1536 | const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { | 1586 | const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { |
| 1537 | cwd: workspace, | 1587 | cwd: workspace, |
| ... | @@ -1555,9 +1605,8 @@ describe("WebSocket Server", () => { | ... | @@ -1555,9 +1605,8 @@ describe("WebSocket Server", () => { |
| 1555 | const addr = server.address(); | 1605 | const addr = server.address(); |
| 1556 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1606 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1557 | 1607 | ||
| 1558 | const ws = await connectWs(port); | 1608 | const [ws] = await connectAndAwaitWelcome(port); |
| 1559 | connections.push(ws); | 1609 | connections.push(ws); |
| 1560 | await waitForMessage(ws); | ||
| 1561 | 1610 | ||
| 1562 | const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { | 1611 | const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { |
| 1563 | cwd: workspace, | 1612 | cwd: workspace, |
| ... | @@ -1603,9 +1652,8 @@ describe("WebSocket Server", () => { | ... | @@ -1603,9 +1652,8 @@ describe("WebSocket Server", () => { |
| 1603 | const addr = server.address(); | 1652 | const addr = server.address(); |
| 1604 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1653 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1605 | 1654 | ||
| 1606 | const ws = await connectWs(port); | 1655 | const [ws] = await connectAndAwaitWelcome(port); |
| 1607 | connections.push(ws); | 1656 | connections.push(ws); |
| 1608 | await waitForMessage(ws); | ||
| 1609 | 1657 | ||
| 1610 | const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); | 1658 | const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); |
| 1611 | expect(listResponse.error).toBeUndefined(); | 1659 | expect(listResponse.error).toBeUndefined(); |
| ... | @@ -1652,9 +1700,8 @@ describe("WebSocket Server", () => { | ... | @@ -1652,9 +1700,8 @@ describe("WebSocket Server", () => { |
| 1652 | const addr = server.address(); | 1700 | const addr = server.address(); |
| 1653 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1701 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1654 | 1702 | ||
| 1655 | const ws = await connectWs(port); | 1703 | const [ws] = await connectAndAwaitWelcome(port); |
| 1656 | connections.push(ws); | 1704 | connections.push(ws); |
| 1657 | await waitForMessage(ws); | ||
| 1658 | 1705 | ||
| 1659 | const response = await sendRequest(ws, WS_METHODS.gitStatus, { | 1706 | const response = await sendRequest(ws, WS_METHODS.gitStatus, { |
| 1660 | cwd: "/test", | 1707 | cwd: "/test", |
| ... | @@ -1692,9 +1739,8 @@ describe("WebSocket Server", () => { | ... | @@ -1692,9 +1739,8 @@ describe("WebSocket Server", () => { |
| 1692 | const addr = server.address(); | 1739 | const addr = server.address(); |
| 1693 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1740 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1694 | 1741 | ||
| 1695 | const ws = await connectWs(port); | 1742 | const [ws] = await connectAndAwaitWelcome(port); |
| 1696 | connections.push(ws); | 1743 | connections.push(ws); |
| 1697 | await waitForMessage(ws); | ||
| 1698 | 1744 | ||
| 1699 | const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, { | 1745 | const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, { |
| 1700 | cwd: "/test", | 1746 | cwd: "/test", |
| ... | @@ -1741,9 +1787,8 @@ describe("WebSocket Server", () => { | ... | @@ -1741,9 +1787,8 @@ describe("WebSocket Server", () => { |
| 1741 | const addr = server.address(); | 1787 | const addr = server.address(); |
| 1742 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | 1788 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; |
| 1743 | 1789 | ||
| 1744 | const ws = await connectWs(port); | 1790 | const [ws] = await connectAndAwaitWelcome(port); |
| 1745 | connections.push(ws); | 1791 | connections.push(ws); |
| 1746 | await waitForMessage(ws); | ||
| 1747 | 1792 | ||
| 1748 | const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { | 1793 | const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { |
| 1749 | cwd: "/test", | 1794 | cwd: "/test", |
| ... | @@ -1764,9 +1809,7 @@ describe("WebSocket Server", () => { | ... | @@ -1764,9 +1809,7 @@ describe("WebSocket Server", () => { |
| 1764 | 1809 | ||
| 1765 | await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); | 1810 | await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); |
| 1766 | 1811 | ||
| 1767 | const authorizedWs = await connectWs(port, "secret-token"); | 1812 | const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token"); |
| 1768 | connections.push(authorizedWs); | 1813 | connections.push(authorizedWs); |
| 1769 | const welcome = (await waitForMessage(authorizedWs)) as WsPush; | ||
| 1770 | expect(welcome.channel).toBe(WS_CHANNELS.serverWelcome); | ||
| 1771 | }); | 1814 | }); |
| 1772 | }); | 1815 | }); |
apps/server/src/wsServer.ts+69-102| ... | @@ -20,13 +20,12 @@ import { | ... | @@ -20,13 +20,12 @@ import { |
| 20 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 20 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 21 | ProjectId, | 21 | ProjectId, |
| 22 | ThreadId, | 22 | ThreadId, |
| 23 | TerminalEvent, | ||
| 24 | WS_CHANNELS, | 23 | WS_CHANNELS, |
| 25 | WS_METHODS, | 24 | WS_METHODS, |
| 26 | WebSocketRequest, | 25 | WebSocketRequest, |
| 27 | WsPush, | 26 | type WsResponse as WsResponseMessage, |
| 28 | WsResponse, | 27 | WsResponse, |
| 29 | ServerProviderStatus, | 28 | type WsPushEnvelopeBase, |
| 30 | } from "@t3tools/contracts"; | 29 | } from "@t3tools/contracts"; |
| 31 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 30 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 32 | import { | 31 | import { |
| ... | @@ -37,6 +36,7 @@ import { | ... | @@ -37,6 +36,7 @@ import { |
| 37 | Layer, | 36 | Layer, |
| 38 | Path, | 37 | Path, |
| 39 | Ref, | 38 | Ref, |
| 39 | Result, | ||
| 40 | Schema, | 40 | Schema, |
| 41 | Scope, | 41 | Scope, |
| 42 | ServiceMap, | 42 | ServiceMap, |
| ... | @@ -66,6 +66,7 @@ import { | ... | @@ -66,6 +66,7 @@ import { |
| 66 | normalizeAttachmentRelativePath, | 66 | normalizeAttachmentRelativePath, |
| 67 | resolveAttachmentRelativePath, | 67 | resolveAttachmentRelativePath, |
| 68 | } from "./attachmentPaths"; | 68 | } from "./attachmentPaths"; |
| 69 | |||
| 69 | import { | 70 | import { |
| 70 | createAttachmentId, | 71 | createAttachmentId, |
| 71 | resolveAttachmentPath, | 72 | resolveAttachmentPath, |
| ... | @@ -74,6 +75,9 @@ import { | ... | @@ -74,6 +75,9 @@ import { |
| 74 | import { parseBase64DataUrl } from "./imageMime.ts"; | 75 | import { parseBase64DataUrl } from "./imageMime.ts"; |
| 75 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | 76 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 76 | import { expandHomePath } from "./os-jank.ts"; | 77 | import { expandHomePath } from "./os-jank.ts"; |
| 78 | import { makeServerPushBus } from "./wsServer/pushBus.ts"; | ||
| 79 | import { makeServerReadiness } from "./wsServer/readiness.ts"; | ||
| 80 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | ||
| 77 | 81 | ||
| 78 | /** | 82 | /** |
| 79 | * ServerShape - Service API for server lifecycle control. | 83 | * ServerShape - Service API for server lifecycle control. |
| ... | @@ -195,6 +199,9 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { | ... | @@ -195,6 +199,9 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { |
| 195 | return Struct.omit(body, ["_tag"]); | 199 | return Struct.omit(body, ["_tag"]); |
| 196 | } | 200 | } |
| 197 | 201 | ||
| 202 | const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); | ||
| 203 | const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest); | ||
| 204 | |||
| 198 | export type ServerCoreRuntimeServices = | 205 | export type ServerCoreRuntimeServices = |
| 199 | | OrchestrationEngineService | 206 | | OrchestrationEngineService |
| 200 | | ProjectionSnapshotQuery | 207 | | ProjectionSnapshotQuery |
| ... | @@ -261,38 +268,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -261,38 +268,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 261 | ), | 268 | ), |
| 262 | ); | 269 | ); |
| 263 | 270 | ||
| 271 | const providerStatuses = yield* providerHealth.getStatuses; | ||
| 272 | |||
| 264 | const clients = yield* Ref.make(new Set<WebSocket>()); | 273 | const clients = yield* Ref.make(new Set<WebSocket>()); |
| 265 | const logger = createLogger("ws"); | 274 | const logger = createLogger("ws"); |
| 275 | const readiness = yield* makeServerReadiness; | ||
| 266 | 276 | ||
| 267 | function logOutgoingPush(push: WsPush, recipients: number) { | 277 | function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) { |
| 268 | if (!logWebSocketEvents) return; | 278 | if (!logWebSocketEvents) return; |
| 269 | logger.event("outgoing push", { | 279 | logger.event("outgoing push", { |
| 270 | channel: push.channel, | 280 | channel: push.channel, |
| 281 | sequence: push.sequence, | ||
| 271 | recipients, | 282 | recipients, |
| 272 | payload: push.data, | 283 | payload: push.data, |
| 273 | }); | 284 | }); |
| 274 | } | 285 | } |
| 275 | 286 | ||
| 276 | const encodePush = Schema.encodeEffect(Schema.fromJsonString(WsPush)); | 287 | const pushBus = yield* makeServerPushBus({ |
| 277 | const broadcastPush = Effect.fnUntraced(function* (push: WsPush) { | 288 | clients, |
| 278 | const message = yield* encodePush(push); | 289 | logOutgoingPush, |
| 279 | let recipients = 0; | ||
| 280 | for (const client of yield* Ref.get(clients)) { | ||
| 281 | if (client.readyState === client.OPEN) { | ||
| 282 | client.send(message); | ||
| 283 | recipients += 1; | ||
| 284 | } | ||
| 285 | } | ||
| 286 | logOutgoingPush(push, recipients); | ||
| 287 | }); | ||
| 288 | |||
| 289 | const onTerminalEvent = Effect.fnUntraced(function* (event: TerminalEvent) { | ||
| 290 | yield* broadcastPush({ | ||
| 291 | type: "push", | ||
| 292 | channel: WS_CHANNELS.terminalEvent, | ||
| 293 | data: event, | ||
| 294 | }); | ||
| 295 | }); | 290 | }); |
| 291 | yield* readiness.markPushBusReady; | ||
| 292 | yield* keybindingsManager.start.pipe( | ||
| 293 | Effect.mapError( | ||
| 294 | (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }), | ||
| 295 | ), | ||
| 296 | ); | ||
| 297 | yield* readiness.markKeybindingsReady; | ||
| 296 | 298 | ||
| 297 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { | 299 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { |
| 298 | readonly command: ClientOrchestrationCommand; | 300 | readonly command: ClientOrchestrationCommand; |
| ... | @@ -474,7 +476,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -474,7 +476,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 474 | } | 476 | } |
| 475 | }), | 477 | }), |
| 476 | ).pipe(Effect.exit); | 478 | ).pipe(Effect.exit); |
| 477 | if (streamExit._tag === "Failure") { | 479 | if (Exit.isFailure(streamExit)) { |
| 478 | if (!res.destroyed) { | 480 | if (!res.destroyed) { |
| 479 | res.destroy(); | 481 | res.destroy(); |
| 480 | } | 482 | } |
| ... | @@ -605,43 +607,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -605,43 +607,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 605 | const subscriptionsScope = yield* Scope.make("sequential"); | 607 | const subscriptionsScope = yield* Scope.make("sequential"); |
| 606 | yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); | 608 | yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); |
| 607 | 609 | ||
| 608 | // Push updated provider statuses to connected clients once background health checks finish. | ||
| 609 | let providers: ReadonlyArray<ServerProviderStatus> = []; | ||
| 610 | yield* providerHealth.getStatuses.pipe( | ||
| 611 | Effect.flatMap((statuses) => { | ||
| 612 | providers = statuses; | ||
| 613 | return broadcastPush({ | ||
| 614 | type: "push", | ||
| 615 | channel: WS_CHANNELS.serverConfigUpdated, | ||
| 616 | data: { | ||
| 617 | issues: [], | ||
| 618 | providers: statuses, | ||
| 619 | }, | ||
| 620 | }); | ||
| 621 | }), | ||
| 622 | Effect.forkIn(subscriptionsScope), | ||
| 623 | ); | ||
| 624 | |||
| 625 | yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => | 610 | yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => |
| 626 | broadcastPush({ | 611 | pushBus.publishAll(ORCHESTRATION_WS_CHANNELS.domainEvent, event), |
| 627 | type: "push", | ||
| 628 | channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | ||
| 629 | data: event, | ||
| 630 | }), | ||
| 631 | ).pipe(Effect.forkIn(subscriptionsScope)); | 612 | ).pipe(Effect.forkIn(subscriptionsScope)); |
| 632 | 613 | ||
| 633 | yield* Stream.runForEach(keybindingsManager.changes, (event) => | 614 | yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => |
| 634 | broadcastPush({ | 615 | pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { |
| 635 | type: "push", | 616 | issues: event.issues, |
| 636 | channel: WS_CHANNELS.serverConfigUpdated, | 617 | providers: providerStatuses, |
| 637 | data: { | ||
| 638 | issues: event.issues, | ||
| 639 | providers, | ||
| 640 | }, | ||
| 641 | }), | 618 | }), |
| 642 | ).pipe(Effect.forkIn(subscriptionsScope)); | 619 | ).pipe(Effect.forkIn(subscriptionsScope)); |
| 643 | 620 | ||
| 644 | yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); | 621 | yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); |
| 622 | yield* readiness.markOrchestrationSubscriptionsReady; | ||
| 645 | 623 | ||
| 646 | let welcomeBootstrapProjectId: ProjectId | undefined; | 624 | let welcomeBootstrapProjectId: ProjectId | undefined; |
| 647 | let welcomeBootstrapThreadId: ThreadId | undefined; | 625 | let welcomeBootstrapThreadId: ThreadId | undefined; |
| ... | @@ -712,23 +690,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -712,23 +690,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 712 | const runPromise = Effect.runPromiseWith(runtimeServices); | 690 | const runPromise = Effect.runPromiseWith(runtimeServices); |
| 713 | 691 | ||
| 714 | const unsubscribeTerminalEvents = yield* terminalManager.subscribe( | 692 | const unsubscribeTerminalEvents = yield* terminalManager.subscribe( |
| 715 | (event) => void Effect.runPromise(onTerminalEvent(event)), | 693 | (event) => void Effect.runPromise(pushBus.publishAll(WS_CHANNELS.terminalEvent, event)), |
| 716 | ); | 694 | ); |
| 717 | yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); | 695 | yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); |
| 696 | yield* readiness.markTerminalSubscriptionsReady; | ||
| 718 | 697 | ||
| 719 | yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( | 698 | yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( |
| 720 | Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), | 699 | Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), |
| 721 | ); | 700 | ); |
| 701 | yield* readiness.markHttpListening; | ||
| 722 | 702 | ||
| 723 | yield* Effect.addFinalizer(() => | 703 | yield* Effect.addFinalizer(() => |
| 724 | Effect.all([ | 704 | Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), |
| 725 | closeAllClients, | ||
| 726 | closeWebSocketServer.pipe( | ||
| 727 | Effect.catch((error) => | ||
| 728 | Effect.logWarning("failed to close web socket server", { cause: error }), | ||
| 729 | ), | ||
| 730 | ), | ||
| 731 | ]), | ||
| 732 | ); | 705 | ); |
| 733 | 706 | ||
| 734 | const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { | 707 | const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { |
| ... | @@ -900,7 +873,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -900,7 +873,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 900 | keybindingsConfigPath, | 873 | keybindingsConfigPath, |
| 901 | keybindings: keybindingsConfig.keybindings, | 874 | keybindings: keybindingsConfig.keybindings, |
| 902 | issues: keybindingsConfig.issues, | 875 | issues: keybindingsConfig.issues, |
| 903 | providers, | 876 | providers: providerStatuses, |
| 904 | availableEditors, | 877 | availableEditors, |
| 905 | }; | 878 | }; |
| 906 | 879 | ||
| ... | @@ -920,44 +893,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -920,44 +893,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 920 | }); | 893 | }); |
| 921 | 894 | ||
| 922 | const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { | 895 | const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { |
| 923 | const encodeResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); | 896 | const sendWsResponse = (response: WsResponseMessage) => |
| 897 | encodeWsResponse(response).pipe( | ||
| 898 | Effect.tap((encodedResponse) => Effect.sync(() => ws.send(encodedResponse))), | ||
| 899 | Effect.asVoid, | ||
| 900 | ); | ||
| 924 | 901 | ||
| 925 | const messageText = websocketRawToString(raw); | 902 | const messageText = websocketRawToString(raw); |
| 926 | if (messageText === null) { | 903 | if (messageText === null) { |
| 927 | const errorResponse = yield* encodeResponse({ | 904 | return yield* sendWsResponse({ |
| 928 | id: "unknown", | 905 | id: "unknown", |
| 929 | error: { message: "Invalid request format: Failed to read message" }, | 906 | error: { message: "Invalid request format: Failed to read message" }, |
| 930 | }); | 907 | }); |
| 931 | ws.send(errorResponse); | ||
| 932 | return; | ||
| 933 | } | 908 | } |
| 934 | 909 | ||
| 935 | const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText); | 910 | const request = decodeWebSocketRequest(messageText); |
| 936 | if (request._tag === "Failure") { | 911 | if (Result.isFailure(request)) { |
| 937 | const errorResponse = yield* encodeResponse({ | 912 | return yield* sendWsResponse({ |
| 938 | id: "unknown", | 913 | id: "unknown", |
| 939 | error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` }, | 914 | error: { message: `Invalid request format: ${formatSchemaError(request.failure)}` }, |
| 940 | }); | 915 | }); |
| 941 | ws.send(errorResponse); | ||
| 942 | return; | ||
| 943 | } | 916 | } |
| 944 | 917 | ||
| 945 | const result = yield* Effect.exit(routeRequest(request.value)); | 918 | const result = yield* Effect.exit(routeRequest(request.success)); |
| 946 | if (result._tag === "Failure") { | 919 | if (Exit.isFailure(result)) { |
| 947 | const errorResponse = yield* encodeResponse({ | 920 | return yield* sendWsResponse({ |
| 948 | id: request.value.id, | 921 | id: request.success.id, |
| 949 | error: { message: Cause.pretty(result.cause) }, | 922 | error: { message: Cause.pretty(result.cause) }, |
| 950 | }); | 923 | }); |
| 951 | ws.send(errorResponse); | ||
| 952 | return; | ||
| 953 | } | 924 | } |
| 954 | 925 | ||
| 955 | const response = yield* encodeResponse({ | 926 | return yield* sendWsResponse({ |
| 956 | id: request.value.id, | 927 | id: request.success.id, |
| 957 | result: result.value, | 928 | result: result.value, |
| 958 | }); | 929 | }); |
| 959 | |||
| 960 | ws.send(response); | ||
| 961 | }); | 930 | }); |
| 962 | 931 | ||
| 963 | httpServer.on("upgrade", (request, socket, head) => { | 932 | httpServer.on("upgrade", (request, socket, head) => { |
| ... | @@ -985,30 +954,28 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -985,30 +954,28 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 985 | }); | 954 | }); |
| 986 | 955 | ||
| 987 | wss.on("connection", (ws) => { | 956 | wss.on("connection", (ws) => { |
| 988 | void runPromise(Ref.update(clients, (clients) => clients.add(ws))); | ||
| 989 | |||
| 990 | const segments = cwd.split(/[/\\]/).filter(Boolean); | 957 | const segments = cwd.split(/[/\\]/).filter(Boolean); |
| 991 | const projectName = segments[segments.length - 1] ?? "project"; | 958 | const projectName = segments[segments.length - 1] ?? "project"; |
| 992 | 959 | ||
| 993 | const welcome: WsPush = { | 960 | const welcomeData = { |
| 994 | type: "push", | 961 | cwd, |
| 995 | channel: WS_CHANNELS.serverWelcome, | 962 | projectName, |
| 996 | data: { | 963 | ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}), |
| 997 | cwd, | 964 | ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}), |
| 998 | projectName, | ||
| 999 | ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}), | ||
| 1000 | ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}), | ||
| 1001 | }, | ||
| 1002 | }; | 965 | }; |
| 1003 | logOutgoingPush(welcome, 1); | 966 | // Send welcome before adding to broadcast set so publishAll calls |
| 1004 | ws.send(JSON.stringify(welcome)); | 967 | // cannot reach this client before the welcome arrives. |
| 968 | void runPromise( | ||
| 969 | readiness.awaitServerReady.pipe( | ||
| 970 | Effect.flatMap(() => pushBus.publishClient(ws, WS_CHANNELS.serverWelcome, welcomeData)), | ||
| 971 | Effect.flatMap((delivered) => | ||
| 972 | delivered ? Ref.update(clients, (clients) => clients.add(ws)) : Effect.void, | ||
| 973 | ), | ||
| 974 | ), | ||
| 975 | ); | ||
| 1005 | 976 | ||
| 1006 | ws.on("message", (raw) => { | 977 | ws.on("message", (raw) => { |
| 1007 | void runPromise( | 978 | void runPromise(handleMessage(ws, raw).pipe(Effect.ignoreCause({ log: true }))); |
| 1008 | handleMessage(ws, raw).pipe( | ||
| 1009 | Effect.catch((error) => Effect.logError("Error handling message", error)), | ||
| 1010 | ), | ||
| 1011 | ); | ||
| 1012 | }); | 979 | }); |
| 1013 | 980 | ||
| 1014 | ws.on("close", () => { | 981 | ws.on("close", () => { |
apps/server/src/wsServer/pushBus.test.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import type { WebSocket } from "ws"; | ||
| 2 | import { it } from "@effect/vitest"; | ||
| 3 | import { describe, expect } from "vitest"; | ||
| 4 | import { Effect, Ref } from "effect"; | ||
| 5 | import { WS_CHANNELS } from "@t3tools/contracts"; | ||
| 6 | |||
| 7 | import { makeServerPushBus } from "./pushBus"; | ||
| 8 | |||
| 9 | class MockWebSocket { | ||
| 10 | static readonly OPEN = 1; | ||
| 11 | |||
| 12 | readonly OPEN = MockWebSocket.OPEN; | ||
| 13 | readyState = MockWebSocket.OPEN; | ||
| 14 | readonly sent: string[] = []; | ||
| 15 | private readonly waiters = new Set<() => void>(); | ||
| 16 | |||
| 17 | send(message: string) { | ||
| 18 | this.sent.push(message); | ||
| 19 | for (const waiter of this.waiters) { | ||
| 20 | waiter(); | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | waitForSentCount(count: number): Promise<void> { | ||
| 25 | if (this.sent.length >= count) { | ||
| 26 | return Promise.resolve(); | ||
| 27 | } | ||
| 28 | |||
| 29 | return new Promise((resolve) => { | ||
| 30 | const check = () => { | ||
| 31 | if (this.sent.length < count) { | ||
| 32 | return; | ||
| 33 | } | ||
| 34 | this.waiters.delete(check); | ||
| 35 | resolve(); | ||
| 36 | }; | ||
| 37 | |||
| 38 | this.waiters.add(check); | ||
| 39 | }); | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | describe("makeServerPushBus", () => { | ||
| 44 | it.live("waits for the welcome push before a new client joins broadcast delivery", () => | ||
| 45 | Effect.scoped( | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const client = new MockWebSocket(); | ||
| 48 | const clients = yield* Ref.make(new Set<WebSocket>()); | ||
| 49 | const pushBus = yield* makeServerPushBus({ | ||
| 50 | clients, | ||
| 51 | logOutgoingPush: () => {}, | ||
| 52 | }); | ||
| 53 | |||
| 54 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | ||
| 55 | issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }], | ||
| 56 | providers: [], | ||
| 57 | }); | ||
| 58 | |||
| 59 | const delivered = yield* pushBus.publishClient( | ||
| 60 | client as unknown as WebSocket, | ||
| 61 | WS_CHANNELS.serverWelcome, | ||
| 62 | { | ||
| 63 | cwd: "/tmp/project", | ||
| 64 | projectName: "project", | ||
| 65 | }, | ||
| 66 | ); | ||
| 67 | expect(delivered).toBe(true); | ||
| 68 | |||
| 69 | yield* Ref.update(clients, (current) => current.add(client as unknown as WebSocket)); | ||
| 70 | |||
| 71 | yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { | ||
| 72 | issues: [], | ||
| 73 | providers: [], | ||
| 74 | }); | ||
| 75 | |||
| 76 | yield* Effect.promise(() => client.waitForSentCount(2)); | ||
| 77 | |||
| 78 | const messages = client.sent.map( | ||
| 79 | (message) => JSON.parse(message) as { channel: string; data: unknown }, | ||
| 80 | ); | ||
| 81 | |||
| 82 | expect(messages).toHaveLength(2); | ||
| 83 | expect(messages[0]).toEqual({ | ||
| 84 | type: "push", | ||
| 85 | sequence: 2, | ||
| 86 | channel: WS_CHANNELS.serverWelcome, | ||
| 87 | data: { | ||
| 88 | cwd: "/tmp/project", | ||
| 89 | projectName: "project", | ||
| 90 | }, | ||
| 91 | }); | ||
| 92 | expect(messages[1]).toEqual({ | ||
| 93 | type: "push", | ||
| 94 | sequence: 3, | ||
| 95 | channel: WS_CHANNELS.serverConfigUpdated, | ||
| 96 | data: { | ||
| 97 | issues: [], | ||
| 98 | providers: [], | ||
| 99 | }, | ||
| 100 | }); | ||
| 101 | }), | ||
| 102 | ), | ||
| 103 | ); | ||
| 104 | }); | ||
apps/server/src/wsServer/pushBus.ts created+114| ... | @@ -0,0 +1,114 @@ | ||
| 1 | import { | ||
| 2 | WsPush, | ||
| 3 | type WsPushChannel, | ||
| 4 | type WsPushData, | ||
| 5 | type WsPushEnvelopeBase, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { Deferred, Effect, Queue, Ref, Schema } from "effect"; | ||
| 8 | import type { Scope } from "effect"; | ||
| 9 | import type { WebSocket } from "ws"; | ||
| 10 | |||
| 11 | type PushTarget = | ||
| 12 | | { readonly kind: "all" } | ||
| 13 | | { readonly kind: "client"; readonly client: WebSocket }; | ||
| 14 | |||
| 15 | interface PushJob<C extends WsPushChannel = WsPushChannel> { | ||
| 16 | readonly channel: C; | ||
| 17 | readonly data: WsPushData<C>; | ||
| 18 | readonly target: PushTarget; | ||
| 19 | readonly delivered: Deferred.Deferred<boolean> | null; | ||
| 20 | } | ||
| 21 | |||
| 22 | export interface ServerPushBus { | ||
| 23 | readonly publishAll: <C extends WsPushChannel>( | ||
| 24 | channel: C, | ||
| 25 | data: WsPushData<C>, | ||
| 26 | ) => Effect.Effect<void>; | ||
| 27 | readonly publishClient: <C extends WsPushChannel>( | ||
| 28 | client: WebSocket, | ||
| 29 | channel: C, | ||
| 30 | data: WsPushData<C>, | ||
| 31 | ) => Effect.Effect<boolean>; | ||
| 32 | } | ||
| 33 | |||
| 34 | export const makeServerPushBus = (input: { | ||
| 35 | readonly clients: Ref.Ref<Set<WebSocket>>; | ||
| 36 | readonly logOutgoingPush: (push: WsPushEnvelopeBase, recipients: number) => void; | ||
| 37 | }): Effect.Effect<ServerPushBus, never, Scope.Scope> => | ||
| 38 | Effect.gen(function* () { | ||
| 39 | const nextSequence = yield* Ref.make(0); | ||
| 40 | const queue = yield* Queue.unbounded<PushJob>(); | ||
| 41 | const encodePush = Schema.encodeUnknownEffect(Schema.fromJsonString(WsPush)); | ||
| 42 | |||
| 43 | const settleDelivery = (job: PushJob, delivered: boolean) => | ||
| 44 | job.delivered === null | ||
| 45 | ? Effect.void | ||
| 46 | : Deferred.succeed(job.delivered, delivered).pipe(Effect.orDie); | ||
| 47 | |||
| 48 | const send = Effect.fnUntraced(function* (job: PushJob) { | ||
| 49 | const sequence = yield* Ref.updateAndGet(nextSequence, (current) => current + 1); | ||
| 50 | const push: WsPushEnvelopeBase = { | ||
| 51 | type: "push", | ||
| 52 | sequence, | ||
| 53 | channel: job.channel, | ||
| 54 | data: job.data, | ||
| 55 | }; | ||
| 56 | const recipients = | ||
| 57 | job.target.kind === "all" ? yield* Ref.get(input.clients) : new Set([job.target.client]); | ||
| 58 | |||
| 59 | return yield* encodePush(push).pipe( | ||
| 60 | Effect.map((message) => { | ||
| 61 | let recipientCount = 0; | ||
| 62 | for (const client of recipients) { | ||
| 63 | if (client.readyState !== client.OPEN) { | ||
| 64 | continue; | ||
| 65 | } | ||
| 66 | client.send(message); | ||
| 67 | recipientCount += 1; | ||
| 68 | } | ||
| 69 | |||
| 70 | input.logOutgoingPush(push, recipientCount); | ||
| 71 | return recipientCount > 0; | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | }); | ||
| 75 | |||
| 76 | yield* Effect.forkScoped( | ||
| 77 | Effect.forever( | ||
| 78 | Queue.take(queue).pipe( | ||
| 79 | Effect.flatMap((job) => | ||
| 80 | send(job).pipe( | ||
| 81 | Effect.tap((delivered) => settleDelivery(job, delivered)), | ||
| 82 | Effect.tapCause(() => settleDelivery(job, false)), | ||
| 83 | Effect.ignoreCause({ log: true }), | ||
| 84 | ), | ||
| 85 | ), | ||
| 86 | ), | ||
| 87 | ), | ||
| 88 | ); | ||
| 89 | |||
| 90 | const publish = | ||
| 91 | (target: PushTarget) => | ||
| 92 | <C extends WsPushChannel>(channel: C, data: WsPushData<C>) => | ||
| 93 | Queue.offer(queue, { | ||
| 94 | channel, | ||
| 95 | data, | ||
| 96 | target, | ||
| 97 | delivered: null, | ||
| 98 | }).pipe(Effect.asVoid); | ||
| 99 | |||
| 100 | return { | ||
| 101 | publishAll: publish({ kind: "all" }), | ||
| 102 | publishClient: (client, channel, data) => | ||
| 103 | Effect.gen(function* () { | ||
| 104 | const delivered = yield* Deferred.make<boolean>(); | ||
| 105 | yield* Queue.offer(queue, { | ||
| 106 | channel, | ||
| 107 | data, | ||
| 108 | target: { kind: "client", client }, | ||
| 109 | delivered, | ||
| 110 | }).pipe(Effect.asVoid); | ||
| 111 | return yield* Deferred.await(delivered); | ||
| 112 | }), | ||
| 113 | } satisfies ServerPushBus; | ||
| 114 | }); | ||
apps/server/src/wsServer/readiness.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { Deferred, Effect } from "effect"; | ||
| 2 | |||
| 3 | export interface ServerReadiness { | ||
| 4 | readonly awaitServerReady: Effect.Effect<void>; | ||
| 5 | readonly markHttpListening: Effect.Effect<void>; | ||
| 6 | readonly markPushBusReady: Effect.Effect<void>; | ||
| 7 | readonly markKeybindingsReady: Effect.Effect<void>; | ||
| 8 | readonly markTerminalSubscriptionsReady: Effect.Effect<void>; | ||
| 9 | readonly markOrchestrationSubscriptionsReady: Effect.Effect<void>; | ||
| 10 | } | ||
| 11 | |||
| 12 | export const makeServerReadiness = Effect.gen(function* () { | ||
| 13 | const httpListening = yield* Deferred.make<void>(); | ||
| 14 | const pushBusReady = yield* Deferred.make<void>(); | ||
| 15 | const keybindingsReady = yield* Deferred.make<void>(); | ||
| 16 | const terminalSubscriptionsReady = yield* Deferred.make<void>(); | ||
| 17 | const orchestrationSubscriptionsReady = yield* Deferred.make<void>(); | ||
| 18 | |||
| 19 | const complete = (deferred: Deferred.Deferred<void>) => | ||
| 20 | Deferred.succeed(deferred, undefined).pipe(Effect.orDie); | ||
| 21 | |||
| 22 | return { | ||
| 23 | awaitServerReady: Effect.all([ | ||
| 24 | Deferred.await(httpListening), | ||
| 25 | Deferred.await(pushBusReady), | ||
| 26 | Deferred.await(keybindingsReady), | ||
| 27 | Deferred.await(terminalSubscriptionsReady), | ||
| 28 | Deferred.await(orchestrationSubscriptionsReady), | ||
| 29 | ]).pipe(Effect.asVoid), | ||
| 30 | markHttpListening: complete(httpListening), | ||
| 31 | markPushBusReady: complete(pushBusReady), | ||
| 32 | markKeybindingsReady: complete(keybindingsReady), | ||
| 33 | markTerminalSubscriptionsReady: complete(terminalSubscriptionsReady), | ||
| 34 | markOrchestrationSubscriptionsReady: complete(orchestrationSubscriptionsReady), | ||
| 35 | } satisfies ServerReadiness; | ||
| 36 | }); | ||
apps/server/vitest.config.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import { defineConfig, mergeConfig } from "vitest/config"; | ||
| 2 | |||
| 3 | import baseConfig from "../../vitest.config"; | ||
| 4 | |||
| 5 | export default mergeConfig( | ||
| 6 | baseConfig, | ||
| 7 | defineConfig({ | ||
| 8 | test: { | ||
| 9 | testTimeout: 15_000, | ||
| 10 | hookTimeout: 15_000, | ||
| 11 | }, | ||
| 12 | }), | ||
| 13 | ); | ||
apps/web/src/components/ChatView.browser.tsx+114| ... | @@ -25,6 +25,7 @@ import { useStore } from "../store"; | ... | @@ -25,6 +25,7 @@ import { useStore } from "../store"; |
| 25 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | 25 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 26 | 26 | ||
| 27 | const THREAD_ID = "thread-browser-test" as ThreadId; | 27 | const THREAD_ID = "thread-browser-test" as ThreadId; |
| 28 | const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; | ||
| 28 | const PROJECT_ID = "project-1" as ProjectId; | 29 | const PROJECT_ID = "project-1" as ProjectId; |
| 29 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; | 30 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 30 | const BASE_TIME_MS = Date.parse(NOW_ISO); | 31 | const BASE_TIME_MS = Date.parse(NOW_ISO); |
| ... | @@ -85,6 +86,7 @@ interface MountedChatView { | ... | @@ -85,6 +86,7 @@ interface MountedChatView { |
| 85 | cleanup: () => Promise<void>; | 86 | cleanup: () => Promise<void>; |
| 86 | measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>; | 87 | measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>; |
| 87 | setViewport: (viewport: ViewportSpec) => Promise<void>; | 88 | setViewport: (viewport: ViewportSpec) => Promise<void>; |
| 89 | router: ReturnType<typeof getRouter>; | ||
| 88 | } | 90 | } |
| 89 | 91 | ||
| 90 | function isoAt(offsetSeconds: number): string { | 92 | function isoAt(offsetSeconds: number): string { |
| ... | @@ -245,6 +247,46 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { | ... | @@ -245,6 +247,46 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { |
| 245 | }; | 247 | }; |
| 246 | } | 248 | } |
| 247 | 249 | ||
| 250 | function addThreadToSnapshot( | ||
| 251 | snapshot: OrchestrationReadModel, | ||
| 252 | threadId: ThreadId, | ||
| 253 | ): OrchestrationReadModel { | ||
| 254 | return { | ||
| 255 | ...snapshot, | ||
| 256 | snapshotSequence: snapshot.snapshotSequence + 1, | ||
| 257 | threads: [ | ||
| 258 | ...snapshot.threads, | ||
| 259 | { | ||
| 260 | id: threadId, | ||
| 261 | projectId: PROJECT_ID, | ||
| 262 | title: "New thread", | ||
| 263 | model: "gpt-5", | ||
| 264 | interactionMode: "default", | ||
| 265 | runtimeMode: "full-access", | ||
| 266 | branch: "main", | ||
| 267 | worktreePath: null, | ||
| 268 | latestTurn: null, | ||
| 269 | createdAt: NOW_ISO, | ||
| 270 | updatedAt: NOW_ISO, | ||
| 271 | deletedAt: null, | ||
| 272 | messages: [], | ||
| 273 | activities: [], | ||
| 274 | proposedPlans: [], | ||
| 275 | checkpoints: [], | ||
| 276 | session: { | ||
| 277 | threadId, | ||
| 278 | status: "ready", | ||
| 279 | providerName: "codex", | ||
| 280 | runtimeMode: "full-access", | ||
| 281 | activeTurnId: null, | ||
| 282 | lastError: null, | ||
| 283 | updatedAt: NOW_ISO, | ||
| 284 | }, | ||
| 285 | }, | ||
| 286 | ], | ||
| 287 | }; | ||
| 288 | } | ||
| 289 | |||
| 248 | function createDraftOnlySnapshot(): OrchestrationReadModel { | 290 | function createDraftOnlySnapshot(): OrchestrationReadModel { |
| 249 | const snapshot = createSnapshotForTargetUser({ | 291 | const snapshot = createSnapshotForTargetUser({ |
| 250 | targetMessageId: "msg-user-draft-target" as MessageId, | 292 | targetMessageId: "msg-user-draft-target" as MessageId, |
| ... | @@ -361,6 +403,7 @@ const worker = setupWorker( | ... | @@ -361,6 +403,7 @@ const worker = setupWorker( |
| 361 | client.send( | 403 | client.send( |
| 362 | JSON.stringify({ | 404 | JSON.stringify({ |
| 363 | type: "push", | 405 | type: "push", |
| 406 | sequence: 1, | ||
| 364 | channel: WS_CHANNELS.serverWelcome, | 407 | channel: WS_CHANNELS.serverWelcome, |
| 365 | data: fixture.welcome, | 408 | data: fixture.welcome, |
| 366 | }), | 409 | }), |
| ... | @@ -448,6 +491,22 @@ async function waitForElement<T extends Element>( | ... | @@ -448,6 +491,22 @@ async function waitForElement<T extends Element>( |
| 448 | return element; | 491 | return element; |
| 449 | } | 492 | } |
| 450 | 493 | ||
| 494 | async function waitForURL( | ||
| 495 | router: ReturnType<typeof getRouter>, | ||
| 496 | predicate: (pathname: string) => boolean, | ||
| 497 | errorMessage: string, | ||
| 498 | ): Promise<string> { | ||
| 499 | let pathname = ""; | ||
| 500 | await vi.waitFor( | ||
| 501 | () => { | ||
| 502 | pathname = router.state.location.pathname; | ||
| 503 | expect(predicate(pathname), errorMessage).toBe(true); | ||
| 504 | }, | ||
| 505 | { timeout: 8_000, interval: 16 }, | ||
| 506 | ); | ||
| 507 | return pathname; | ||
| 508 | } | ||
| 509 | |||
| 451 | async function waitForComposerEditor(): Promise<HTMLElement> { | 510 | async function waitForComposerEditor(): Promise<HTMLElement> { |
| 452 | return waitForElement( | 511 | return waitForElement( |
| 453 | () => document.querySelector<HTMLElement>('[contenteditable="true"]'), | 512 | () => document.querySelector<HTMLElement>('[contenteditable="true"]'), |
| ... | @@ -593,6 +652,7 @@ async function mountChatView(options: { | ... | @@ -593,6 +652,7 @@ async function mountChatView(options: { |
| 593 | await setViewport(viewport); | 652 | await setViewport(viewport); |
| 594 | await waitForProductionStyles(); | 653 | await waitForProductionStyles(); |
| 595 | }, | 654 | }, |
| 655 | router, | ||
| 596 | }; | 656 | }; |
| 597 | } | 657 | } |
| 598 | 658 | ||
| ... | @@ -934,6 +994,60 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -934,6 +994,60 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 934 | } | 994 | } |
| 935 | }); | 995 | }); |
| 936 | 996 | ||
| 997 | it("keeps the new thread selected after clicking the new-thread button", async () => { | ||
| 998 | const mounted = await mountChatView({ | ||
| 999 | viewport: DEFAULT_VIEWPORT, | ||
| 1000 | snapshot: createSnapshotForTargetUser({ | ||
| 1001 | targetMessageId: "msg-user-new-thread-test" as MessageId, | ||
| 1002 | targetText: "new thread selection test", | ||
| 1003 | }), | ||
| 1004 | }); | ||
| 1005 | |||
| 1006 | try { | ||
| 1007 | // Wait for the sidebar to render with the project. | ||
| 1008 | const newThreadButton = page.getByTestId("new-thread-button"); | ||
| 1009 | await expect.element(newThreadButton).toBeInTheDocument(); | ||
| 1010 | |||
| 1011 | await newThreadButton.click(); | ||
| 1012 | |||
| 1013 | // The route should change to a new draft thread ID. | ||
| 1014 | const newThreadPath = await waitForURL( | ||
| 1015 | mounted.router, | ||
| 1016 | (path) => UUID_ROUTE_RE.test(path), | ||
| 1017 | "Route should have changed to a new draft thread UUID.", | ||
| 1018 | ); | ||
| 1019 | const newThreadId = newThreadPath.slice(1) as ThreadId; | ||
| 1020 | |||
| 1021 | // The composer editor should be present for the new draft thread. | ||
| 1022 | await waitForComposerEditor(); | ||
| 1023 | |||
| 1024 | // Simulate the snapshot sync arriving from the server after the draft | ||
| 1025 | // thread has been promoted to a server thread (thread.create + turn.start | ||
| 1026 | // succeeded). The snapshot now includes the new thread, and the sync | ||
| 1027 | // should clear the draft without disrupting the route. | ||
| 1028 | const { syncServerReadModel } = useStore.getState(); | ||
| 1029 | syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId)); | ||
| 1030 | |||
| 1031 | // Clear the draft now that the server thread exists (mirrors EventRouter behavior). | ||
| 1032 | useComposerDraftStore.getState().clearDraftThread(newThreadId); | ||
| 1033 | |||
| 1034 | // The route should still be on the new thread — not redirected away. | ||
| 1035 | await waitForURL( | ||
| 1036 | mounted.router, | ||
| 1037 | (path) => path === newThreadPath, | ||
| 1038 | "New thread should remain selected after snapshot sync clears the draft.", | ||
| 1039 | ); | ||
| 1040 | |||
| 1041 | // The empty thread view and composer should still be visible. | ||
| 1042 | await expect | ||
| 1043 | .element(page.getByText("Send a message to start the conversation.")) | ||
| 1044 | .toBeInTheDocument(); | ||
| 1045 | await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); | ||
| 1046 | } finally { | ||
| 1047 | await mounted.cleanup(); | ||
| 1048 | } | ||
| 1049 | }); | ||
| 1050 | |||
| 937 | it("keeps long proposed plans lightweight until the user expands them", async () => { | 1051 | it("keeps long proposed plans lightweight until the user expands them", async () => { |
| 938 | const mounted = await mountChatView({ | 1052 | const mounted = await mountChatView({ |
| 939 | viewport: DEFAULT_VIEWPORT, | 1053 | viewport: DEFAULT_VIEWPORT, |
apps/web/src/components/ChatView.tsx-4| ... | @@ -613,7 +613,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -613,7 +613,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 613 | (store) => store.syncPersistedAttachments, | 613 | (store) => store.syncPersistedAttachments, |
| 614 | ); | 614 | ); |
| 615 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); | 615 | const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); |
| 616 | const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); | ||
| 617 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 616 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 618 | const getDraftThreadByProjectId = useComposerDraftStore( | 617 | const getDraftThreadByProjectId = useComposerDraftStore( |
| 619 | (store) => store.getDraftThreadByProjectId, | 618 | (store) => store.getDraftThreadByProjectId, |
| ... | @@ -2788,9 +2787,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2788,9 +2787,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2788 | createdAt: messageCreatedAt, | 2787 | createdAt: messageCreatedAt, |
| 2789 | }); | 2788 | }); |
| 2790 | turnStartSucceeded = true; | 2789 | turnStartSucceeded = true; |
| 2791 | if (isFirstMessage) { | ||
| 2792 | clearDraftThread(threadIdForSend); | ||
| 2793 | } | ||
| 2794 | })().catch(async (err: unknown) => { | 2790 | })().catch(async (err: unknown) => { |
| 2795 | if (createdServerThreadForLocalDraft && !turnStartSucceeded) { | 2791 | if (createdServerThreadForLocalDraft && !turnStartSucceeded) { |
| 2796 | await api.orchestration | 2792 | await api.orchestration |
apps/web/src/components/ComposerPromptEditor.tsx+1| ... | @@ -748,6 +748,7 @@ function ComposerPromptEditorInner({ | ... | @@ -748,6 +748,7 @@ function ComposerPromptEditorInner({ |
| 748 | "block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none", | 748 | "block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none", |
| 749 | className, | 749 | className, |
| 750 | )} | 750 | )} |
| 751 | data-testid="composer-editor" | ||
| 751 | aria-placeholder={placeholder} | 752 | aria-placeholder={placeholder} |
| 752 | placeholder={<span />} | 753 | placeholder={<span />} |
| 753 | onPaste={onPaste} | 754 | onPaste={onPaste} |
apps/web/src/components/KeybindingsToast.browser.tsx created+371| ... | @@ -0,0 +1,371 @@ | ||
| 1 | import "../index.css"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | ORCHESTRATION_WS_METHODS, | ||
| 5 | type MessageId, | ||
| 6 | type OrchestrationReadModel, | ||
| 7 | type ProjectId, | ||
| 8 | type ServerConfig, | ||
| 9 | type ThreadId, | ||
| 10 | type WsWelcomePayload, | ||
| 11 | WS_CHANNELS, | ||
| 12 | WS_METHODS, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; | ||
| 15 | import { ws, http, HttpResponse } from "msw"; | ||
| 16 | import { setupWorker } from "msw/browser"; | ||
| 17 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| 18 | import { render } from "vitest-browser-react"; | ||
| 19 | |||
| 20 | import { useComposerDraftStore } from "../composerDraftStore"; | ||
| 21 | import { getRouter } from "../router"; | ||
| 22 | import { useStore } from "../store"; | ||
| 23 | |||
| 24 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; | ||
| 25 | const PROJECT_ID = "project-1" as ProjectId; | ||
| 26 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; | ||
| 27 | |||
| 28 | interface TestFixture { | ||
| 29 | snapshot: OrchestrationReadModel; | ||
| 30 | serverConfig: ServerConfig; | ||
| 31 | welcome: WsWelcomePayload; | ||
| 32 | } | ||
| 33 | |||
| 34 | let fixture: TestFixture; | ||
| 35 | let wsClient: { send: (data: string) => void } | null = null; | ||
| 36 | let pushSequence = 1; | ||
| 37 | |||
| 38 | const wsLink = ws.link(/ws(s)?:\/\/.*/); | ||
| 39 | |||
| 40 | function createBaseServerConfig(): ServerConfig { | ||
| 41 | return { | ||
| 42 | cwd: "/repo/project", | ||
| 43 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", | ||
| 44 | keybindings: [], | ||
| 45 | issues: [], | ||
| 46 | providers: [ | ||
| 47 | { | ||
| 48 | provider: "codex", | ||
| 49 | status: "ready", | ||
| 50 | available: true, | ||
| 51 | authStatus: "authenticated", | ||
| 52 | checkedAt: NOW_ISO, | ||
| 53 | }, | ||
| 54 | ], | ||
| 55 | availableEditors: [], | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | |||
| 59 | function createMinimalSnapshot(): OrchestrationReadModel { | ||
| 60 | return { | ||
| 61 | snapshotSequence: 1, | ||
| 62 | projects: [ | ||
| 63 | { | ||
| 64 | id: PROJECT_ID, | ||
| 65 | title: "Project", | ||
| 66 | workspaceRoot: "/repo/project", | ||
| 67 | defaultModel: "gpt-5", | ||
| 68 | scripts: [], | ||
| 69 | createdAt: NOW_ISO, | ||
| 70 | updatedAt: NOW_ISO, | ||
| 71 | deletedAt: null, | ||
| 72 | }, | ||
| 73 | ], | ||
| 74 | threads: [ | ||
| 75 | { | ||
| 76 | id: THREAD_ID, | ||
| 77 | projectId: PROJECT_ID, | ||
| 78 | title: "Test thread", | ||
| 79 | model: "gpt-5", | ||
| 80 | interactionMode: "default", | ||
| 81 | runtimeMode: "full-access", | ||
| 82 | branch: "main", | ||
| 83 | worktreePath: null, | ||
| 84 | latestTurn: null, | ||
| 85 | createdAt: NOW_ISO, | ||
| 86 | updatedAt: NOW_ISO, | ||
| 87 | deletedAt: null, | ||
| 88 | messages: [ | ||
| 89 | { | ||
| 90 | id: "msg-1" as MessageId, | ||
| 91 | role: "user", | ||
| 92 | text: "hello", | ||
| 93 | turnId: null, | ||
| 94 | streaming: false, | ||
| 95 | createdAt: NOW_ISO, | ||
| 96 | updatedAt: NOW_ISO, | ||
| 97 | }, | ||
| 98 | ], | ||
| 99 | activities: [], | ||
| 100 | proposedPlans: [], | ||
| 101 | checkpoints: [], | ||
| 102 | session: { | ||
| 103 | threadId: THREAD_ID, | ||
| 104 | status: "ready", | ||
| 105 | providerName: "codex", | ||
| 106 | runtimeMode: "full-access", | ||
| 107 | activeTurnId: null, | ||
| 108 | lastError: null, | ||
| 109 | updatedAt: NOW_ISO, | ||
| 110 | }, | ||
| 111 | }, | ||
| 112 | ], | ||
| 113 | updatedAt: NOW_ISO, | ||
| 114 | }; | ||
| 115 | } | ||
| 116 | |||
| 117 | function buildFixture(): TestFixture { | ||
| 118 | return { | ||
| 119 | snapshot: createMinimalSnapshot(), | ||
| 120 | serverConfig: createBaseServerConfig(), | ||
| 121 | welcome: { | ||
| 122 | cwd: "/repo/project", | ||
| 123 | projectName: "Project", | ||
| 124 | bootstrapProjectId: PROJECT_ID, | ||
| 125 | bootstrapThreadId: THREAD_ID, | ||
| 126 | }, | ||
| 127 | }; | ||
| 128 | } | ||
| 129 | |||
| 130 | function resolveWsRpc(tag: string): unknown { | ||
| 131 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { | ||
| 132 | return fixture.snapshot; | ||
| 133 | } | ||
| 134 | if (tag === WS_METHODS.serverGetConfig) { | ||
| 135 | return fixture.serverConfig; | ||
| 136 | } | ||
| 137 | if (tag === WS_METHODS.gitListBranches) { | ||
| 138 | return { | ||
| 139 | isRepo: true, | ||
| 140 | hasOriginRemote: true, | ||
| 141 | branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], | ||
| 142 | }; | ||
| 143 | } | ||
| 144 | if (tag === WS_METHODS.gitStatus) { | ||
| 145 | return { | ||
| 146 | branch: "main", | ||
| 147 | hasWorkingTreeChanges: false, | ||
| 148 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 149 | hasUpstream: true, | ||
| 150 | aheadCount: 0, | ||
| 151 | behindCount: 0, | ||
| 152 | pr: null, | ||
| 153 | }; | ||
| 154 | } | ||
| 155 | if (tag === WS_METHODS.projectsSearchEntries) { | ||
| 156 | return { entries: [], truncated: false }; | ||
| 157 | } | ||
| 158 | return {}; | ||
| 159 | } | ||
| 160 | |||
| 161 | const worker = setupWorker( | ||
| 162 | wsLink.addEventListener("connection", ({ client }) => { | ||
| 163 | wsClient = client; | ||
| 164 | pushSequence = 1; | ||
| 165 | client.send( | ||
| 166 | JSON.stringify({ | ||
| 167 | type: "push", | ||
| 168 | sequence: pushSequence++, | ||
| 169 | channel: WS_CHANNELS.serverWelcome, | ||
| 170 | data: fixture.welcome, | ||
| 171 | }), | ||
| 172 | ); | ||
| 173 | client.addEventListener("message", (event) => { | ||
| 174 | const rawData = event.data; | ||
| 175 | if (typeof rawData !== "string") return; | ||
| 176 | let request: { id: string; body: { _tag: string; [key: string]: unknown } }; | ||
| 177 | try { | ||
| 178 | request = JSON.parse(rawData); | ||
| 179 | } catch { | ||
| 180 | return; | ||
| 181 | } | ||
| 182 | const method = request.body?._tag; | ||
| 183 | if (typeof method !== "string") return; | ||
| 184 | client.send( | ||
| 185 | JSON.stringify({ | ||
| 186 | id: request.id, | ||
| 187 | result: resolveWsRpc(method), | ||
| 188 | }), | ||
| 189 | ); | ||
| 190 | }); | ||
| 191 | }), | ||
| 192 | http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), | ||
| 193 | http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), | ||
| 194 | ); | ||
| 195 | |||
| 196 | function sendServerConfigUpdatedPush(issues: Array<{ kind: string; message: string }>) { | ||
| 197 | if (!wsClient) throw new Error("WebSocket client not connected"); | ||
| 198 | wsClient.send( | ||
| 199 | JSON.stringify({ | ||
| 200 | type: "push", | ||
| 201 | sequence: pushSequence++, | ||
| 202 | channel: WS_CHANNELS.serverConfigUpdated, | ||
| 203 | data: { | ||
| 204 | issues, | ||
| 205 | providers: fixture.serverConfig.providers, | ||
| 206 | }, | ||
| 207 | }), | ||
| 208 | ); | ||
| 209 | } | ||
| 210 | |||
| 211 | function queryToastTitles(): string[] { | ||
| 212 | return Array.from(document.querySelectorAll('[data-slot="toast-title"]')).map( | ||
| 213 | (el) => el.textContent ?? "", | ||
| 214 | ); | ||
| 215 | } | ||
| 216 | |||
| 217 | async function waitForElement<T extends Element>( | ||
| 218 | query: () => T | null, | ||
| 219 | errorMessage: string, | ||
| 220 | ): Promise<T> { | ||
| 221 | let element: T | null = null; | ||
| 222 | await vi.waitFor( | ||
| 223 | () => { | ||
| 224 | element = query(); | ||
| 225 | expect(element, errorMessage).toBeTruthy(); | ||
| 226 | }, | ||
| 227 | { timeout: 8_000, interval: 16 }, | ||
| 228 | ); | ||
| 229 | return element!; | ||
| 230 | } | ||
| 231 | |||
| 232 | async function waitForComposerEditor(): Promise<HTMLElement> { | ||
| 233 | return waitForElement( | ||
| 234 | () => document.querySelector<HTMLElement>('[data-testid="composer-editor"]'), | ||
| 235 | "App should render composer editor", | ||
| 236 | ); | ||
| 237 | } | ||
| 238 | |||
| 239 | async function waitForToast(title: string, count = 1): Promise<void> { | ||
| 240 | await vi.waitFor( | ||
| 241 | () => { | ||
| 242 | const matches = queryToastTitles().filter((t) => t === title); | ||
| 243 | expect(matches.length, `Expected ${count} "${title}" toast(s)`).toBeGreaterThanOrEqual(count); | ||
| 244 | }, | ||
| 245 | { timeout: 4_000, interval: 16 }, | ||
| 246 | ); | ||
| 247 | } | ||
| 248 | |||
| 249 | async function waitForNoToast(title: string): Promise<void> { | ||
| 250 | await vi.waitFor( | ||
| 251 | () => { | ||
| 252 | expect(queryToastTitles().filter((t) => t === title)).toHaveLength(0); | ||
| 253 | }, | ||
| 254 | { timeout: 10_000, interval: 50 }, | ||
| 255 | ); | ||
| 256 | } | ||
| 257 | |||
| 258 | async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { | ||
| 259 | const host = document.createElement("div"); | ||
| 260 | host.style.position = "fixed"; | ||
| 261 | host.style.inset = "0"; | ||
| 262 | host.style.width = "100vw"; | ||
| 263 | host.style.height = "100vh"; | ||
| 264 | host.style.display = "grid"; | ||
| 265 | host.style.overflow = "hidden"; | ||
| 266 | document.body.append(host); | ||
| 267 | |||
| 268 | const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); | ||
| 269 | |||
| 270 | const screen = await render(<RouterProvider router={router} />, { container: host }); | ||
| 271 | await waitForComposerEditor(); | ||
| 272 | |||
| 273 | return { | ||
| 274 | cleanup: async () => { | ||
| 275 | await screen.unmount(); | ||
| 276 | host.remove(); | ||
| 277 | }, | ||
| 278 | }; | ||
| 279 | } | ||
| 280 | |||
| 281 | describe("Keybindings update toast", () => { | ||
| 282 | beforeAll(async () => { | ||
| 283 | fixture = buildFixture(); | ||
| 284 | await worker.start({ | ||
| 285 | onUnhandledRequest: "bypass", | ||
| 286 | quiet: true, | ||
| 287 | serviceWorker: { url: "/mockServiceWorker.js" }, | ||
| 288 | }); | ||
| 289 | }); | ||
| 290 | |||
| 291 | afterAll(async () => { | ||
| 292 | await worker.stop(); | ||
| 293 | }); | ||
| 294 | |||
| 295 | beforeEach(() => { | ||
| 296 | localStorage.clear(); | ||
| 297 | document.body.innerHTML = ""; | ||
| 298 | pushSequence = 1; | ||
| 299 | useComposerDraftStore.setState({ | ||
| 300 | draftsByThreadId: {}, | ||
| 301 | draftThreadsByThreadId: {}, | ||
| 302 | projectDraftThreadIdByProjectId: {}, | ||
| 303 | }); | ||
| 304 | useStore.setState({ | ||
| 305 | projects: [], | ||
| 306 | threads: [], | ||
| 307 | threadsHydrated: false, | ||
| 308 | }); | ||
| 309 | }); | ||
| 310 | |||
| 311 | afterEach(() => { | ||
| 312 | document.body.innerHTML = ""; | ||
| 313 | }); | ||
| 314 | |||
| 315 | it("shows a toast for each consecutive keybinding update with no issues", async () => { | ||
| 316 | const mounted = await mountApp(); | ||
| 317 | |||
| 318 | try { | ||
| 319 | sendServerConfigUpdatedPush([]); | ||
| 320 | await waitForToast("Keybindings updated", 1); | ||
| 321 | |||
| 322 | // Each server push represents a distinct file change, so it should produce its own toast. | ||
| 323 | sendServerConfigUpdatedPush([]); | ||
| 324 | await waitForToast("Keybindings updated", 2); | ||
| 325 | } finally { | ||
| 326 | await mounted.cleanup(); | ||
| 327 | } | ||
| 328 | }); | ||
| 329 | |||
| 330 | it("shows a warning toast when keybinding config has issues", async () => { | ||
| 331 | const mounted = await mountApp(); | ||
| 332 | |||
| 333 | try { | ||
| 334 | sendServerConfigUpdatedPush([ | ||
| 335 | { kind: "keybindings.malformed-config", message: "Expected JSON array" }, | ||
| 336 | ]); | ||
| 337 | await waitForToast("Invalid keybindings configuration"); | ||
| 338 | } finally { | ||
| 339 | await mounted.cleanup(); | ||
| 340 | } | ||
| 341 | }); | ||
| 342 | |||
| 343 | it("does not show a toast from the replayed cached value on subscribe", async () => { | ||
| 344 | const mounted = await mountApp(); | ||
| 345 | |||
| 346 | try { | ||
| 347 | sendServerConfigUpdatedPush([]); | ||
| 348 | await waitForToast("Keybindings updated"); | ||
| 349 | await waitForNoToast("Keybindings updated"); | ||
| 350 | |||
| 351 | // Remount the app — onServerConfigUpdated replays the cached value | ||
| 352 | // synchronously on subscribe. This should NOT produce a toast. | ||
| 353 | await mounted.cleanup(); | ||
| 354 | const remounted = await mountApp(); | ||
| 355 | |||
| 356 | // Give it a moment to process the replayed value | ||
| 357 | await new Promise((resolve) => setTimeout(resolve, 500)); | ||
| 358 | |||
| 359 | const titles = queryToastTitles(); | ||
| 360 | expect( | ||
| 361 | titles.filter((t) => t === "Keybindings updated").length, | ||
| 362 | "Replayed cached value should not produce a toast", | ||
| 363 | ).toBe(0); | ||
| 364 | |||
| 365 | await remounted.cleanup(); | ||
| 366 | } catch (error) { | ||
| 367 | await mounted.cleanup().catch(() => {}); | ||
| 368 | throw error; | ||
| 369 | } | ||
| 370 | }); | ||
| 371 | }); | ||
apps/web/src/components/Sidebar.tsx+1| ... | @@ -1477,6 +1477,7 @@ export default function Sidebar() { | ... | @@ -1477,6 +1477,7 @@ export default function Sidebar() { |
| 1477 | <button | 1477 | <button |
| 1478 | type="button" | 1478 | type="button" |
| 1479 | aria-label={`Create new thread in ${project.name}`} | 1479 | aria-label={`Create new thread in ${project.name}`} |
| 1480 | data-testid="new-thread-button" | ||
| 1480 | /> | 1481 | /> |
| 1481 | } | 1482 | } |
| 1482 | showOnHover | 1483 | showOnHover |
apps/web/src/composerDraftStore.ts+17| ... | @@ -1279,3 +1279,20 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1279,3 +1279,20 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1279 | export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { | 1279 | export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { |
| 1280 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); | 1280 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); |
| 1281 | } | 1281 | } |
| 1282 | |||
| 1283 | /** | ||
| 1284 | * Clear draft threads that have been promoted to server threads. | ||
| 1285 | * | ||
| 1286 | * Call this after a snapshot sync so the route guard in `_chat.$threadId` | ||
| 1287 | * sees the server thread before the draft is removed — avoids a redirect | ||
| 1288 | * to `/` caused by a gap where neither draft nor server thread exists. | ||
| 1289 | */ | ||
| 1290 | export function clearPromotedDraftThreads(serverThreadIds: ReadonlySet<ThreadId>): void { | ||
| 1291 | const store = useComposerDraftStore.getState(); | ||
| 1292 | const draftThreadIds = Object.keys(store.draftThreadsByThreadId) as ThreadId[]; | ||
| 1293 | for (const draftId of draftThreadIds) { | ||
| 1294 | if (serverThreadIds.has(draftId)) { | ||
| 1295 | store.clearDraftThread(draftId); | ||
| 1296 | } | ||
| 1297 | } | ||
| 1298 | } |
apps/web/src/routes/__root.tsx+8-8| ... | @@ -15,7 +15,7 @@ import { Button } from "../components/ui/button"; | ... | @@ -15,7 +15,7 @@ import { Button } from "../components/ui/button"; |
| 15 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; | 15 | import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; |
| 16 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; | 16 | import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; |
| 17 | import { readNativeApi } from "../nativeApi"; | 17 | import { readNativeApi } from "../nativeApi"; |
| 18 | import { useComposerDraftStore } from "../composerDraftStore"; | 18 | import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; |
| 19 | import { useStore } from "../store"; | 19 | import { useStore } from "../store"; |
| 20 | import { useTerminalStateStore } from "../terminalStateStore"; | 20 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 21 | import { preferredTerminalEditor } from "../terminal-links"; | 21 | import { preferredTerminalEditor } from "../terminal-links"; |
| ... | @@ -140,7 +140,6 @@ function EventRouter() { | ... | @@ -140,7 +140,6 @@ function EventRouter() { |
| 140 | const navigate = useNavigate(); | 140 | const navigate = useNavigate(); |
| 141 | const pathname = useRouterState({ select: (state) => state.location.pathname }); | 141 | const pathname = useRouterState({ select: (state) => state.location.pathname }); |
| 142 | const pathnameRef = useRef(pathname); | 142 | const pathnameRef = useRef(pathname); |
| 143 | const lastConfigIssuesSignatureRef = useRef<string | null>(null); | ||
| 144 | const handledBootstrapThreadIdRef = useRef<string | null>(null); | 143 | const handledBootstrapThreadIdRef = useRef<string | null>(null); |
| 145 | 144 | ||
| 146 | pathnameRef.current = pathname; | 145 | pathnameRef.current = pathname; |
| ... | @@ -159,6 +158,7 @@ function EventRouter() { | ... | @@ -159,6 +158,7 @@ function EventRouter() { |
| 159 | if (disposed) return; | 158 | if (disposed) return; |
| 160 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); | 159 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); |
| 161 | syncServerReadModel(snapshot); | 160 | syncServerReadModel(snapshot); |
| 161 | clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id))); | ||
| 162 | const draftThreadIds = Object.keys( | 162 | const draftThreadIds = Object.keys( |
| 163 | useComposerDraftStore.getState().draftThreadsByThreadId, | 163 | useComposerDraftStore.getState().draftThreadsByThreadId, |
| 164 | ) as ThreadId[]; | 164 | ) as ThreadId[]; |
| ... | @@ -255,14 +255,13 @@ function EventRouter() { | ... | @@ -255,14 +255,13 @@ function EventRouter() { |
| 255 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; | 255 | handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; |
| 256 | })().catch(() => undefined); | 256 | })().catch(() => undefined); |
| 257 | }); | 257 | }); |
| 258 | // onServerConfigUpdated replays the latest cached value synchronously | ||
| 259 | // during subscribe. Skip the toast for that replay so effect re-runs | ||
| 260 | // don't produce duplicate toasts. | ||
| 261 | let subscribed = false; | ||
| 258 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { | 262 | const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { |
| 259 | const signature = JSON.stringify(payload.issues); | ||
| 260 | if (lastConfigIssuesSignatureRef.current === signature) { | ||
| 261 | return; | ||
| 262 | } | ||
| 263 | lastConfigIssuesSignatureRef.current = signature; | ||
| 264 | |||
| 265 | void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); | 263 | void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); |
| 264 | if (!subscribed) return; | ||
| 266 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); | 265 | const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); |
| 267 | if (!issue) { | 266 | if (!issue) { |
| 268 | toastManager.add({ | 267 | toastManager.add({ |
| ... | @@ -297,6 +296,7 @@ function EventRouter() { | ... | @@ -297,6 +296,7 @@ function EventRouter() { |
| 297 | }, | 296 | }, |
| 298 | }); | 297 | }); |
| 299 | }); | 298 | }); |
| 299 | subscribed = true; | ||
| 300 | return () => { | 300 | return () => { |
| 301 | disposed = true; | 301 | disposed = true; |
| 302 | needsProviderInvalidation = false; | 302 | needsProviderInvalidation = false; |
apps/web/src/wsNativeApi.test.ts+65-95| ... | @@ -1,12 +1,18 @@ | ... | @@ -1,12 +1,18 @@ |
| 1 | import { | 1 | import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | type ContextMenuItem, | 3 | type ContextMenuItem, |
| 4 | EventId, | ||
| 4 | ORCHESTRATION_WS_CHANNELS, | 5 | ORCHESTRATION_WS_CHANNELS, |
| 5 | ORCHESTRATION_WS_METHODS, | 6 | ORCHESTRATION_WS_METHODS, |
| 7 | type OrchestrationEvent, | ||
| 6 | ProjectId, | 8 | ProjectId, |
| 7 | ThreadId, | 9 | ThreadId, |
| 10 | type WsPushChannel, | ||
| 11 | type WsPushData, | ||
| 12 | type WsPushMessage, | ||
| 8 | WS_CHANNELS, | 13 | WS_CHANNELS, |
| 9 | WS_METHODS, | 14 | WS_METHODS, |
| 15 | type WsPush, | ||
| 10 | type ServerProviderStatus, | 16 | type ServerProviderStatus, |
| 11 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 18 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| ... | @@ -19,26 +25,38 @@ const showContextMenuFallbackMock = | ... | @@ -19,26 +25,38 @@ const showContextMenuFallbackMock = |
| 19 | position?: { x: number; y: number }, | 25 | position?: { x: number; y: number }, |
| 20 | ) => Promise<T | null> | 26 | ) => Promise<T | null> |
| 21 | >(); | 27 | >(); |
| 22 | const channelListeners = new Map<string, Set<(data: unknown) => void>>(); | 28 | const channelListeners = new Map<string, Set<(message: WsPush) => void>>(); |
| 23 | const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>( | 29 | const latestPushByChannel = new Map<string, WsPush>(); |
| 24 | (channel, listener) => { | 30 | const subscribeMock = vi.fn< |
| 25 | const listeners = channelListeners.get(channel) ?? new Set<(data: unknown) => void>(); | 31 | ( |
| 26 | listeners.add(listener); | 32 | channel: string, |
| 27 | channelListeners.set(channel, listeners); | 33 | listener: (message: WsPush) => void, |
| 28 | return () => { | 34 | options?: { replayLatest?: boolean }, |
| 29 | listeners.delete(listener); | 35 | ) => () => void |
| 30 | if (listeners.size === 0) { | 36 | >((channel, listener, options) => { |
| 31 | channelListeners.delete(channel); | 37 | const listeners = channelListeners.get(channel) ?? new Set<(message: WsPush) => void>(); |
| 32 | } | 38 | listeners.add(listener); |
| 33 | }; | 39 | channelListeners.set(channel, listeners); |
| 34 | }, | 40 | const latest = latestPushByChannel.get(channel); |
| 35 | ); | 41 | if (latest && options?.replayLatest) { |
| 42 | listener(latest); | ||
| 43 | } | ||
| 44 | return () => { | ||
| 45 | listeners.delete(listener); | ||
| 46 | if (listeners.size === 0) { | ||
| 47 | channelListeners.delete(channel); | ||
| 48 | } | ||
| 49 | }; | ||
| 50 | }); | ||
| 36 | 51 | ||
| 37 | vi.mock("./wsTransport", () => { | 52 | vi.mock("./wsTransport", () => { |
| 38 | return { | 53 | return { |
| 39 | WsTransport: class MockWsTransport { | 54 | WsTransport: class MockWsTransport { |
| 40 | request = requestMock; | 55 | request = requestMock; |
| 41 | subscribe = subscribeMock; | 56 | subscribe = subscribeMock; |
| 57 | getLatestPush(channel: string) { | ||
| 58 | return latestPushByChannel.get(channel) ?? null; | ||
| 59 | } | ||
| 42 | }, | 60 | }, |
| 43 | }; | 61 | }; |
| 44 | }); | 62 | }); |
| ... | @@ -47,11 +65,20 @@ vi.mock("./contextMenuFallback", () => ({ | ... | @@ -47,11 +65,20 @@ vi.mock("./contextMenuFallback", () => ({ |
| 47 | showContextMenuFallback: showContextMenuFallbackMock, | 65 | showContextMenuFallback: showContextMenuFallbackMock, |
| 48 | })); | 66 | })); |
| 49 | 67 | ||
| 50 | function emitPush(channel: string, data: unknown): void { | 68 | let nextPushSequence = 1; |
| 69 | |||
| 70 | function emitPush<C extends WsPushChannel>(channel: C, data: WsPushData<C>): void { | ||
| 51 | const listeners = channelListeners.get(channel); | 71 | const listeners = channelListeners.get(channel); |
| 72 | const message = { | ||
| 73 | type: "push" as const, | ||
| 74 | sequence: nextPushSequence++, | ||
| 75 | channel, | ||
| 76 | data, | ||
| 77 | } as WsPushMessage<C>; | ||
| 78 | latestPushByChannel.set(channel, message); | ||
| 52 | if (!listeners) return; | 79 | if (!listeners) return; |
| 53 | for (const listener of listeners) { | 80 | for (const listener of listeners) { |
| 54 | listener(data); | 81 | listener(message); |
| 55 | } | 82 | } |
| 56 | } | 83 | } |
| 57 | 84 | ||
| ... | @@ -81,6 +108,8 @@ beforeEach(() => { | ... | @@ -81,6 +108,8 @@ beforeEach(() => { |
| 81 | showContextMenuFallbackMock.mockReset(); | 108 | showContextMenuFallbackMock.mockReset(); |
| 82 | subscribeMock.mockClear(); | 109 | subscribeMock.mockClear(); |
| 83 | channelListeners.clear(); | 110 | channelListeners.clear(); |
| 111 | latestPushByChannel.clear(); | ||
| 112 | nextPushSequence = 1; | ||
| 84 | Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); | 113 | Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); |
| 85 | }); | 114 | }); |
| 86 | 115 | ||
| ... | @@ -90,7 +119,6 @@ afterEach(() => { | ... | @@ -90,7 +119,6 @@ afterEach(() => { |
| 90 | 119 | ||
| 91 | describe("wsNativeApi", () => { | 120 | describe("wsNativeApi", () => { |
| 92 | it("delivers and caches valid server.welcome payloads", async () => { | 121 | it("delivers and caches valid server.welcome payloads", async () => { |
| 93 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 94 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | 122 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); |
| 95 | 123 | ||
| 96 | createWsNativeApi(); | 124 | createWsNativeApi(); |
| ... | @@ -108,7 +136,6 @@ describe("wsNativeApi", () => { | ... | @@ -108,7 +136,6 @@ describe("wsNativeApi", () => { |
| 108 | 136 | ||
| 109 | expect(lateListener).toHaveBeenCalledTimes(1); | 137 | expect(lateListener).toHaveBeenCalledTimes(1); |
| 110 | expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload)); | 138 | expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload)); |
| 111 | expect(warnSpy).not.toHaveBeenCalled(); | ||
| 112 | }); | 139 | }); |
| 113 | 140 | ||
| 114 | it("preserves bootstrap ids from server.welcome payloads", async () => { | 141 | it("preserves bootstrap ids from server.welcome payloads", async () => { |
| ... | @@ -121,8 +148,8 @@ describe("wsNativeApi", () => { | ... | @@ -121,8 +148,8 @@ describe("wsNativeApi", () => { |
| 121 | emitPush(WS_CHANNELS.serverWelcome, { | 148 | emitPush(WS_CHANNELS.serverWelcome, { |
| 122 | cwd: "/tmp/workspace", | 149 | cwd: "/tmp/workspace", |
| 123 | projectName: "t3-code", | 150 | projectName: "t3-code", |
| 124 | bootstrapProjectId: "project-1", | 151 | bootstrapProjectId: ProjectId.makeUnsafe("project-1"), |
| 125 | bootstrapThreadId: "thread-1", | 152 | bootstrapThreadId: ThreadId.makeUnsafe("thread-1"), |
| 126 | }); | 153 | }); |
| 127 | 154 | ||
| 128 | expect(listener).toHaveBeenCalledTimes(1); | 155 | expect(listener).toHaveBeenCalledTimes(1); |
| ... | @@ -136,31 +163,26 @@ describe("wsNativeApi", () => { | ... | @@ -136,31 +163,26 @@ describe("wsNativeApi", () => { |
| 136 | ); | 163 | ); |
| 137 | }); | 164 | }); |
| 138 | 165 | ||
| 139 | it("ignores invalid server.welcome payloads and keeps subscription active", async () => { | 166 | it("delivers successive server.welcome payloads to active listeners", async () => { |
| 140 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 141 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); | 167 | const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi"); |
| 142 | 168 | ||
| 143 | createWsNativeApi(); | 169 | createWsNativeApi(); |
| 144 | const listener = vi.fn(); | 170 | const listener = vi.fn(); |
| 145 | onServerWelcome(listener); | 171 | onServerWelcome(listener); |
| 146 | 172 | ||
| 147 | emitPush(WS_CHANNELS.serverWelcome, { cwd: 42, projectName: "t3-code" }); | 173 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/one", projectName: "one" }); |
| 148 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); | 174 | emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" }); |
| 149 | 175 | ||
| 150 | expect(listener).toHaveBeenCalledTimes(1); | 176 | expect(listener).toHaveBeenCalledTimes(2); |
| 151 | expect(listener).toHaveBeenCalledWith( | 177 | expect(listener).toHaveBeenLastCalledWith( |
| 152 | expect.objectContaining({ cwd: "/tmp/workspace", projectName: "t3-code" }), | 178 | expect.objectContaining({ |
| 179 | cwd: "/tmp/workspace", | ||
| 180 | projectName: "t3-code", | ||
| 181 | }), | ||
| 153 | ); | 182 | ); |
| 154 | expect(warnSpy).toHaveBeenCalledTimes(1); | ||
| 155 | expect(warnSpy).toHaveBeenCalledWith("Dropped inbound WebSocket push payload", { | ||
| 156 | reason: "decode-failed", | ||
| 157 | raw: { cwd: 42, projectName: "t3-code" }, | ||
| 158 | issue: expect.stringContaining("SchemaError"), | ||
| 159 | }); | ||
| 160 | }); | 183 | }); |
| 161 | 184 | ||
| 162 | it("delivers and caches valid server.configUpdated payloads", async () => { | 185 | it("delivers and caches valid server.configUpdated payloads", async () => { |
| 163 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 164 | const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); | 186 | const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); |
| 165 | 187 | ||
| 166 | createWsNativeApi(); | 188 | createWsNativeApi(); |
| ... | @@ -186,11 +208,9 @@ describe("wsNativeApi", () => { | ... | @@ -186,11 +208,9 @@ describe("wsNativeApi", () => { |
| 186 | onServerConfigUpdated(lateListener); | 208 | onServerConfigUpdated(lateListener); |
| 187 | expect(lateListener).toHaveBeenCalledTimes(1); | 209 | expect(lateListener).toHaveBeenCalledTimes(1); |
| 188 | expect(lateListener).toHaveBeenCalledWith(payload); | 210 | expect(lateListener).toHaveBeenCalledWith(payload); |
| 189 | expect(warnSpy).not.toHaveBeenCalled(); | ||
| 190 | }); | 211 | }); |
| 191 | 212 | ||
| 192 | it("drops malformed server.configUpdated payloads", async () => { | 213 | it("delivers successive server.configUpdated payloads to active listeners", async () => { |
| 193 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 194 | const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); | 214 | const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi"); |
| 195 | 215 | ||
| 196 | createWsNativeApi(); | 216 | createWsNativeApi(); |
| ... | @@ -198,24 +218,22 @@ describe("wsNativeApi", () => { | ... | @@ -198,24 +218,22 @@ describe("wsNativeApi", () => { |
| 198 | onServerConfigUpdated(listener); | 218 | onServerConfigUpdated(listener); |
| 199 | 219 | ||
| 200 | emitPush(WS_CHANNELS.serverConfigUpdated, { | 220 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 201 | issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }], | 221 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], |
| 202 | providers: defaultProviders, | 222 | providers: defaultProviders, |
| 203 | }); | 223 | }); |
| 204 | emitPush(WS_CHANNELS.serverConfigUpdated, { | 224 | emitPush(WS_CHANNELS.serverConfigUpdated, { |
| 205 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | 225 | issues: [], |
| 206 | providers: defaultProviders, | 226 | providers: defaultProviders, |
| 207 | }); | 227 | }); |
| 208 | 228 | ||
| 209 | expect(listener).toHaveBeenCalledTimes(1); | 229 | expect(listener).toHaveBeenCalledTimes(2); |
| 210 | expect(listener).toHaveBeenCalledWith({ | 230 | expect(listener).toHaveBeenLastCalledWith({ |
| 211 | issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], | 231 | issues: [], |
| 212 | providers: defaultProviders, | 232 | providers: defaultProviders, |
| 213 | }); | 233 | }); |
| 214 | expect(warnSpy).toHaveBeenCalledTimes(1); | ||
| 215 | }); | 234 | }); |
| 216 | 235 | ||
| 217 | it("forwards valid terminal and orchestration events", async () => { | 236 | it("forwards valid terminal and orchestration events", async () => { |
| 218 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 219 | const { createWsNativeApi } = await import("./wsNativeApi"); | 237 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 220 | 238 | ||
| 221 | const api = createWsNativeApi(); | 239 | const api = createWsNativeApi(); |
| ... | @@ -236,9 +254,9 @@ describe("wsNativeApi", () => { | ... | @@ -236,9 +254,9 @@ describe("wsNativeApi", () => { |
| 236 | 254 | ||
| 237 | const orchestrationEvent = { | 255 | const orchestrationEvent = { |
| 238 | sequence: 1, | 256 | sequence: 1, |
| 239 | eventId: "event-1", | 257 | eventId: EventId.makeUnsafe("event-1"), |
| 240 | aggregateKind: "project", | 258 | aggregateKind: "project", |
| 241 | aggregateId: "project-1", | 259 | aggregateId: ProjectId.makeUnsafe("project-1"), |
| 242 | occurredAt: "2026-02-24T00:00:00.000Z", | 260 | occurredAt: "2026-02-24T00:00:00.000Z", |
| 243 | commandId: null, | 261 | commandId: null, |
| 244 | causationEventId: null, | 262 | causationEventId: null, |
| ... | @@ -246,7 +264,7 @@ describe("wsNativeApi", () => { | ... | @@ -246,7 +264,7 @@ describe("wsNativeApi", () => { |
| 246 | metadata: {}, | 264 | metadata: {}, |
| 247 | type: "project.created", | 265 | type: "project.created", |
| 248 | payload: { | 266 | payload: { |
| 249 | projectId: "project-1", | 267 | projectId: ProjectId.makeUnsafe("project-1"), |
| 250 | title: "Project", | 268 | title: "Project", |
| 251 | workspaceRoot: "/tmp/workspace", | 269 | workspaceRoot: "/tmp/workspace", |
| 252 | defaultModel: null, | 270 | defaultModel: null, |
| ... | @@ -254,61 +272,13 @@ describe("wsNativeApi", () => { | ... | @@ -254,61 +272,13 @@ describe("wsNativeApi", () => { |
| 254 | createdAt: "2026-02-24T00:00:00.000Z", | 272 | createdAt: "2026-02-24T00:00:00.000Z", |
| 255 | updatedAt: "2026-02-24T00:00:00.000Z", | 273 | updatedAt: "2026-02-24T00:00:00.000Z", |
| 256 | }, | 274 | }, |
| 257 | } as const; | 275 | } satisfies Extract<OrchestrationEvent, { type: "project.created" }>; |
| 258 | emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent); | 276 | emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent); |
| 259 | 277 | ||
| 260 | expect(onTerminalEvent).toHaveBeenCalledTimes(1); | 278 | expect(onTerminalEvent).toHaveBeenCalledTimes(1); |
| 261 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); | 279 | expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent); |
| 262 | expect(onDomainEvent).toHaveBeenCalledTimes(1); | 280 | expect(onDomainEvent).toHaveBeenCalledTimes(1); |
| 263 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); | 281 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); |
| 264 | expect(warnSpy).not.toHaveBeenCalled(); | ||
| 265 | }); | ||
| 266 | |||
| 267 | it("drops malformed terminal and orchestration push payloads", async () => { | ||
| 268 | const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); | ||
| 269 | const { createWsNativeApi } = await import("./wsNativeApi"); | ||
| 270 | |||
| 271 | const api = createWsNativeApi(); | ||
| 272 | const onTerminalEvent = vi.fn(); | ||
| 273 | const onDomainEvent = vi.fn(); | ||
| 274 | |||
| 275 | api.terminal.onEvent(onTerminalEvent); | ||
| 276 | api.orchestration.onDomainEvent(onDomainEvent); | ||
| 277 | |||
| 278 | emitPush(WS_CHANNELS.terminalEvent, { | ||
| 279 | threadId: "thread-1", | ||
| 280 | terminalId: "", | ||
| 281 | createdAt: "2026-02-24T00:00:00.000Z", | ||
| 282 | type: "output", | ||
| 283 | data: "hello", | ||
| 284 | }); | ||
| 285 | emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, { | ||
| 286 | sequence: -1, | ||
| 287 | type: "project.created", | ||
| 288 | }); | ||
| 289 | |||
| 290 | expect(onTerminalEvent).not.toHaveBeenCalled(); | ||
| 291 | expect(onDomainEvent).not.toHaveBeenCalled(); | ||
| 292 | expect(warnSpy).toHaveBeenCalledTimes(2); | ||
| 293 | expect(warnSpy).toHaveBeenNthCalledWith(1, "Dropped inbound WebSocket push payload", { | ||
| 294 | reason: "decode-failed", | ||
| 295 | raw: { | ||
| 296 | threadId: "thread-1", | ||
| 297 | terminalId: "", | ||
| 298 | createdAt: "2026-02-24T00:00:00.000Z", | ||
| 299 | type: "output", | ||
| 300 | data: "hello", | ||
| 301 | }, | ||
| 302 | issue: expect.stringContaining("SchemaError"), | ||
| 303 | }); | ||
| 304 | expect(warnSpy).toHaveBeenNthCalledWith(2, "Dropped inbound WebSocket push payload", { | ||
| 305 | reason: "decode-failed", | ||
| 306 | raw: { | ||
| 307 | sequence: -1, | ||
| 308 | type: "project.created", | ||
| 309 | }, | ||
| 310 | issue: expect.stringContaining("SchemaError"), | ||
| 311 | }); | ||
| 312 | }); | 282 | }); |
| 313 | 283 | ||
| 314 | it("wraps orchestration dispatch commands in the command envelope", async () => { | 284 | it("wraps orchestration dispatch commands in the command envelope", async () => { |
apps/web/src/wsNativeApi.ts+16-45| ... | @@ -1,16 +1,13 @@ | ... | @@ -1,16 +1,13 @@ |
| 1 | import { | 1 | import { |
| 2 | OrchestrationEvent, | ||
| 3 | ORCHESTRATION_WS_CHANNELS, | 2 | ORCHESTRATION_WS_CHANNELS, |
| 4 | ORCHESTRATION_WS_METHODS, | 3 | ORCHESTRATION_WS_METHODS, |
| 5 | type ContextMenuItem, | 4 | type ContextMenuItem, |
| 6 | type NativeApi, | 5 | type NativeApi, |
| 7 | ServerConfigUpdatedPayload, | 6 | ServerConfigUpdatedPayload, |
| 8 | TerminalEvent, | ||
| 9 | WS_CHANNELS, | 7 | WS_CHANNELS, |
| 10 | WS_METHODS, | 8 | WS_METHODS, |
| 11 | WsWelcomePayload, | 9 | type WsWelcomePayload, |
| 12 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 13 | import { Cause, Schema } from "effect"; | ||
| 14 | 11 | ||
| 15 | import { showContextMenuFallback } from "./contextMenuFallback"; | 12 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 16 | import { WsTransport } from "./wsTransport"; | 13 | import { WsTransport } from "./wsTransport"; |
| ... | @@ -18,24 +15,6 @@ import { WsTransport } from "./wsTransport"; | ... | @@ -18,24 +15,6 @@ import { WsTransport } from "./wsTransport"; |
| 18 | let instance: { api: NativeApi; transport: WsTransport } | null = null; | 15 | let instance: { api: NativeApi; transport: WsTransport } | null = null; |
| 19 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); | 16 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); |
| 20 | const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); | 17 | const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); |
| 21 | let lastWelcome: WsWelcomePayload | null = null; | ||
| 22 | let lastServerConfigUpdated: ServerConfigUpdatedPayload | null = null; | ||
| 23 | |||
| 24 | const decodeAndWarnOnFailure = <T>( | ||
| 25 | schema: Schema.Schema<T> & { readonly DecodingServices: never }, | ||
| 26 | raw: unknown, | ||
| 27 | ): T | null => { | ||
| 28 | const decoded = Schema.decodeUnknownExit(schema)(raw); | ||
| 29 | if (decoded._tag === "Failure") { | ||
| 30 | console.warn("Dropped inbound WebSocket push payload", { | ||
| 31 | reason: "decode-failed", | ||
| 32 | raw, | ||
| 33 | issue: Cause.pretty(decoded.cause), | ||
| 34 | }); | ||
| 35 | return null; | ||
| 36 | } | ||
| 37 | return decoded.value; | ||
| 38 | }; | ||
| 39 | 18 | ||
| 40 | /** | 19 | /** |
| 41 | * Subscribe to the server welcome message. If a welcome was already received | 20 | * Subscribe to the server welcome message. If a welcome was already received |
| ... | @@ -45,10 +24,10 @@ const decodeAndWarnOnFailure = <T>( | ... | @@ -45,10 +24,10 @@ const decodeAndWarnOnFailure = <T>( |
| 45 | export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { | 24 | export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { |
| 46 | welcomeListeners.add(listener); | 25 | welcomeListeners.add(listener); |
| 47 | 26 | ||
| 48 | // Replay cached welcome for late subscribers | 27 | const latestWelcome = instance?.transport.getLatestPush(WS_CHANNELS.serverWelcome)?.data ?? null; |
| 49 | if (lastWelcome) { | 28 | if (latestWelcome) { |
| 50 | try { | 29 | try { |
| 51 | listener(lastWelcome); | 30 | listener(latestWelcome); |
| 52 | } catch { | 31 | } catch { |
| 53 | // Swallow listener errors | 32 | // Swallow listener errors |
| 54 | } | 33 | } |
| ... | @@ -68,9 +47,11 @@ export function onServerConfigUpdated( | ... | @@ -68,9 +47,11 @@ export function onServerConfigUpdated( |
| 68 | ): () => void { | 47 | ): () => void { |
| 69 | serverConfigUpdatedListeners.add(listener); | 48 | serverConfigUpdatedListeners.add(listener); |
| 70 | 49 | ||
| 71 | if (lastServerConfigUpdated) { | 50 | const latestConfig = |
| 51 | instance?.transport.getLatestPush(WS_CHANNELS.serverConfigUpdated)?.data ?? null; | ||
| 52 | if (latestConfig) { | ||
| 72 | try { | 53 | try { |
| 73 | listener(lastServerConfigUpdated); | 54 | listener(latestConfig); |
| 74 | } catch { | 55 | } catch { |
| 75 | // Swallow listener errors | 56 | // Swallow listener errors |
| 76 | } | 57 | } |
| ... | @@ -86,12 +67,8 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -86,12 +67,8 @@ export function createWsNativeApi(): NativeApi { |
| 86 | 67 | ||
| 87 | const transport = new WsTransport(); | 68 | const transport = new WsTransport(); |
| 88 | 69 | ||
| 89 | // Listen for server welcome and forward to registered listeners. | 70 | transport.subscribe(WS_CHANNELS.serverWelcome, (message) => { |
| 90 | // Also cache it so late subscribers (React effects) get it immediately. | 71 | const payload = message.data; |
| 91 | transport.subscribe(WS_CHANNELS.serverWelcome, (data) => { | ||
| 92 | const payload = decodeAndWarnOnFailure(WsWelcomePayload, data); | ||
| 93 | if (!payload) return; | ||
| 94 | lastWelcome = payload; | ||
| 95 | for (const listener of welcomeListeners) { | 72 | for (const listener of welcomeListeners) { |
| 96 | try { | 73 | try { |
| 97 | listener(payload); | 74 | listener(payload); |
| ... | @@ -100,10 +77,8 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -100,10 +77,8 @@ export function createWsNativeApi(): NativeApi { |
| 100 | } | 77 | } |
| 101 | } | 78 | } |
| 102 | }); | 79 | }); |
| 103 | transport.subscribe(WS_CHANNELS.serverConfigUpdated, (data) => { | 80 | transport.subscribe(WS_CHANNELS.serverConfigUpdated, (message) => { |
| 104 | const payload = decodeAndWarnOnFailure(ServerConfigUpdatedPayload, data); | 81 | const payload = message.data; |
| 105 | if (!payload) return; | ||
| 106 | lastServerConfigUpdated = payload; | ||
| 107 | for (const listener of serverConfigUpdatedListeners) { | 82 | for (const listener of serverConfigUpdatedListeners) { |
| 108 | try { | 83 | try { |
| 109 | listener(payload); | 84 | listener(payload); |
| ... | @@ -134,10 +109,7 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -134,10 +109,7 @@ export function createWsNativeApi(): NativeApi { |
| 134 | restart: (input) => transport.request(WS_METHODS.terminalRestart, input), | 109 | restart: (input) => transport.request(WS_METHODS.terminalRestart, input), |
| 135 | close: (input) => transport.request(WS_METHODS.terminalClose, input), | 110 | close: (input) => transport.request(WS_METHODS.terminalClose, input), |
| 136 | onEvent: (callback) => | 111 | onEvent: (callback) => |
| 137 | transport.subscribe(WS_CHANNELS.terminalEvent, (data) => { | 112 | transport.subscribe(WS_CHANNELS.terminalEvent, (message) => callback(message.data)), |
| 138 | const payload = decodeAndWarnOnFailure(TerminalEvent, data); | ||
| 139 | if (payload) callback(payload); | ||
| 140 | }), | ||
| 141 | }, | 113 | }, |
| 142 | projects: { | 114 | projects: { |
| 143 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), | 115 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), |
| ... | @@ -199,10 +171,9 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -199,10 +171,9 @@ export function createWsNativeApi(): NativeApi { |
| 199 | replayEvents: (fromSequenceExclusive) => | 171 | replayEvents: (fromSequenceExclusive) => |
| 200 | transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), | 172 | transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), |
| 201 | onDomainEvent: (callback) => | 173 | onDomainEvent: (callback) => |
| 202 | transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (data) => { | 174 | transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (message) => |
| 203 | const payload = decodeAndWarnOnFailure(OrchestrationEvent, data); | 175 | callback(message.data), |
| 204 | if (payload) callback(payload); | 176 | ), |
| 205 | }), | ||
| 206 | }, | 177 | }, |
| 207 | }; | 178 | }; |
| 208 | 179 |
apps/web/src/wsTransport.test.ts+53-19| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { WS_CHANNELS } from "@t3tools/contracts"; | ||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | 3 | ||
| 3 | import { WsTransport } from "./wsTransport"; | 4 | import { WsTransport } from "./wsTransport"; |
| ... | @@ -90,18 +91,24 @@ describe("WsTransport", () => { | ... | @@ -90,18 +91,24 @@ describe("WsTransport", () => { |
| 90 | socket.open(); | 91 | socket.open(); |
| 91 | 92 | ||
| 92 | const listener = vi.fn(); | 93 | const listener = vi.fn(); |
| 93 | transport.subscribe("providers.event", listener); | 94 | transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener); |
| 94 | 95 | ||
| 95 | socket.serverMessage( | 96 | socket.serverMessage( |
| 96 | JSON.stringify({ | 97 | JSON.stringify({ |
| 97 | type: "push", | 98 | type: "push", |
| 98 | channel: "providers.event", | 99 | sequence: 1, |
| 99 | data: { status: "ok" }, | 100 | channel: WS_CHANNELS.serverConfigUpdated, |
| 101 | data: { issues: [], providers: [] }, | ||
| 100 | }), | 102 | }), |
| 101 | ); | 103 | ); |
| 102 | 104 | ||
| 103 | expect(listener).toHaveBeenCalledTimes(1); | 105 | expect(listener).toHaveBeenCalledTimes(1); |
| 104 | expect(listener).toHaveBeenCalledWith({ status: "ok" }); | 106 | expect(listener).toHaveBeenCalledWith({ |
| 107 | type: "push", | ||
| 108 | sequence: 1, | ||
| 109 | channel: WS_CHANNELS.serverConfigUpdated, | ||
| 110 | data: { issues: [], providers: [] }, | ||
| 111 | }); | ||
| 105 | 112 | ||
| 106 | transport.dispose(); | 113 | transport.dispose(); |
| 107 | }); | 114 | }); |
| ... | @@ -137,12 +144,13 @@ describe("WsTransport", () => { | ... | @@ -137,12 +144,13 @@ describe("WsTransport", () => { |
| 137 | socket.open(); | 144 | socket.open(); |
| 138 | 145 | ||
| 139 | const listener = vi.fn(); | 146 | const listener = vi.fn(); |
| 140 | transport.subscribe("providers.event", listener); | 147 | transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener); |
| 141 | 148 | ||
| 142 | socket.serverMessage("{ invalid-json"); | 149 | socket.serverMessage("{ invalid-json"); |
| 143 | socket.serverMessage( | 150 | socket.serverMessage( |
| 144 | JSON.stringify({ | 151 | JSON.stringify({ |
| 145 | type: "push", | 152 | type: "push", |
| 153 | sequence: 2, | ||
| 146 | channel: 42, | 154 | channel: 42, |
| 147 | data: { bad: true }, | 155 | data: { bad: true }, |
| 148 | }), | 156 | }), |
| ... | @@ -150,26 +158,52 @@ describe("WsTransport", () => { | ... | @@ -150,26 +158,52 @@ describe("WsTransport", () => { |
| 150 | socket.serverMessage( | 158 | socket.serverMessage( |
| 151 | JSON.stringify({ | 159 | JSON.stringify({ |
| 152 | type: "push", | 160 | type: "push", |
| 153 | channel: "providers.event", | 161 | sequence: 3, |
| 154 | data: { ok: true }, | 162 | channel: WS_CHANNELS.serverConfigUpdated, |
| 163 | data: { issues: [], providers: [] }, | ||
| 155 | }), | 164 | }), |
| 156 | ); | 165 | ); |
| 157 | 166 | ||
| 158 | expect(listener).toHaveBeenCalledTimes(1); | 167 | expect(listener).toHaveBeenCalledTimes(1); |
| 159 | expect(listener).toHaveBeenCalledWith({ ok: true }); | 168 | expect(listener).toHaveBeenCalledWith({ |
| 160 | expect(warnSpy).toHaveBeenCalledTimes(2); | 169 | type: "push", |
| 161 | expect(warnSpy).toHaveBeenNthCalledWith(1, "Dropped inbound WebSocket envelope", { | 170 | sequence: 3, |
| 162 | reason: "decode-failed", | 171 | channel: WS_CHANNELS.serverConfigUpdated, |
| 163 | issue: | 172 | data: { issues: [], providers: [] }, |
| 164 | "SchemaError: SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)", | ||
| 165 | raw: "{ invalid-json", | ||
| 166 | }); | ||
| 167 | expect(warnSpy).toHaveBeenNthCalledWith(2, "Dropped inbound WebSocket envelope", { | ||
| 168 | reason: "decode-failed", | ||
| 169 | issue: expect.stringContaining("SchemaError: Expected string, got 42"), | ||
| 170 | raw: '{"type":"push","channel":42,"data":{"bad":true}}', | ||
| 171 | }); | 173 | }); |
| 174 | expect(warnSpy).toHaveBeenCalledTimes(2); | ||
| 175 | expect(warnSpy).toHaveBeenNthCalledWith( | ||
| 176 | 1, | ||
| 177 | "Dropped inbound WebSocket envelope", | ||
| 178 | "SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)", | ||
| 179 | ); | ||
| 180 | expect(warnSpy).toHaveBeenNthCalledWith( | ||
| 181 | 2, | ||
| 182 | "Dropped inbound WebSocket envelope", | ||
| 183 | expect.stringContaining('Expected "server.configUpdated"'), | ||
| 184 | ); | ||
| 185 | |||
| 186 | transport.dispose(); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("queues requests until the websocket opens", async () => { | ||
| 190 | const transport = new WsTransport("ws://localhost:3020"); | ||
| 191 | const socket = getSocket(); | ||
| 172 | 192 | ||
| 193 | const requestPromise = transport.request("projects.list"); | ||
| 194 | expect(socket.sent).toHaveLength(0); | ||
| 195 | |||
| 196 | socket.open(); | ||
| 197 | expect(socket.sent).toHaveLength(1); | ||
| 198 | const requestEnvelope = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; | ||
| 199 | socket.serverMessage( | ||
| 200 | JSON.stringify({ | ||
| 201 | id: requestEnvelope.id, | ||
| 202 | result: { projects: [] }, | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | |||
| 206 | await expect(requestPromise).resolves.toEqual({ projects: [] }); | ||
| 173 | transport.dispose(); | 207 | transport.dispose(); |
| 174 | }); | 208 | }); |
| 175 | }); | 209 | }); |
apps/web/src/wsTransport.ts+124-53| ... | @@ -1,7 +1,15 @@ | ... | @@ -1,7 +1,15 @@ |
| 1 | import { WebSocketResponse, WsPush, WsResponse } from "@t3tools/contracts"; | 1 | import { |
| 2 | import { Cause, Schema } from "effect"; | 2 | type WsPush, |
| 3 | type WsPushChannel, | ||
| 4 | type WsPushMessage, | ||
| 5 | WebSocketResponse, | ||
| 6 | type WsResponse as WsResponseMessage, | ||
| 7 | WsResponse as WsResponseSchema, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | ||
| 10 | import { Result, Schema } from "effect"; | ||
| 3 | 11 | ||
| 4 | type PushListener = (data: unknown) => void; | 12 | type PushListener<C extends WsPushChannel> = (message: WsPushMessage<C>) => void; |
| 5 | 13 | ||
| 6 | interface PendingRequest { | 14 | interface PendingRequest { |
| 7 | resolve: (result: unknown) => void; | 15 | resolve: (result: unknown) => void; |
| ... | @@ -9,12 +17,20 @@ interface PendingRequest { | ... | @@ -9,12 +17,20 @@ interface PendingRequest { |
| 9 | timeout: ReturnType<typeof setTimeout>; | 17 | timeout: ReturnType<typeof setTimeout>; |
| 10 | } | 18 | } |
| 11 | 19 | ||
| 20 | interface SubscribeOptions { | ||
| 21 | readonly replayLatest?: boolean; | ||
| 22 | } | ||
| 23 | |||
| 24 | type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed"; | ||
| 25 | |||
| 12 | const REQUEST_TIMEOUT_MS = 60_000; | 26 | const REQUEST_TIMEOUT_MS = 60_000; |
| 13 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; | 27 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; |
| 14 | const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse)); | 28 | const decodeWsResponse = decodeUnknownJsonResult(WsResponseSchema); |
| 15 | const isWsPushEnvelope = Schema.is(WsPush); | ||
| 16 | const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse); | 29 | const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse); |
| 17 | 30 | ||
| 31 | const isWsPushMessage = (value: WsResponseMessage): value is WsPush => | ||
| 32 | "type" in value && value.type === "push"; | ||
| 33 | |||
| 18 | interface WsRequestEnvelope { | 34 | interface WsRequestEnvelope { |
| 19 | id: string; | 35 | id: string; |
| 20 | body: { | 36 | body: { |
| ... | @@ -23,20 +39,28 @@ interface WsRequestEnvelope { | ... | @@ -23,20 +39,28 @@ interface WsRequestEnvelope { |
| 23 | }; | 39 | }; |
| 24 | } | 40 | } |
| 25 | 41 | ||
| 42 | function asError(value: unknown, fallback: string): Error { | ||
| 43 | if (value instanceof Error) { | ||
| 44 | return value; | ||
| 45 | } | ||
| 46 | return new Error(fallback); | ||
| 47 | } | ||
| 48 | |||
| 26 | export class WsTransport { | 49 | export class WsTransport { |
| 27 | private ws: WebSocket | null = null; | 50 | private ws: WebSocket | null = null; |
| 28 | private nextId = 1; | 51 | private nextId = 1; |
| 29 | private readonly pending = new Map<string, PendingRequest>(); | 52 | private readonly pending = new Map<string, PendingRequest>(); |
| 30 | private readonly listeners = new Map<string, Set<PushListener>>(); | 53 | private readonly listeners = new Map<string, Set<(message: WsPush) => void>>(); |
| 54 | private readonly latestPushByChannel = new Map<string, WsPush>(); | ||
| 55 | private readonly outboundQueue: string[] = []; | ||
| 31 | private reconnectAttempt = 0; | 56 | private reconnectAttempt = 0; |
| 32 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; | 57 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; |
| 33 | private disposed = false; | 58 | private disposed = false; |
| 59 | private state: TransportState = "connecting"; | ||
| 34 | private readonly url: string; | 60 | private readonly url: string; |
| 35 | 61 | ||
| 36 | constructor(url?: string) { | 62 | constructor(url?: string) { |
| 37 | const bridgeUrl = window.desktopBridge?.getWsUrl(); | 63 | const bridgeUrl = window.desktopBridge?.getWsUrl(); |
| 38 | // In dev mode, VITE_WS_URL points to the server's WebSocket endpoint. | ||
| 39 | // In production, the page is served by the WS server on the same host:port. | ||
| 40 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; | 64 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; |
| 41 | this.url = | 65 | this.url = |
| 42 | url ?? | 66 | url ?? |
| ... | @@ -52,9 +76,11 @@ export class WsTransport { | ... | @@ -52,9 +76,11 @@ export class WsTransport { |
| 52 | if (typeof method !== "string" || method.length === 0) { | 76 | if (typeof method !== "string" || method.length === 0) { |
| 53 | throw new Error("Request method is required"); | 77 | throw new Error("Request method is required"); |
| 54 | } | 78 | } |
| 79 | |||
| 55 | const id = String(this.nextId++); | 80 | const id = String(this.nextId++); |
| 56 | const body = params != null ? { ...params, _tag: method } : { _tag: method }; | 81 | const body = params != null ? { ...params, _tag: method } : { _tag: method }; |
| 57 | const message: WsRequestEnvelope = { id, body }; | 82 | const message: WsRequestEnvelope = { id, body }; |
| 83 | const encoded = JSON.stringify(message); | ||
| 58 | 84 | ||
| 59 | return new Promise<T>((resolve, reject) => { | 85 | return new Promise<T>((resolve, reject) => { |
| 60 | const timeout = setTimeout(() => { | 86 | const timeout = setTimeout(() => { |
| ... | @@ -68,28 +94,53 @@ export class WsTransport { | ... | @@ -68,28 +94,53 @@ export class WsTransport { |
| 68 | timeout, | 94 | timeout, |
| 69 | }); | 95 | }); |
| 70 | 96 | ||
| 71 | this.send(message); | 97 | this.send(encoded); |
| 72 | }); | 98 | }); |
| 73 | } | 99 | } |
| 74 | 100 | ||
| 75 | subscribe(channel: string, listener: PushListener): () => void { | 101 | subscribe<C extends WsPushChannel>( |
| 102 | channel: C, | ||
| 103 | listener: PushListener<C>, | ||
| 104 | options?: SubscribeOptions, | ||
| 105 | ): () => void { | ||
| 76 | let channelListeners = this.listeners.get(channel); | 106 | let channelListeners = this.listeners.get(channel); |
| 77 | if (!channelListeners) { | 107 | if (!channelListeners) { |
| 78 | channelListeners = new Set(); | 108 | channelListeners = new Set<(message: WsPush) => void>(); |
| 79 | this.listeners.set(channel, channelListeners); | 109 | this.listeners.set(channel, channelListeners); |
| 80 | } | 110 | } |
| 81 | channelListeners.add(listener); | 111 | |
| 112 | const wrappedListener = (message: WsPush) => { | ||
| 113 | listener(message as WsPushMessage<C>); | ||
| 114 | }; | ||
| 115 | channelListeners.add(wrappedListener); | ||
| 116 | |||
| 117 | if (options?.replayLatest) { | ||
| 118 | const latest = this.latestPushByChannel.get(channel); | ||
| 119 | if (latest) { | ||
| 120 | wrappedListener(latest); | ||
| 121 | } | ||
| 122 | } | ||
| 82 | 123 | ||
| 83 | return () => { | 124 | return () => { |
| 84 | channelListeners!.delete(listener); | 125 | channelListeners?.delete(wrappedListener); |
| 85 | if (channelListeners!.size === 0) { | 126 | if (channelListeners?.size === 0) { |
| 86 | this.listeners.delete(channel); | 127 | this.listeners.delete(channel); |
| 87 | } | 128 | } |
| 88 | }; | 129 | }; |
| 89 | } | 130 | } |
| 90 | 131 | ||
| 132 | getLatestPush<C extends WsPushChannel>(channel: C): WsPushMessage<C> | null { | ||
| 133 | const latest = this.latestPushByChannel.get(channel); | ||
| 134 | return latest ? (latest as WsPushMessage<C>) : null; | ||
| 135 | } | ||
| 136 | |||
| 137 | getState(): TransportState { | ||
| 138 | return this.state; | ||
| 139 | } | ||
| 140 | |||
| 91 | dispose() { | 141 | dispose() { |
| 92 | this.disposed = true; | 142 | this.disposed = true; |
| 143 | this.state = "disposed"; | ||
| 93 | if (this.reconnectTimer !== null) { | 144 | if (this.reconnectTimer !== null) { |
| 94 | clearTimeout(this.reconnectTimer); | 145 | clearTimeout(this.reconnectTimer); |
| 95 | this.reconnectTimer = null; | 146 | this.reconnectTimer = null; |
| ... | @@ -99,18 +150,24 @@ export class WsTransport { | ... | @@ -99,18 +150,24 @@ export class WsTransport { |
| 99 | pending.reject(new Error("Transport disposed")); | 150 | pending.reject(new Error("Transport disposed")); |
| 100 | } | 151 | } |
| 101 | this.pending.clear(); | 152 | this.pending.clear(); |
| 153 | this.outboundQueue.length = 0; | ||
| 102 | this.ws?.close(); | 154 | this.ws?.close(); |
| 103 | this.ws = null; | 155 | this.ws = null; |
| 104 | } | 156 | } |
| 105 | 157 | ||
| 106 | private connect() { | 158 | private connect() { |
| 107 | if (this.disposed) return; | 159 | if (this.disposed) { |
| 160 | return; | ||
| 161 | } | ||
| 108 | 162 | ||
| 163 | this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting"; | ||
| 109 | const ws = new WebSocket(this.url); | 164 | const ws = new WebSocket(this.url); |
| 110 | 165 | ||
| 111 | ws.addEventListener("open", () => { | 166 | ws.addEventListener("open", () => { |
| 112 | this.ws = ws; | 167 | this.ws = ws; |
| 168 | this.state = "open"; | ||
| 113 | this.reconnectAttempt = 0; | 169 | this.reconnectAttempt = 0; |
| 170 | this.flushQueue(); | ||
| 114 | }); | 171 | }); |
| 115 | 172 | ||
| 116 | ws.addEventListener("message", (event) => { | 173 | ws.addEventListener("message", (event) => { |
| ... | @@ -118,34 +175,37 @@ export class WsTransport { | ... | @@ -118,34 +175,37 @@ export class WsTransport { |
| 118 | }); | 175 | }); |
| 119 | 176 | ||
| 120 | ws.addEventListener("close", () => { | 177 | ws.addEventListener("close", () => { |
| 121 | this.ws = null; | 178 | if (this.ws === ws) { |
| 179 | this.ws = null; | ||
| 180 | } | ||
| 181 | if (this.disposed) { | ||
| 182 | this.state = "disposed"; | ||
| 183 | return; | ||
| 184 | } | ||
| 185 | this.state = "closed"; | ||
| 122 | this.scheduleReconnect(); | 186 | this.scheduleReconnect(); |
| 123 | }); | 187 | }); |
| 124 | 188 | ||
| 125 | ws.addEventListener("error", () => { | 189 | ws.addEventListener("error", () => { |
| 126 | // close event will fire after error | 190 | // close will follow |
| 127 | }); | 191 | }); |
| 128 | } | 192 | } |
| 129 | 193 | ||
| 130 | private handleMessage(raw: unknown) { | 194 | private handleMessage(raw: unknown) { |
| 131 | const exit = decodeWsResponseFromJson(raw); | 195 | const result = decodeWsResponse(raw); |
| 132 | if (exit._tag === "Failure") { | 196 | if (Result.isFailure(result)) { |
| 133 | console.warn("Dropped inbound WebSocket envelope", { | 197 | console.warn("Dropped inbound WebSocket envelope", formatSchemaError(result.failure)); |
| 134 | reason: "decode-failed", | ||
| 135 | raw, | ||
| 136 | issue: Cause.pretty(exit.cause), | ||
| 137 | }); | ||
| 138 | return; | 198 | return; |
| 139 | } | 199 | } |
| 140 | const message = exit.value; | ||
| 141 | 200 | ||
| 142 | // Push event | 201 | const message = result.success; |
| 143 | if (isWsPushEnvelope(message)) { | 202 | if (isWsPushMessage(message)) { |
| 203 | this.latestPushByChannel.set(message.channel, message); | ||
| 144 | const channelListeners = this.listeners.get(message.channel); | 204 | const channelListeners = this.listeners.get(message.channel); |
| 145 | if (channelListeners) { | 205 | if (channelListeners) { |
| 146 | for (const listener of channelListeners) { | 206 | for (const listener of channelListeners) { |
| 147 | try { | 207 | try { |
| 148 | listener(message.data); | 208 | listener(message); |
| 149 | } catch { | 209 | } catch { |
| 150 | // Swallow listener errors | 210 | // Swallow listener errors |
| 151 | } | 211 | } |
| ... | @@ -154,57 +214,68 @@ export class WsTransport { | ... | @@ -154,57 +214,68 @@ export class WsTransport { |
| 154 | return; | 214 | return; |
| 155 | } | 215 | } |
| 156 | 216 | ||
| 157 | // Response to a request | ||
| 158 | if (!isWebSocketResponseEnvelope(message)) { | 217 | if (!isWebSocketResponseEnvelope(message)) { |
| 159 | return; | 218 | return; |
| 160 | } | 219 | } |
| 161 | 220 | ||
| 162 | const pending = this.pending.get(message.id); | 221 | const pending = this.pending.get(message.id); |
| 163 | if (!pending) return; | 222 | if (!pending) { |
| 223 | return; | ||
| 224 | } | ||
| 164 | 225 | ||
| 165 | clearTimeout(pending.timeout); | 226 | clearTimeout(pending.timeout); |
| 166 | this.pending.delete(message.id); | 227 | this.pending.delete(message.id); |
| 167 | 228 | ||
| 168 | if (message.error) { | 229 | if (message.error) { |
| 169 | pending.reject(new Error(message.error.message)); | 230 | pending.reject(new Error(message.error.message)); |
| 170 | } else { | 231 | return; |
| 171 | pending.resolve(message.result); | ||
| 172 | } | 232 | } |
| 233 | |||
| 234 | pending.resolve(message.result); | ||
| 173 | } | 235 | } |
| 174 | 236 | ||
| 175 | private send(message: WsRequestEnvelope) { | 237 | private send(encodedMessage: string) { |
| 176 | if (this.ws?.readyState === WebSocket.OPEN) { | 238 | if (this.disposed) { |
| 177 | this.ws.send(JSON.stringify(message)); | ||
| 178 | return; | 239 | return; |
| 179 | } | 240 | } |
| 180 | 241 | ||
| 181 | // If not connected, wait for connection | 242 | this.outboundQueue.push(encodedMessage); |
| 182 | const waitForOpen = () => { | 243 | try { |
| 183 | const check = setInterval(() => { | 244 | this.flushQueue(); |
| 184 | if (this.disposed) { | 245 | } catch { |
| 185 | clearInterval(check); | 246 | // Swallow: flushQueue has queued the message for retry on reconnect |
| 186 | return; | 247 | } |
| 187 | } | 248 | } |
| 188 | if (this.ws?.readyState === WebSocket.OPEN) { | ||
| 189 | clearInterval(check); | ||
| 190 | this.ws.send(JSON.stringify(message)); | ||
| 191 | } | ||
| 192 | }, 50); | ||
| 193 | 249 | ||
| 194 | // Give up after timeout (the pending request will time out on its own) | 250 | private flushQueue() { |
| 195 | setTimeout(() => clearInterval(check), REQUEST_TIMEOUT_MS); | 251 | if (this.ws?.readyState !== WebSocket.OPEN) { |
| 196 | }; | 252 | return; |
| 197 | waitForOpen(); | 253 | } |
| 254 | |||
| 255 | while (this.outboundQueue.length > 0) { | ||
| 256 | const message = this.outboundQueue.shift(); | ||
| 257 | if (!message) { | ||
| 258 | continue; | ||
| 259 | } | ||
| 260 | try { | ||
| 261 | this.ws.send(message); | ||
| 262 | } catch (error) { | ||
| 263 | this.outboundQueue.unshift(message); | ||
| 264 | throw asError(error, "Failed to send WebSocket request."); | ||
| 265 | } | ||
| 266 | } | ||
| 198 | } | 267 | } |
| 199 | 268 | ||
| 200 | private scheduleReconnect() { | 269 | private scheduleReconnect() { |
| 201 | if (this.disposed) return; | 270 | if (this.disposed || this.reconnectTimer !== null) { |
| 271 | return; | ||
| 272 | } | ||
| 202 | 273 | ||
| 203 | const delay = | 274 | const delay = |
| 204 | RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? | 275 | RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? |
| 205 | RECONNECT_DELAYS_MS[0]!; | 276 | RECONNECT_DELAYS_MS[0]!; |
| 206 | 277 | ||
| 207 | this.reconnectAttempt++; | 278 | this.reconnectAttempt += 1; |
| 208 | this.reconnectTimer = setTimeout(() => { | 279 | this.reconnectTimer = setTimeout(() => { |
| 209 | this.reconnectTimer = null; | 280 | this.reconnectTimer = null; |
| 210 | this.connect(); | 281 | this.connect(); |
apps/web/vitest.browser.config.ts+4-1| ... | @@ -15,7 +15,10 @@ export default mergeConfig( | ... | @@ -15,7 +15,10 @@ export default mergeConfig( |
| 15 | }, | 15 | }, |
| 16 | }, | 16 | }, |
| 17 | test: { | 17 | test: { |
| 18 | include: ["src/components/ChatView.browser.tsx"], | 18 | include: [ |
| 19 | "src/components/ChatView.browser.tsx", | ||
| 20 | "src/components/KeybindingsToast.browser.tsx", | ||
| 21 | ], | ||
| 19 | browser: { | 22 | browser: { |
| 20 | enabled: true, | 23 | enabled: true, |
| 21 | provider: playwright(), | 24 | provider: playwright(), |
packages/contracts/src/ws.test.ts+43-3| ... | @@ -1,11 +1,11 @@ | ... | @@ -1,11 +1,11 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import { Effect, Schema } from "effect"; | 2 | import { Effect, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { ORCHESTRATION_WS_METHODS } from "./orchestration"; | 4 | import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS } from "./orchestration"; |
| 5 | import { WS_METHODS } from "./ws"; | 5 | import { WebSocketRequest, WsResponse, WS_CHANNELS, WS_METHODS } from "./ws"; |
| 6 | import { WebSocketRequest } from "./ws"; | ||
| 7 | 6 | ||
| 8 | const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); | 7 | const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest); |
| 8 | const decodeWsResponse = Schema.decodeUnknownEffect(WsResponse); | ||
| 9 | 9 | ||
| 10 | it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () => | 10 | it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () => |
| 11 | Effect.gen(function* () { | 11 | Effect.gen(function* () { |
| ... | @@ -72,3 +72,43 @@ it.effect("accepts git.preparePullRequestThread requests", () => | ... | @@ -72,3 +72,43 @@ it.effect("accepts git.preparePullRequestThread requests", () => |
| 72 | assert.strictEqual(parsed.body._tag, WS_METHODS.gitPreparePullRequestThread); | 72 | assert.strictEqual(parsed.body._tag, WS_METHODS.gitPreparePullRequestThread); |
| 73 | }), | 73 | }), |
| 74 | ); | 74 | ); |
| 75 | |||
| 76 | it.effect("accepts typed websocket push envelopes with sequence", () => | ||
| 77 | Effect.gen(function* () { | ||
| 78 | const parsed = yield* decodeWsResponse({ | ||
| 79 | type: "push", | ||
| 80 | sequence: 1, | ||
| 81 | channel: WS_CHANNELS.serverWelcome, | ||
| 82 | data: { | ||
| 83 | cwd: "/tmp/workspace", | ||
| 84 | projectName: "workspace", | ||
| 85 | }, | ||
| 86 | }); | ||
| 87 | |||
| 88 | if (!("type" in parsed) || parsed.type !== "push") { | ||
| 89 | assert.fail("expected websocket response to decode as a push envelope"); | ||
| 90 | } | ||
| 91 | |||
| 92 | assert.strictEqual(parsed.type, "push"); | ||
| 93 | assert.strictEqual(parsed.sequence, 1); | ||
| 94 | assert.strictEqual(parsed.channel, WS_CHANNELS.serverWelcome); | ||
| 95 | }), | ||
| 96 | ); | ||
| 97 | |||
| 98 | it.effect("rejects push envelopes when channel payload does not match the channel schema", () => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const result = yield* Effect.exit( | ||
| 101 | decodeWsResponse({ | ||
| 102 | type: "push", | ||
| 103 | sequence: 2, | ||
| 104 | channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | ||
| 105 | data: { | ||
| 106 | cwd: "/tmp/workspace", | ||
| 107 | projectName: "workspace", | ||
| 108 | }, | ||
| 109 | }), | ||
| 110 | ); | ||
| 111 | |||
| 112 | assert.strictEqual(result._tag, "Failure"); | ||
| 113 | }), | ||
| 114 | ); |
packages/contracts/src/ws.ts+70-14| ... | @@ -1,8 +1,10 @@ | ... | @@ -1,8 +1,10 @@ |
| 1 | import { Schema, Struct } from "effect"; | 1 | import { Schema, Struct } from "effect"; |
| 2 | import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; | 2 | import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | ClientOrchestrationCommand, | 5 | ClientOrchestrationCommand, |
| 6 | OrchestrationEvent, | ||
| 7 | ORCHESTRATION_WS_CHANNELS, | ||
| 6 | OrchestrationGetFullThreadDiffInput, | 8 | OrchestrationGetFullThreadDiffInput, |
| 7 | ORCHESTRATION_WS_METHODS, | 9 | ORCHESTRATION_WS_METHODS, |
| 8 | OrchestrationGetSnapshotInput, | 10 | OrchestrationGetSnapshotInput, |
| ... | @@ -25,6 +27,7 @@ import { | ... | @@ -25,6 +27,7 @@ import { |
| 25 | import { | 27 | import { |
| 26 | TerminalClearInput, | 28 | TerminalClearInput, |
| 27 | TerminalCloseInput, | 29 | TerminalCloseInput, |
| 30 | TerminalEvent, | ||
| 28 | TerminalOpenInput, | 31 | TerminalOpenInput, |
| 29 | TerminalResizeInput, | 32 | TerminalResizeInput, |
| 30 | TerminalRestartInput, | 33 | TerminalRestartInput, |
| ... | @@ -33,6 +36,7 @@ import { | ... | @@ -33,6 +36,7 @@ import { |
| 33 | import { KeybindingRule } from "./keybindings"; | 36 | import { KeybindingRule } from "./keybindings"; |
| 34 | import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; | 37 | import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; |
| 35 | import { OpenInEditorInput } from "./editor"; | 38 | import { OpenInEditorInput } from "./editor"; |
| 39 | import { ServerConfigUpdatedPayload } from "./server"; | ||
| 36 | 40 | ||
| 37 | // ── WebSocket RPC Method Names ─────────────────────────────────────── | 41 | // ── WebSocket RPC Method Names ─────────────────────────────────────── |
| 38 | 42 | ||
| ... | @@ -154,19 +158,8 @@ export const WebSocketResponse = Schema.Struct({ | ... | @@ -154,19 +158,8 @@ export const WebSocketResponse = Schema.Struct({ |
| 154 | }); | 158 | }); |
| 155 | export type WebSocketResponse = typeof WebSocketResponse.Type; | 159 | export type WebSocketResponse = typeof WebSocketResponse.Type; |
| 156 | 160 | ||
| 157 | export const WsPush = Schema.Struct({ | 161 | export const WsPushSequence = NonNegativeInt; |
| 158 | type: Schema.Literal("push"), | 162 | export type WsPushSequence = typeof WsPushSequence.Type; |
| 159 | channel: TrimmedNonEmptyString, | ||
| 160 | data: Schema.Unknown, | ||
| 161 | }); | ||
| 162 | export type WsPush = typeof WsPush.Type; | ||
| 163 | |||
| 164 | // ── Union of all server → client messages ───────────────────────────── | ||
| 165 | |||
| 166 | export const WsResponse = Schema.Union([WebSocketResponse, WsPush]); | ||
| 167 | export type WsResponse = typeof WsResponse.Type; | ||
| 168 | |||
| 169 | // ── Server welcome payload ─────────────────────────────────────────── | ||
| 170 | 163 | ||
| 171 | export const WsWelcomePayload = Schema.Struct({ | 164 | export const WsWelcomePayload = Schema.Struct({ |
| 172 | cwd: TrimmedNonEmptyString, | 165 | cwd: TrimmedNonEmptyString, |
| ... | @@ -175,3 +168,66 @@ export const WsWelcomePayload = Schema.Struct({ | ... | @@ -175,3 +168,66 @@ export const WsWelcomePayload = Schema.Struct({ |
| 175 | bootstrapThreadId: Schema.optional(ThreadId), | 168 | bootstrapThreadId: Schema.optional(ThreadId), |
| 176 | }); | 169 | }); |
| 177 | export type WsWelcomePayload = typeof WsWelcomePayload.Type; | 170 | export type WsWelcomePayload = typeof WsWelcomePayload.Type; |
| 171 | |||
| 172 | export interface WsPushPayloadByChannel { | ||
| 173 | readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; | ||
| 174 | readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; | ||
| 175 | readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; | ||
| 176 | readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; | ||
| 177 | } | ||
| 178 | |||
| 179 | export type WsPushChannel = keyof WsPushPayloadByChannel; | ||
| 180 | export type WsPushData<C extends WsPushChannel> = WsPushPayloadByChannel[C]; | ||
| 181 | |||
| 182 | const makeWsPushSchema = <const Channel extends string, Payload extends Schema.Schema<any>>( | ||
| 183 | channel: Channel, | ||
| 184 | payload: Payload, | ||
| 185 | ) => | ||
| 186 | Schema.Struct({ | ||
| 187 | type: Schema.Literal("push"), | ||
| 188 | sequence: WsPushSequence, | ||
| 189 | channel: Schema.Literal(channel), | ||
| 190 | data: payload, | ||
| 191 | }); | ||
| 192 | |||
| 193 | export const WsPushServerWelcome = makeWsPushSchema(WS_CHANNELS.serverWelcome, WsWelcomePayload); | ||
| 194 | export const WsPushServerConfigUpdated = makeWsPushSchema( | ||
| 195 | WS_CHANNELS.serverConfigUpdated, | ||
| 196 | ServerConfigUpdatedPayload, | ||
| 197 | ); | ||
| 198 | export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent); | ||
| 199 | export const WsPushOrchestrationDomainEvent = makeWsPushSchema( | ||
| 200 | ORCHESTRATION_WS_CHANNELS.domainEvent, | ||
| 201 | OrchestrationEvent, | ||
| 202 | ); | ||
| 203 | |||
| 204 | export const WsPushChannelSchema = Schema.Literals([ | ||
| 205 | WS_CHANNELS.serverWelcome, | ||
| 206 | WS_CHANNELS.serverConfigUpdated, | ||
| 207 | WS_CHANNELS.terminalEvent, | ||
| 208 | ORCHESTRATION_WS_CHANNELS.domainEvent, | ||
| 209 | ]); | ||
| 210 | export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; | ||
| 211 | |||
| 212 | export const WsPush = Schema.Union([ | ||
| 213 | WsPushServerWelcome, | ||
| 214 | WsPushServerConfigUpdated, | ||
| 215 | WsPushTerminalEvent, | ||
| 216 | WsPushOrchestrationDomainEvent, | ||
| 217 | ]); | ||
| 218 | export type WsPush = typeof WsPush.Type; | ||
| 219 | |||
| 220 | export type WsPushMessage<C extends WsPushChannel> = Extract<WsPush, { channel: C }>; | ||
| 221 | |||
| 222 | export const WsPushEnvelopeBase = Schema.Struct({ | ||
| 223 | type: Schema.Literal("push"), | ||
| 224 | sequence: WsPushSequence, | ||
| 225 | channel: WsPushChannelSchema, | ||
| 226 | data: Schema.Unknown, | ||
| 227 | }); | ||
| 228 | export type WsPushEnvelopeBase = typeof WsPushEnvelopeBase.Type; | ||
| 229 | |||
| 230 | // ── Union of all server → client messages ───────────────────────────── | ||
| 231 | |||
| 232 | export const WsResponse = Schema.Union([WebSocketResponse, WsPush]); | ||
| 233 | export type WsResponse = typeof WsResponse.Type; |
packages/shared/package.json+8| ... | @@ -23,6 +23,14 @@ | ... | @@ -23,6 +23,14 @@ |
| 23 | "./Net": { | 23 | "./Net": { |
| 24 | "types": "./src/Net.ts", | 24 | "types": "./src/Net.ts", |
| 25 | "import": "./src/Net.ts" | 25 | "import": "./src/Net.ts" |
| 26 | }, | ||
| 27 | "./DrainableWorker": { | ||
| 28 | "types": "./src/DrainableWorker.ts", | ||
| 29 | "import": "./src/DrainableWorker.ts" | ||
| 30 | }, | ||
| 31 | "./schemaJson": { | ||
| 32 | "types": "./src/schemaJson.ts", | ||
| 33 | "import": "./src/schemaJson.ts" | ||
| 26 | } | 34 | } |
| 27 | }, | 35 | }, |
| 28 | "scripts": { | 36 | "scripts": { |
packages/shared/src/DrainableWorker.test.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | import { it } from "@effect/vitest"; | ||
| 2 | import { describe, expect } from "vitest"; | ||
| 3 | import { Deferred, Effect } from "effect"; | ||
| 4 | |||
| 5 | import { makeDrainableWorker } from "./DrainableWorker"; | ||
| 6 | |||
| 7 | describe("makeDrainableWorker", () => { | ||
| 8 | it.live("waits for work enqueued during active processing before draining", () => | ||
| 9 | Effect.scoped( | ||
| 10 | Effect.gen(function* () { | ||
| 11 | const processed: string[] = []; | ||
| 12 | const firstStarted = yield* Deferred.make<void>(); | ||
| 13 | const releaseFirst = yield* Deferred.make<void>(); | ||
| 14 | const secondStarted = yield* Deferred.make<void>(); | ||
| 15 | const releaseSecond = yield* Deferred.make<void>(); | ||
| 16 | |||
| 17 | const worker = yield* makeDrainableWorker((item: string) => | ||
| 18 | Effect.gen(function* () { | ||
| 19 | if (item === "first") { | ||
| 20 | yield* Deferred.succeed(firstStarted, undefined).pipe(Effect.orDie); | ||
| 21 | yield* Deferred.await(releaseFirst); | ||
| 22 | } | ||
| 23 | |||
| 24 | if (item === "second") { | ||
| 25 | yield* Deferred.succeed(secondStarted, undefined).pipe(Effect.orDie); | ||
| 26 | yield* Deferred.await(releaseSecond); | ||
| 27 | } | ||
| 28 | |||
| 29 | processed.push(item); | ||
| 30 | }), | ||
| 31 | ); | ||
| 32 | |||
| 33 | yield* worker.enqueue("first"); | ||
| 34 | yield* Deferred.await(firstStarted); | ||
| 35 | |||
| 36 | const drained = yield* Deferred.make<void>(); | ||
| 37 | yield* Effect.forkChild( | ||
| 38 | worker.drain.pipe( | ||
| 39 | Effect.tap(() => Deferred.succeed(drained, undefined).pipe(Effect.orDie)), | ||
| 40 | ), | ||
| 41 | ); | ||
| 42 | |||
| 43 | yield* worker.enqueue("second"); | ||
| 44 | yield* Deferred.succeed(releaseFirst, undefined); | ||
| 45 | yield* Deferred.await(secondStarted); | ||
| 46 | |||
| 47 | expect(yield* Deferred.isDone(drained)).toBe(false); | ||
| 48 | |||
| 49 | yield* Deferred.succeed(releaseSecond, undefined); | ||
| 50 | yield* Deferred.await(drained); | ||
| 51 | |||
| 52 | expect(processed).toEqual(["first", "second"]); | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | ); | ||
| 56 | }); | ||
packages/shared/src/DrainableWorker.ts created+101| ... | @@ -0,0 +1,101 @@ | ||
| 1 | /** | ||
| 2 | * DrainableWorker - A queue-based worker that exposes a `drain()` effect. | ||
| 3 | * | ||
| 4 | * Wraps the common `Queue.unbounded` + `Effect.forever` pattern and adds | ||
| 5 | * a signal that resolves when the queue is empty **and** the current item | ||
| 6 | * has finished processing. This lets tests replace timing-sensitive | ||
| 7 | * `Effect.sleep` calls with deterministic `drain()`. | ||
| 8 | * | ||
| 9 | * @module DrainableWorker | ||
| 10 | */ | ||
| 11 | import { Deferred, Effect, Queue, Ref } from "effect"; | ||
| 12 | import type { Scope } from "effect"; | ||
| 13 | |||
| 14 | export interface DrainableWorker<A> { | ||
| 15 | /** | ||
| 16 | * Enqueue a work item and track it for `drain()`. | ||
| 17 | * | ||
| 18 | * This wraps `Queue.offer` so drain state is updated atomically with the | ||
| 19 | * enqueue path instead of inferring it from queue internals. | ||
| 20 | */ | ||
| 21 | readonly enqueue: (item: A) => Effect.Effect<void>; | ||
| 22 | |||
| 23 | /** | ||
| 24 | * Resolves when the queue is empty and the worker is idle (not processing). | ||
| 25 | */ | ||
| 26 | readonly drain: Effect.Effect<void>; | ||
| 27 | } | ||
| 28 | |||
| 29 | /** | ||
| 30 | * Create a drainable worker that processes items from an unbounded queue. | ||
| 31 | * | ||
| 32 | * The worker is forked into the current scope and will be interrupted when | ||
| 33 | * the scope closes. A finalizer shuts down the queue. | ||
| 34 | * | ||
| 35 | * @param process - The effect to run for each queued item. | ||
| 36 | * @returns A `DrainableWorker` with `queue` and `drain`. | ||
| 37 | */ | ||
| 38 | export const makeDrainableWorker = <A, E, R>( | ||
| 39 | process: (item: A) => Effect.Effect<void, E, R>, | ||
| 40 | ): Effect.Effect<DrainableWorker<A>, never, Scope.Scope | R> => | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const queue = yield* Queue.unbounded<A>(); | ||
| 43 | const initialIdle = yield* Deferred.make<void>(); | ||
| 44 | yield* Deferred.succeed(initialIdle, undefined).pipe(Effect.orDie); | ||
| 45 | const state = yield* Ref.make({ | ||
| 46 | outstanding: 0, | ||
| 47 | idle: initialIdle, | ||
| 48 | }); | ||
| 49 | |||
| 50 | yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); | ||
| 51 | |||
| 52 | const finishOne = Ref.modify(state, (current) => { | ||
| 53 | const remaining = Math.max(0, current.outstanding - 1); | ||
| 54 | return [ | ||
| 55 | remaining === 0 ? current.idle : null, | ||
| 56 | { | ||
| 57 | outstanding: remaining, | ||
| 58 | idle: current.idle, | ||
| 59 | }, | ||
| 60 | ] as const; | ||
| 61 | }).pipe( | ||
| 62 | Effect.flatMap((idle) => | ||
| 63 | idle === null ? Effect.void : Deferred.succeed(idle, undefined).pipe(Effect.orDie), | ||
| 64 | ), | ||
| 65 | ); | ||
| 66 | |||
| 67 | yield* Effect.forkScoped( | ||
| 68 | Effect.forever( | ||
| 69 | Queue.take(queue).pipe( | ||
| 70 | Effect.flatMap((item) => process(item).pipe(Effect.ensuring(finishOne))), | ||
| 71 | ), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | |||
| 75 | const enqueue: DrainableWorker<A>["enqueue"] = (item) => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const nextIdle = yield* Deferred.make<void>(); | ||
| 78 | yield* Ref.update(state, (current) => | ||
| 79 | current.outstanding === 0 | ||
| 80 | ? { | ||
| 81 | outstanding: 1, | ||
| 82 | idle: nextIdle, | ||
| 83 | } | ||
| 84 | : { | ||
| 85 | outstanding: current.outstanding + 1, | ||
| 86 | idle: current.idle, | ||
| 87 | }, | ||
| 88 | ); | ||
| 89 | |||
| 90 | const accepted = yield* Queue.offer(queue, item); | ||
| 91 | if (!accepted) { | ||
| 92 | yield* finishOne; | ||
| 93 | } | ||
| 94 | }); | ||
| 95 | |||
| 96 | const drain: DrainableWorker<A>["drain"] = Ref.get(state).pipe( | ||
| 97 | Effect.flatMap(({ idle }) => Deferred.await(idle)), | ||
| 98 | ); | ||
| 99 | |||
| 100 | return { enqueue, drain } satisfies DrainableWorker<A>; | ||
| 101 | }); | ||
packages/shared/src/schemaJson.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import { Cause, Exit, Result, Schema, SchemaIssue } from "effect"; | ||
| 2 | |||
| 3 | export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>( | ||
| 4 | schema: S, | ||
| 5 | ) => { | ||
| 6 | const decode = Schema.decodeExit(Schema.fromJsonString(schema)); | ||
| 7 | return (input: string) => { | ||
| 8 | const result = decode(input); | ||
| 9 | if (Exit.isFailure(result)) { | ||
| 10 | return Result.fail(result.cause); | ||
| 11 | } | ||
| 12 | return Result.succeed(result.value); | ||
| 13 | }; | ||
| 14 | }; | ||
| 15 | |||
| 16 | export const decodeUnknownJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>( | ||
| 17 | schema: S, | ||
| 18 | ) => { | ||
| 19 | const decode = Schema.decodeUnknownExit(Schema.fromJsonString(schema)); | ||
| 20 | return (input: unknown) => { | ||
| 21 | const result = decode(input); | ||
| 22 | if (Exit.isFailure(result)) { | ||
| 23 | return Result.fail(result.cause); | ||
| 24 | } | ||
| 25 | return Result.succeed(result.value); | ||
| 26 | }; | ||
| 27 | }; | ||
| 28 | |||
| 29 | export const formatSchemaError = (cause: Cause.Cause<Schema.SchemaError>) => { | ||
| 30 | const squashed = Cause.squash(cause); | ||
| 31 | return Schema.isSchemaError(squashed) | ||
| 32 | ? SchemaIssue.makeFormatterDefault()(squashed.issue) | ||
| 33 | : Cause.pretty(cause); | ||
| 34 | }; | ||