authorgravatar for jason.laster.11@gmail.comJason Laster <jason.laster.11@gmail.com> 2026-03-10 15:54:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-10 15:54:34-07:00
log7d115334902046e42e843779f91e275ff1c96a27
treece2d86f4db4d05de06624138020529b25c48f12c
parent1031a226f8cf90d358d9768fc0842dcdae61a315
signature Signed by PGP key B5690EEEBB952194

Stabilize runtime orchestration and fix flaky CI tests (#488)

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:377311 │ 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 stdio22 │ 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
45sequenceDiagram
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
631. The browser boots [`WsTransport`][1] and registers typed listeners in [`wsNativeApi`][2].
642. The server accepts the connection in [`wsServer`][3] and brings up the runtime graph defined in [`serverLayers`][7].
653. [`ServerReadiness`][4] waits until the key startup barriers are complete.
664. Once the server is ready, [`wsServer`][3] sends `server.welcome` from the contracts in [`ws.ts`][6] through [`ServerPushBus`][5].
675. 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
72sequenceDiagram
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
931. A user action in the browser becomes a typed request through [`WsTransport`][1] and the browser API layer in [`nativeApi`][12].
942. [`wsServer`][3] decodes that request using the shared WebSocket contracts in [`ws.ts`][6] and routes it to the right service.
953. [`ProviderService`][8] starts or resumes a session and talks to `codex app-server` over JSON-RPC on stdio.
964. Provider-native events are pulled back into the server by [`ProviderRuntimeIngestion`][9], which converts them into orchestration events.
975. [`OrchestrationEngine`][10] persists those events, updates the read model, and exposes them as domain events.
986. [`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
103sequenceDiagram
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
1221. Some work continues after the initial request returns, especially in [`ProviderRuntimeIngestion`][9], [`ProviderCommandReactor`][13], and [`CheckpointReactor`][14].
1232. These flows run as queue-backed workers using [`DrainableWorker`][16], which helps keep side effects ordered and test synchronization deterministic.
1243. When a milestone completes, the server emits a typed receipt on [`RuntimeReceiptBus`][15], such as checkpoint completion or turn quiescence.
1254. Tests and orchestration code wait on those receipts instead of polling git state, projections, or timers.
1265. 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 @@
3The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol:3The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol:
44
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 updates6- **Push events**: typed envelopes with `channel`, `sequence` (monotonic per connection), and channel-specific `data`
7
8Push 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.
79
8Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`:10Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`:
911
...@@ -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`
1315
14Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI.16Codex 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
24Provider runtime events flow through queue-based workers:
25
261. **ProviderRuntimeIngestion** — consumes provider runtime streams, emits orchestration commands
272. **ProviderCommandReactor** — reacts to orchestration intent events, dispatches provider calls
283. **CheckpointReactor** — captures git checkpoints on turn start/complete, publishes runtime receipts
29
30All 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]
2node = "24.13.1"
3bun = "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
4Replace 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
8All 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
45import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";46import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
46import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";47import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
47import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";48import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
49import { RuntimeReceiptBusLive } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
48import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";50import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
49import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";51import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
50import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts";52import { 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";
55import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";57import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
56import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";58import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
59import {
60 RuntimeReceiptBus,
61 type OrchestrationRuntimeReceipt,
62} from "../src/orchestration/Services/RuntimeReceiptBus.ts";
5763
58import {64import {
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 never192 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}
190206
...@@ -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);
325345
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);
331355
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 never422 never
399 >;423 >;
400424
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";
23import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts";23import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts";
24import type {
25 CheckpointDiffFinalizedReceipt,
26 TurnProcessingQuiescedReceipt,
27} from "../src/orchestration/Services/RuntimeReceiptBus.ts";
2428
25const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);29const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
26const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);30const 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 );
190210
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, []);
210230
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 );
370394
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 );
411451
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* () {
654654
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 }));
658658
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* () {
13651365
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 });
13711371
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";
39import * as Semaphore from "effect/Semaphore";43import * as Semaphore from "effect/Semaphore";
...@@ -422,6 +426,7 @@ export interface KeybindingsConfigState {...@@ -422,6 +426,7 @@ export interface KeybindingsConfigState {
422}426}
423427
424export interface KeybindingsChangeEvent {428export interface KeybindingsChangeEvent {
429 readonly keybindings: ResolvedKeybindingsConfig;
425 readonly issues: readonly ServerConfigIssue[];430 readonly issues: readonly ServerConfigIssue[];
426}431}
427432
...@@ -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 */
470export interface KeybindingsShape {475export 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 default493 * 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>;
481502
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>;
486512
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>();
512538 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);
515544
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 );
682711
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 );
805801
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
22import { CheckpointReactorLive } from "./CheckpointReactor.ts";22import { CheckpointReactorLive } from "./CheckpointReactor.ts";
23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
24import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";24import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
25import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
25import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";26import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
26import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";27import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
27import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";28import { 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(
137async function waitForEvent(138async 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}
190191
191async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) {192async 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", () => {
252253
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);
268270
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 }
326329
...@@ -449,7 +452,7 @@ describe("CheckpointReactor", () => {...@@ -449,7 +452,7 @@ describe("CheckpointReactor", () => {
449 payload: { state: "completed" },452 payload: { state: "completed" },
450 });453 });
451454
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 });
645648
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";
10import { Cause, Effect, Layer, Option, Queue, Stream } from "effect";10import { Cause, Effect, Layer, Option, Stream } from "effect";
11import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1112
12import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";13import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
13import {14import {
...@@ -19,6 +20,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts...@@ -19,6 +20,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts
19import { ProviderService } from "../../provider/Services/ProviderService.ts";20import { ProviderService } from "../../provider/Services/ProviderService.ts";
20import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";21import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
21import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";22import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
23import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
22import { CheckpointStoreError } from "../../checkpointing/Errors.ts";24import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
23import { OrchestrationDispatchError } from "../Errors.ts";25import { OrchestrationDispatchError } from "../Errors.ts";
24import { isGitRepository } from "../../git/isRepo.ts";26import { 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;
6770
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 });
280299
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 });
354380
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 });
416449
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 );
605638
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 );
613640
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 );
626654
...@@ -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 });
636664
637 return {665 return {
638 start,666 start,
667 drain: worker.drain,
639 } satisfies CheckpointReactorShape;668 } satisfies CheckpointReactorShape;
640});669});
641670
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);
223223
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 }
264265
...@@ -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();
581582
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";
15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Schema, Stream } from "effect";15import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect";
16import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1617
17import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";18import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
18import { GitCore } from "../../git/Services/GitCore.ts";19import { 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 );
667666
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(
671670 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" &&
675674 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 }
688681
689 return Queue.offer(queue, event).pipe(Effect.asVoid);682 return worker.enqueue(event);
690 }),683 }),
691 );684 ).pipe(Effect.asVoid);
692 });
693685
694 return {686 return {
695 start,687 start,
688 drain: worker.drain,
696 } satisfies ProviderCommandReactorShape;689 } satisfies ProviderCommandReactorShape;
697});690});
698691
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);
163163
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 }
214215
...@@ -377,7 +378,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -377,7 +378,7 @@ describe("ProviderRuntimeIngestion", () => {
377 threadId: asThreadId("thread-1"),378 threadId: asThreadId("thread-1"),
378 });379 });
379380
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 });
432433
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 });
486487
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 });
727728
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();
788789
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";
13import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect";13import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect";
14import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1415
15import { ProviderService } from "../../provider/Services/ProviderService.ts";16import { ProviderService } from "../../provider/Services/ProviderService.ts";
16import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";17import { 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 );
11201121
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));
11241123
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 });
11421139
1143 return {1140 return {
1144 start,1141 start,
1142 drain: worker.drain,
1145 } satisfies ProviderRuntimeIngestionShape;1143 } satisfies ProviderRuntimeIngestionShape;
1146});1144});
11471145
apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts created+18
...@@ -0,0 +1,18 @@
1import { Effect, Layer, PubSub, Stream } from "effect";
2
3import {
4 RuntimeReceiptBus,
5 type RuntimeReceiptBusShape,
6 type OrchestrationRuntimeReceipt,
7} from "../Services/RuntimeReceiptBus.ts";
8
9const 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
18export 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}
2733
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}
2733
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}
2733
28/**34/**
apps/server/src/orchestration/Services/RuntimeReceiptBus.ts created+49
...@@ -0,0 +1,49 @@
1import { CheckpointRef, IsoDateTime, NonNegativeInt, ThreadId, TurnId } from "@t3tools/contracts";
2import { Schema, ServiceMap } from "effect";
3import type { Effect, Stream } from "effect";
4
5export const CheckpointBaselineCapturedReceipt = Schema.Struct({
6 type: Schema.Literal("checkpoint.baseline.captured"),
7 threadId: ThreadId,
8 checkpointTurnCount: NonNegativeInt,
9 checkpointRef: CheckpointRef,
10 createdAt: IsoDateTime,
11});
12export type CheckpointBaselineCapturedReceipt = typeof CheckpointBaselineCapturedReceipt.Type;
13
14export 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});
23export type CheckpointDiffFinalizedReceipt = typeof CheckpointDiffFinalizedReceipt.Type;
24
25export const TurnProcessingQuiescedReceipt = Schema.Struct({
26 type: Schema.Literal("turn.processing.quiesced"),
27 threadId: ThreadId,
28 turnId: TurnId,
29 checkpointTurnCount: NonNegativeInt,
30 createdAt: IsoDateTime,
31});
32export type TurnProcessingQuiescedReceipt = typeof TurnProcessingQuiescedReceipt.Type;
33
34export const OrchestrationRuntimeReceipt = Schema.Union([
35 CheckpointBaselineCapturedReceipt,
36 CheckpointDiffFinalizedReceipt,
37 TurnProcessingQuiescedReceipt,
38]);
39export type OrchestrationRuntimeReceipt = typeof OrchestrationRuntimeReceipt.Type;
40
41export interface RuntimeReceiptBusShape {
42 readonly publish: (receipt: OrchestrationRuntimeReceipt) => Effect.Effect<void>;
43 readonly stream: Stream.Stream<OrchestrationRuntimeReceipt>;
44}
45
46export 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 }
293293
294 yield* upsertSessionBinding(session, threadId, {294 yield* upsertSessionBinding(session, threadId, {
295 ...(input.providerOptions !== undefined295 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
17import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";17import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
18import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";18import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
19import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";19import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
20import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
20import { ProviderUnsupportedError } from "./provider/Errors";21import { ProviderUnsupportedError } from "./provider/Errors";
21import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";22import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
22import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";23import { 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";
32import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";34import { 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";
52import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";54import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
53import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";55import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
5456
55interface PendingMessages {
56 queue: unknown[];
57 waiters: Array<(message: unknown) => void>;
58}
59
60const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
61
62const asEventId = (value: string): EventId => EventId.makeUnsafe(value);57const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
63const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);58const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
64const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);59const 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}
216211
217function 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
223interface 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
232interface SocketChannels {
233 push: MessageChannel<WsPush>;
234 response: MessageChannel<WebSocketResponse>;
235}
236
237const channelsBySocket = new WeakMap<WebSocket, SocketChannels>();
238
239function 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
249function 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
269function 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
275function 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
283function 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);
223292
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 });
233304
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}
238309
239function waitForMessage(ws: WebSocket): Promise<unknown> {310async 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 }
244312
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 }
249323
250 return new Promise((resolve) => {324 throw lastError;
251 pending.waiters.push(resolve);
252 });
253}325}
254326
255function 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;328async 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}
262336
263async function sendRequest(337async 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.dispatchCommand347 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);
277353
278 // Wait for response with matching id354 // 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}
292362
293async function waitForPush(363async 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
382async 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}
314400
315async function requestPath(401async function requestPath(
...@@ -489,18 +575,15 @@ describe("WebSocket Server", () => {...@@ -489,18 +575,15 @@ describe("WebSocket Server", () => {
489575
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);
496581
497 const ws = await connectWs(port);582 const [ws, welcome] = await connectAndAwaitWelcome(port);
498 connections.push(ws);583 connections.push(ws);
499584
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);
591674
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);
668749
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);
692772
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);
719797
720 const ws = await connectWs(port);798 const [ws] = await connectAndAwaitWelcome(port);
721 connections.push(ws);799 connections.push(ws);
722 await waitForMessage(ws);
723800
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;
744821
745 const ws = await connectWs(port);822 const [ws] = await connectAndAwaitWelcome(port);
746 connections.push(ws);823 connections.push(ws);
747824
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;
772846
773 const ws = await connectWs(port);847 const [ws] = await connectAndAwaitWelcome(port);
774 connections.push(ws);848 connections.push(ws);
775 await waitForMessage(ws);
776849
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;
803876
804 const ws = await connectWs(port);877 const [ws] = await connectAndAwaitWelcome(port);
805 connections.push(ws);878 connections.push(ws);
806 await waitForMessage(ws);
807879
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;
843915
844 const ws = await connectWs(port);916 const [ws] = await connectAndAwaitWelcome(port);
845 connections.push(ws);917 connections.push(ws);
846 await waitForMessage(ws);
847918
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;
887958
888 const ws = await connectWs(port);959 const [ws] = await connectAndAwaitWelcome(port);
889 connections.push(ws);960 connections.push(ws);
890 await waitForMessage(ws);
891961
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 });
906975
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;
931998
932 const ws = await connectWs(port);999 const [ws] = await connectAndAwaitWelcome(port);
933 connections.push(ws);1000 connections.push(ws);
934 await waitForMessage(ws);
9351001
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;
9591025
960 const ws = await connectWs(port);1026 const [ws] = await connectAndAwaitWelcome(port);
961 connections.push(ws);1027 connections.push(ws);
9621028
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;
9931057
994 const ws = await connectWs(port);1058 const [ws] = await connectAndAwaitWelcome(port);
995 connections.push(ws);1059 connections.push(ws);
996 await waitForMessage(ws);
9971060
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;
10351098
1036 const ws = await connectWs(port);1099 const [ws] = await connectAndAwaitWelcome(port);
1037 connections.push(ws);1100 connections.push(ws);
10381101
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;
10511111
1052 const ws = await connectWs(port);1112 const [ws] = await connectAndAwaitWelcome(port);
1053 connections.push(ws);1113 connections.push(ws);
1054 await waitForMessage(ws);
10551114
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;
10691128
1070 const ws = await connectWs(port);1129 const [ws] = await connectAndAwaitWelcome(port);
1071 connections.push(ws);1130 connections.push(ws);
1072 await waitForMessage(ws);
10731131
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;
10891147
1090 const ws = await connectWs(port);1148 const [ws] = await connectAndAwaitWelcome(port);
1091 connections.push(ws);1149 connections.push(ws);
1092 await waitForMessage(ws);
10931150
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;
11061163
1107 const ws = await connectWs(port);1164 const [ws] = await connectAndAwaitWelcome(port);
1108 connections.push(ws);1165 connections.push(ws);
1109 await waitForMessage(ws);
11101166
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;
11851241
1186 const ws = await connectWs(port);1242 const [ws] = await connectAndAwaitWelcome(port);
1187 connections.push(ws);1243 connections.push(ws);
1188 await waitForMessage(ws);
11891244
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;
12781333
1279 const ws = await connectWs(port);1334 const [ws] = await connectAndAwaitWelcome(port);
1280 connections.push(ws);1335 connections.push(ws);
1281 await waitForMessage(ws);
12821336
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);
13331387
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 });
13371396
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;
13571416
1358 const ws = await connectWs(port);1417 const [ws] = await connectAndAwaitWelcome(port);
1359 connections.push(ws);1418 connections.push(ws);
1360 await waitForMessage(ws);
13611419
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;
13751433
1376 const ws = await connectWs(port);1434 const [ws] = await connectAndAwaitWelcome(port);
1377 connections.push(ws);1435 connections.push(ws);
13781436
1379 // Consume welcome
1380 await waitForMessage(ws);
1381
1382 // Send garbage1437 // Send garbage
1383 ws.send("not json at all");1438 ws.send("not json at all");
13841439
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;
14221476
1423 const ws = await connectWs(port);1477 const [ws] = await connectAndAwaitWelcome(port);
1424 connections.push(ws);1478 connections.push(ws);
1425 await waitForMessage(ws);
14261479
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;
14681521
1469 const ws = await connectWs(port);1522 const [ws] = await connectAndAwaitWelcome(port);
1470 connections.push(ws);1523 connections.push(ws);
1471 await waitForMessage(ws);
14721524
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;
15051557
1506 const ws = await connectWs(port);1558 const [ws] = await connectAndAwaitWelcome(port);
1507 connections.push(ws);1559 connections.push(ws);
1508 await waitForMessage(ws);
15091560
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;
15311582
1532 const ws = await connectWs(port);1583 const [ws] = await connectAndAwaitWelcome(port);
1533 connections.push(ws);1584 connections.push(ws);
1534 await waitForMessage(ws);
15351585
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;
15571607
1558 const ws = await connectWs(port);1608 const [ws] = await connectAndAwaitWelcome(port);
1559 connections.push(ws);1609 connections.push(ws);
1560 await waitForMessage(ws);
15611610
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;
16051654
1606 const ws = await connectWs(port);1655 const [ws] = await connectAndAwaitWelcome(port);
1607 connections.push(ws);1656 connections.push(ws);
1608 await waitForMessage(ws);
16091657
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;
16541702
1655 const ws = await connectWs(port);1703 const [ws] = await connectAndAwaitWelcome(port);
1656 connections.push(ws);1704 connections.push(ws);
1657 await waitForMessage(ws);
16581705
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;
16941741
1695 const ws = await connectWs(port);1742 const [ws] = await connectAndAwaitWelcome(port);
1696 connections.push(ws);1743 connections.push(ws);
1697 await waitForMessage(ws);
16981744
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;
17431789
1744 const ws = await connectWs(port);1790 const [ws] = await connectAndAwaitWelcome(port);
1745 connections.push(ws);1791 connections.push(ws);
1746 await waitForMessage(ws);
17471792
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", () => {
17641809
1765 await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");1810 await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");
17661811
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";
31import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";30import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
32import {31import {
...@@ -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
69import {70import {
70 createAttachmentId,71 createAttachmentId,
71 resolveAttachmentPath,72 resolveAttachmentPath,
...@@ -74,6 +75,9 @@ import {...@@ -74,6 +75,9 @@ import {
74import { parseBase64DataUrl } from "./imageMime.ts";75import { parseBase64DataUrl } from "./imageMime.ts";
75import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";76import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
76import { expandHomePath } from "./os-jank.ts";77import { expandHomePath } from "./os-jank.ts";
78import { makeServerPushBus } from "./wsServer/pushBus.ts";
79import { makeServerReadiness } from "./wsServer/readiness.ts";
80import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
7781
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}
197201
202const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse));
203const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest);
204
198export type ServerCoreRuntimeServices =205export type ServerCoreRuntimeServices =
199 | OrchestrationEngineService206 | OrchestrationEngineService
200 | ProjectionSnapshotQuery207 | 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 );
263270
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;
266276
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 }
275286
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;
296298
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));
607609
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));
632613
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));
643620
644 yield* Scope.provide(orchestrationReactor.start, subscriptionsScope);621 yield* Scope.provide(orchestrationReactor.start, subscriptionsScope);
622 yield* readiness.markOrchestrationSubscriptionsReady;
645623
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);
713691
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;
718697
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;
722702
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 );
733706
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 };
906879
...@@ -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 });
921894
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 );
924901
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 }
934909
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 }
944917
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 }
954925
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 });
962931
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 });
986955
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";
992959
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 );
1005976
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 });
1013980
1014 ws.on("close", () => {981 ws.on("close", () => {
apps/server/src/wsServer/pushBus.test.ts created+104
...@@ -0,0 +1,104 @@
1import type { WebSocket } from "ws";
2import { it } from "@effect/vitest";
3import { describe, expect } from "vitest";
4import { Effect, Ref } from "effect";
5import { WS_CHANNELS } from "@t3tools/contracts";
6
7import { makeServerPushBus } from "./pushBus";
8
9class 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
43describe("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 @@
1import {
2 WsPush,
3 type WsPushChannel,
4 type WsPushData,
5 type WsPushEnvelopeBase,
6} from "@t3tools/contracts";
7import { Deferred, Effect, Queue, Ref, Schema } from "effect";
8import type { Scope } from "effect";
9import type { WebSocket } from "ws";
10
11type PushTarget =
12 | { readonly kind: "all" }
13 | { readonly kind: "client"; readonly client: WebSocket };
14
15interface 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
22export 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
34export 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 @@
1import { Deferred, Effect } from "effect";
2
3export 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
12export 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 @@
1import { defineConfig, mergeConfig } from "vitest/config";
2
3import baseConfig from "../../vitest.config";
4
5export 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";
25import { estimateTimelineMessageHeight } from "./timelineHeight";25import { estimateTimelineMessageHeight } from "./timelineHeight";
2626
27const THREAD_ID = "thread-browser-test" as ThreadId;27const THREAD_ID = "thread-browser-test" as ThreadId;
28const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
28const PROJECT_ID = "project-1" as ProjectId;29const PROJECT_ID = "project-1" as ProjectId;
29const NOW_ISO = "2026-03-04T12:00:00.000Z";30const NOW_ISO = "2026-03-04T12:00:00.000Z";
30const BASE_TIME_MS = Date.parse(NOW_ISO);31const 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}
8991
90function isoAt(offsetSeconds: number): string {92function 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}
247249
250function 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
248function createDraftOnlySnapshot(): OrchestrationReadModel {290function 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}
450493
494async 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
451async function waitForComposerEditor(): Promise<HTMLElement> {510async 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}
598658
...@@ -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 });
936996
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.orchestration2792 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 @@
1import "../index.css";
2
3import {
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";
14import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
15import { ws, http, HttpResponse } from "msw";
16import { setupWorker } from "msw/browser";
17import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
18import { render } from "vitest-browser-react";
19
20import { useComposerDraftStore } from "../composerDraftStore";
21import { getRouter } from "../router";
22import { useStore } from "../store";
23
24const THREAD_ID = "thread-kb-toast-test" as ThreadId;
25const PROJECT_ID = "project-1" as ProjectId;
26const NOW_ISO = "2026-03-04T12:00:00.000Z";
27
28interface TestFixture {
29 snapshot: OrchestrationReadModel;
30 serverConfig: ServerConfig;
31 welcome: WsWelcomePayload;
32}
33
34let fixture: TestFixture;
35let wsClient: { send: (data: string) => void } | null = null;
36let pushSequence = 1;
37
38const wsLink = ws.link(/ws(s)?:\/\/.*/);
39
40function 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
59function 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
117function 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
130function 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
161const 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
196function 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
211function queryToastTitles(): string[] {
212 return Array.from(document.querySelectorAll('[data-slot="toast-title"]')).map(
213 (el) => el.textContent ?? "",
214 );
215}
216
217async 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
232async function waitForComposerEditor(): Promise<HTMLElement> {
233 return waitForElement(
234 () => document.querySelector<HTMLElement>('[data-testid="composer-editor"]'),
235 "App should render composer editor",
236 );
237}
238
239async 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
249async 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
258async 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
281describe("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 <button1477 <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 showOnHover1483 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>()(
1279export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState {1279export 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 */
1290export 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";
15import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";15import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
16import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";16import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
17import { readNativeApi } from "../nativeApi";17import { readNativeApi } from "../nativeApi";
18import { useComposerDraftStore } from "../composerDraftStore";18import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore";
19import { useStore } from "../store";19import { useStore } from "../store";
20import { useTerminalStateStore } from "../terminalStateStore";20import { useTerminalStateStore } from "../terminalStateStore";
21import { preferredTerminalEditor } from "../terminal-links";21import { 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);
145144
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 @@
1import {1import {
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";
12import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";18import { 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 >();
22const channelListeners = new Map<string, Set<(data: unknown) => void>>();28const channelListeners = new Map<string, Set<(message: WsPush) => void>>();
23const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>(29const latestPushByChannel = new Map<string, WsPush>();
24 (channel, listener) => {30const 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});
3651
37vi.mock("./wsTransport", () => {52vi.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}));
4967
50function emitPush(channel: string, data: unknown): void {68let nextPushSequence = 1;
69
70function 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}
5784
...@@ -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});
86115
...@@ -90,7 +119,6 @@ afterEach(() => {...@@ -90,7 +119,6 @@ afterEach(() => {
90119
91describe("wsNativeApi", () => {120describe("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");
95123
96 createWsNativeApi();124 createWsNativeApi();
...@@ -108,7 +136,6 @@ describe("wsNativeApi", () => {...@@ -108,7 +136,6 @@ describe("wsNativeApi", () => {
108136
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 });
113140
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 });
127154
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 });
138165
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");
142168
143 createWsNativeApi();169 createWsNativeApi();
144 const listener = vi.fn();170 const listener = vi.fn();
145 onServerWelcome(listener);171 onServerWelcome(listener);
146172
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" });
149175
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 });
161184
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");
165187
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 });
191212
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");
195215
196 createWsNativeApi();216 createWsNativeApi();
...@@ -198,24 +218,22 @@ describe("wsNativeApi", () => {...@@ -198,24 +218,22 @@ describe("wsNativeApi", () => {
198 onServerConfigUpdated(listener);218 onServerConfigUpdated(listener);
199219
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 });
208228
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 });
216235
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");
220238
221 const api = createWsNativeApi();239 const api = createWsNativeApi();
...@@ -236,9 +254,9 @@ describe("wsNativeApi", () => {...@@ -236,9 +254,9 @@ describe("wsNativeApi", () => {
236254
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);
259277
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 });
313283
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 @@
1import {1import {
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";
13import { Cause, Schema } from "effect";
1411
15import { showContextMenuFallback } from "./contextMenuFallback";12import { showContextMenuFallback } from "./contextMenuFallback";
16import { WsTransport } from "./wsTransport";13import { WsTransport } from "./wsTransport";
...@@ -18,24 +15,6 @@ import { WsTransport } from "./wsTransport";...@@ -18,24 +15,6 @@ import { WsTransport } from "./wsTransport";
18let instance: { api: NativeApi; transport: WsTransport } | null = null;15let instance: { api: NativeApi; transport: WsTransport } | null = null;
19const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();16const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
20const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();17const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>();
21let lastWelcome: WsWelcomePayload | null = null;
22let lastServerConfigUpdated: ServerConfigUpdatedPayload | null = null;
23
24const 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};
3918
40/**19/**
41 * Subscribe to the server welcome message. If a welcome was already received20 * 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>(
45export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void {24export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void {
46 welcomeListeners.add(listener);25 welcomeListeners.add(listener);
4726
48 // Replay cached welcome for late subscribers27 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 errors32 // 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);
7049
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 errors56 // Swallow listener errors
76 }57 }
...@@ -86,12 +67,8 @@ export function createWsNativeApi(): NativeApi {...@@ -86,12 +67,8 @@ export function createWsNativeApi(): NativeApi {
8667
87 const transport = new WsTransport();68 const transport = new WsTransport();
8869
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 };
208179
apps/web/src/wsTransport.test.ts+53-19
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { WS_CHANNELS } from "@t3tools/contracts";
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
23
3import { WsTransport } from "./wsTransport";4import { WsTransport } from "./wsTransport";
...@@ -90,18 +91,24 @@ describe("WsTransport", () => {...@@ -90,18 +91,24 @@ describe("WsTransport", () => {
90 socket.open();91 socket.open();
9192
92 const listener = vi.fn();93 const listener = vi.fn();
93 transport.subscribe("providers.event", listener);94 transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);
9495
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 );
102104
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 });
105112
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();
138145
139 const listener = vi.fn();146 const listener = vi.fn();
140 transport.subscribe("providers.event", listener);147 transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);
141148
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 );
157166
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();
172192
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 @@
1import { WebSocketResponse, WsPush, WsResponse } from "@t3tools/contracts";1import {
2import { 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";
9import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
10import { Result, Schema } from "effect";
311
4type PushListener = (data: unknown) => void;12type PushListener<C extends WsPushChannel> = (message: WsPushMessage<C>) => void;
513
6interface PendingRequest {14interface 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}
1119
20interface SubscribeOptions {
21 readonly replayLatest?: boolean;
22}
23
24type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed";
25
12const REQUEST_TIMEOUT_MS = 60_000;26const REQUEST_TIMEOUT_MS = 60_000;
13const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];27const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
14const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse));28const decodeWsResponse = decodeUnknownJsonResult(WsResponseSchema);
15const isWsPushEnvelope = Schema.is(WsPush);
16const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse);29const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse);
1730
31const isWsPushMessage = (value: WsResponseMessage): value is WsPush =>
32 "type" in value && value.type === "push";
33
18interface WsRequestEnvelope {34interface 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}
2541
42function asError(value: unknown, fallback: string): Error {
43 if (value instanceof Error) {
44 return value;
45 }
46 return new Error(fallback);
47}
48
26export class WsTransport {49export 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;
3561
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);
5884
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 });
7096
71 this.send(message);97 this.send(encoded);
72 });98 });
73 }99 }
74100
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 }
82123
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 }
90131
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 }
105157
106 private connect() {158 private connect() {
107 if (this.disposed) return;159 if (this.disposed) {
160 return;
161 }
108162
163 this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
109 const ws = new WebSocket(this.url);164 const ws = new WebSocket(this.url);
110165
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 });
115172
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 });
119176
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 });
124188
125 ws.addEventListener("error", () => {189 ws.addEventListener("error", () => {
126 // close event will fire after error190 // close will follow
127 });191 });
128 }192 }
129193
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;
141200
142 // Push event201 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 errors210 // 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 }
156216
157 // Response to a request
158 if (!isWebSocketResponseEnvelope(message)) {217 if (!isWebSocketResponseEnvelope(message)) {
159 return;218 return;
160 }219 }
161220
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 }
164225
165 clearTimeout(pending.timeout);226 clearTimeout(pending.timeout);
166 this.pending.delete(message.id);227 this.pending.delete(message.id);
167228
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 }
174236
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 }
180241
181 // If not connected, wait for connection242 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);
193249
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 }
199268
200 private scheduleReconnect() {269 private scheduleReconnect() {
201 if (this.disposed) return;270 if (this.disposed || this.reconnectTimer !== null) {
271 return;
272 }
202273
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]!;
206277
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 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";2import { Effect, Schema } from "effect";
33
4import { ORCHESTRATION_WS_METHODS } from "./orchestration";4import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS } from "./orchestration";
5import { WS_METHODS } from "./ws";5import { WebSocketRequest, WsResponse, WS_CHANNELS, WS_METHODS } from "./ws";
6import { WebSocketRequest } from "./ws";
76
8const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest);7const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest);
8const decodeWsResponse = Schema.decodeUnknownEffect(WsResponse);
99
10it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () =>10it.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
76it.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
98it.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 @@
1import { Schema, Struct } from "effect";1import { Schema, Struct } from "effect";
2import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";2import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
33
4import {4import {
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 {
25import {27import {
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 {
33import { KeybindingRule } from "./keybindings";36import { KeybindingRule } from "./keybindings";
34import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";37import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";
35import { OpenInEditorInput } from "./editor";38import { OpenInEditorInput } from "./editor";
39import { ServerConfigUpdatedPayload } from "./server";
3640
37// ── WebSocket RPC Method Names ───────────────────────────────────────41// ── WebSocket RPC Method Names ───────────────────────────────────────
3842
...@@ -154,19 +158,8 @@ export const WebSocketResponse = Schema.Struct({...@@ -154,19 +158,8 @@ export const WebSocketResponse = Schema.Struct({
154});158});
155export type WebSocketResponse = typeof WebSocketResponse.Type;159export type WebSocketResponse = typeof WebSocketResponse.Type;
156160
157export const WsPush = Schema.Struct({161export const WsPushSequence = NonNegativeInt;
158 type: Schema.Literal("push"),162export type WsPushSequence = typeof WsPushSequence.Type;
159 channel: TrimmedNonEmptyString,
160 data: Schema.Unknown,
161});
162export type WsPush = typeof WsPush.Type;
163
164// ── Union of all server → client messages ─────────────────────────────
165
166export const WsResponse = Schema.Union([WebSocketResponse, WsPush]);
167export type WsResponse = typeof WsResponse.Type;
168
169// ── Server welcome payload ───────────────────────────────────────────
170163
171export const WsWelcomePayload = Schema.Struct({164export 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});
177export type WsWelcomePayload = typeof WsWelcomePayload.Type;170export type WsWelcomePayload = typeof WsWelcomePayload.Type;
171
172export 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
179export type WsPushChannel = keyof WsPushPayloadByChannel;
180export type WsPushData<C extends WsPushChannel> = WsPushPayloadByChannel[C];
181
182const 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
193export const WsPushServerWelcome = makeWsPushSchema(WS_CHANNELS.serverWelcome, WsWelcomePayload);
194export const WsPushServerConfigUpdated = makeWsPushSchema(
195 WS_CHANNELS.serverConfigUpdated,
196 ServerConfigUpdatedPayload,
197);
198export const WsPushTerminalEvent = makeWsPushSchema(WS_CHANNELS.terminalEvent, TerminalEvent);
199export const WsPushOrchestrationDomainEvent = makeWsPushSchema(
200 ORCHESTRATION_WS_CHANNELS.domainEvent,
201 OrchestrationEvent,
202);
203
204export const WsPushChannelSchema = Schema.Literals([
205 WS_CHANNELS.serverWelcome,
206 WS_CHANNELS.serverConfigUpdated,
207 WS_CHANNELS.terminalEvent,
208 ORCHESTRATION_WS_CHANNELS.domainEvent,
209]);
210export type WsPushChannelSchema = typeof WsPushChannelSchema.Type;
211
212export const WsPush = Schema.Union([
213 WsPushServerWelcome,
214 WsPushServerConfigUpdated,
215 WsPushTerminalEvent,
216 WsPushOrchestrationDomainEvent,
217]);
218export type WsPush = typeof WsPush.Type;
219
220export type WsPushMessage<C extends WsPushChannel> = Extract<WsPush, { channel: C }>;
221
222export const WsPushEnvelopeBase = Schema.Struct({
223 type: Schema.Literal("push"),
224 sequence: WsPushSequence,
225 channel: WsPushChannelSchema,
226 data: Schema.Unknown,
227});
228export type WsPushEnvelopeBase = typeof WsPushEnvelopeBase.Type;
229
230// ── Union of all server → client messages ─────────────────────────────
231
232export const WsResponse = Schema.Union([WebSocketResponse, WsPush]);
233export 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 @@
1import { it } from "@effect/vitest";
2import { describe, expect } from "vitest";
3import { Deferred, Effect } from "effect";
4
5import { makeDrainableWorker } from "./DrainableWorker";
6
7describe("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 */
11import { Deferred, Effect, Queue, Ref } from "effect";
12import type { Scope } from "effect";
13
14export 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 */
38export 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 @@
1import { Cause, Exit, Result, Schema, SchemaIssue } from "effect";
2
3export 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
16export 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
29export 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};