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
55```
66┌─────────────────────────────────┐
77│ Browser (React + Vite) │
8│ Connected via WebSocket │
8│ wsTransport (state machine) │
9│ Typed push decode at boundary │
910└──────────┬──────────────────────┘
1011 │ ws://localhost:3773
1112┌──────────▼──────────────────────┐
1213│ apps/server (Node.js) │
1314│ WebSocket + HTTP static server │
14│ ProviderManager │
15│ CodexAppServerManager │
15│ ServerPushBus (ordered pushes) │
16│ ServerReadiness (startup gate) │
17│ OrchestrationEngine │
18│ ProviderService │
19│ CheckpointReactor │
20│ RuntimeReceiptBus │
1621└──────────┬──────────────────────┘
1722 │ JSON-RPC over stdio
1823┌──────────▼──────────────────────┐
1924│ codex app-server │
2025└─────────────────────────────────┘
2126```
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 @@
33The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol:
44
55- **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }`
6- **Push events**: `{ type: "push", channel, data }` for orchestration read-model updates
6- **Push events**: typed envelopes with `channel`, `sequence` (monotonic per connection), and channel-specific `data`
7
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
810Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`:
911
......@@ -12,3 +14,17 @@ Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`:
1214- `shell.openInEditor`, `server.getConfig`
1315
1416Codex 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 @@
33- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built web app, and opens the browser on start.
44- `/apps/web`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket.
55- `/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 {
1515 Layer,
1616 ManagedRuntime,
1717 Option,
18 Ref,
1819 Schedule,
1920 Schema,
2021 Scope,
......@@ -45,6 +46,7 @@ import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointRea
4546import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
4647import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
4748import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
49import { RuntimeReceiptBusLive } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
4850import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
4951import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
5052import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts";
......@@ -54,6 +56,10 @@ import {
5456} from "../src/orchestration/Services/OrchestrationEngine.ts";
5557import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
5658import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
59import {
60 RuntimeReceiptBus,
61 type OrchestrationRuntimeReceipt,
62} from "../src/orchestration/Services/RuntimeReceiptBus.ts";
5763
5864import {
5965 makeTestProviderAdapterHarness,
......@@ -114,7 +120,7 @@ function waitFor<A, E>(
114120 read: Effect.Effect<A, E>,
115121 predicate: (value: A) => boolean,
116122 description: string,
117 timeoutMs = 3000,
123 timeoutMs = 10_000,
118124): Effect.Effect<A, never> {
119125 const RETRY_SIGNAL = "wait_for_retry";
120126 const retryIntervalMs = 10;
......@@ -185,6 +191,16 @@ export interface OrchestrationIntegrationHarness {
185191 },
186192 never
187193 >;
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 };
188204 readonly dispose: Effect.Effect<void, never>;
189205}
190206
......@@ -268,6 +284,7 @@ export const makeOrchestrationIntegrationHarness = (
268284 ProjectionPendingApprovalRepositoryLive,
269285 CheckpointStoreLive,
270286 providerLayer,
287 RuntimeReceiptBusLive,
271288 );
272289 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
273290 Layer.provideMerge(runtimeServicesLayer),
......@@ -322,11 +339,18 @@ export const makeOrchestrationIntegrationHarness = (
322339 "load ProjectionPendingApprovalRepository service",
323340 () => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),
324341 ).pipe(Effect.orDie);
342 const runtimeReceiptBus = yield* tryRuntimePromise("load RuntimeReceiptBus service", () =>
343 runtime.runPromise(Effect.service(RuntimeReceiptBus)),
344 ).pipe(Effect.orDie);
325345
326346 const scope = yield* Scope.make("sequential");
327347 yield* tryRuntimePromise("start OrchestrationReactor", () =>
328348 runtime.runPromise(reactor.start.pipe(Scope.provide(scope))),
329349 ).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));
330354 yield* sleep(10);
331355
332356 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (
......@@ -398,6 +422,30 @@ export const makeOrchestrationIntegrationHarness = (
398422 never
399423 >;
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
401449 let disposed = false;
402450 const dispose = Effect.gen(function* () {
403451 if (disposed) {
......@@ -443,6 +491,7 @@ export const makeOrchestrationIntegrationHarness = (
443491 waitForThread,
444492 waitForDomainEvent,
445493 waitForPendingApproval,
494 waitForReceipt,
446495 dispose,
447496 } satisfies OrchestrationIntegrationHarness;
448497 });
apps/server/integration/orchestrationEngine.integration.test.ts+42-2
......@@ -21,6 +21,10 @@ import {
2121 type OrchestrationIntegrationHarness,
2222} from "./OrchestrationEngineHarness.integration.ts";
2323import { checkpointRefForThreadTurn } from "../src/checkpointing/Utils.ts";
24import type {
25 CheckpointDiffFinalizedReceipt,
26 TurnProcessingQuiescedReceipt,
27} from "../src/orchestration/Services/RuntimeReceiptBus.ts";
2428
2529const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
2630const 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 +
187191 messageId: "msg-user-single",
188192 text: "Say hello",
189193 });
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
191211 const thread = yield* harness.waitForThread(
192212 THREAD_ID,
......@@ -208,8 +228,6 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
208228 assert.equal(checkpointRows[0]?.status, "ready");
209229 assert.deepEqual(checkpointRows[0]?.files, []);
210230
211 yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed");
212
213231 const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0);
214232 const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1);
215233 assert.equal(gitRefExists(harness.workspaceDir, ref0), true);
......@@ -367,6 +385,12 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
367385 messageId: "msg-user-multi-1",
368386 text: "Make first edit",
369387 });
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
371395 yield* harness.waitForThread(
372396 THREAD_ID,
......@@ -408,6 +432,22 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
408432 messageId: "msg-user-multi-2",
409433 text: "Make second edit",
410434 });
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
412452 const secondTurnThread = yield* harness.waitForThread(
413453 THREAD_ID,
apps/server/src/git/Layers/GitCore.ts+2-2
......@@ -654,7 +654,7 @@ const makeGitCore = Effect.gen(function* () {
654654
655655 const statusDetails: GitCoreShape["statusDetails"] = (cwd) =>
656656 Effect.gen(function* () {
657 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.catch(() => Effect.void));
657 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
658658
659659 const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all(
660660 [
......@@ -1365,7 +1365,7 @@ const makeGitCore = Effect.gen(function* () {
13651365
13661366 // Refresh upstream refs in the background so checkout remains responsive.
13671367 yield* Effect.forkScoped(
1368 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)),
1368 refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })),
13691369 );
13701370 });
13711371
apps/server/src/keybindings.ts+98-41
......@@ -22,7 +22,9 @@ import {
2222 Array,
2323 Cache,
2424 Cause,
25 Deferred,
2526 Effect,
27 Exit,
2628 FileSystem,
2729 Path,
2830 Layer,
......@@ -33,7 +35,9 @@ import {
3335 SchemaGetter,
3436 SchemaIssue,
3537 SchemaTransformation,
38 Ref,
3639 ServiceMap,
40 Scope,
3741 Stream,
3842} from "effect";
3943import * as Semaphore from "effect/Semaphore";
......@@ -422,6 +426,7 @@ export interface KeybindingsConfigState {
422426}
423427
424428export interface KeybindingsChangeEvent {
429 readonly keybindings: ResolvedKeybindingsConfig;
425430 readonly issues: readonly ServerConfigIssue[];
426431}
427432
......@@ -468,6 +473,22 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve
468473 * KeybindingsShape - Service API for keybinding configuration operations.
469474 */
470475export 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
471492 /**
472493 * Ensure the on-disk keybindings file exists and includes all default
473494 * commands so newly-added defaults are backfilled on startup.
......@@ -480,9 +501,14 @@ export interface KeybindingsShape {
480501 readonly loadConfigState: Effect.Effect<KeybindingsConfigState, KeybindingsConfigError>;
481502
482503 /**
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.
484510 */
485 readonly changes: Stream.Stream<KeybindingsChangeEvent>;
511 readonly streamChanges: Stream.Stream<KeybindingsChangeEvent>;
486512
487513 /**
488514 * Upsert a keybinding rule and persist the resulting configuration.
......@@ -509,9 +535,12 @@ const makeKeybindings = Effect.gen(function* () {
509535 const upsertSemaphore = yield* Semaphore.make(1);
510536 const resolvedConfigCacheKey = "resolved" as const;
511537 const changesPubSub = yield* PubSub.unbounded<KeybindingsChangeEvent>();
512
513 const emitChange = (issues: readonly ServerConfigIssue[]) =>
514 PubSub.publish(changesPubSub, { issues }).pipe(Effect.asVoid);
538 const startedRef = yield* Ref.make(false);
539 const startedDeferred = yield* Deferred.make<void, KeybindingsConfigError>();
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
516545 const readConfigExists = fs.exists(keybindingsConfigPath).pipe(
517546 Effect.mapError(
......@@ -676,43 +705,10 @@ const makeKeybindings = Effect.gen(function* () {
676705 Effect.gen(function* () {
677706 yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey);
678707 const configState = yield* loadConfigStateFromCacheOrDisk;
679 yield* emitChange(configState.issues);
708 yield* emitChange(configState);
680709 }),
681710 );
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
716712 const syncDefaultKeybindingsOnStartup = upsertSemaphore.withPermits(1)(
717713 Effect.gen(function* () {
718714 const configExists = yield* readConfigExists;
......@@ -803,10 +799,68 @@ const makeKeybindings = Effect.gen(function* () {
803799 }),
804800 );
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
806855 return {
856 start,
857 ready: Deferred.await(startedDeferred),
807858 syncDefaultKeybindingsOnStartup,
808859 loadConfigState: loadConfigStateFromCacheOrDisk,
809 changes: Stream.fromPubSub(changesPubSub),
860 getSnapshot: loadConfigStateFromCacheOrDisk,
861 get streamChanges() {
862 return Stream.fromPubSub(changesPubSub);
863 },
810864 upsertKeybindingRule: (rule) =>
811865 upsertSemaphore.withPermits(1)(
812866 Effect.gen(function* () {
......@@ -833,7 +887,10 @@ const makeKeybindings = Effect.gen(function* () {
833887 keybindings: nextResolved,
834888 issues: [],
835889 });
836 yield* emitChange([]);
890 yield* emitChange({
891 keybindings: nextResolved,
892 issues: [],
893 });
837894 return nextResolved;
838895 }),
839896 ),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+9-6
......@@ -22,6 +22,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts
2222import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2323import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2424import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
25import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
2526import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
2627import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
2728import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
......@@ -112,7 +113,7 @@ async function waitForThread(
112113 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
113114 activities: ReadonlyArray<{ kind: string }>;
114115 }) => boolean,
115 timeoutMs = 2000,
116 timeoutMs = 5000,
116117) {
117118 const deadline = Date.now() + timeoutMs;
118119 const poll = async (): Promise<{
......@@ -137,7 +138,7 @@ async function waitForThread(
137138async function waitForEvent(
138139 engine: OrchestrationEngineShape,
139140 predicate: (event: { type: string }) => boolean,
140 timeoutMs = 2000,
141 timeoutMs = 5000,
141142) {
142143 const deadline = Date.now() + timeoutMs;
143144 const poll = async () => {
......@@ -188,7 +189,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string {
188189 return runGit(cwd, ["show", `${ref}:${filePath}`]);
189190}
190191
191async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) {
192async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 5000) {
192193 const deadline = Date.now() + timeoutMs;
193194 const poll = async (): Promise<void> => {
194195 if (gitRefExists(cwd, ref)) {
......@@ -252,6 +253,7 @@ describe("CheckpointReactor", () => {
252253
253254 const layer = CheckpointReactorLive.pipe(
254255 Layer.provideMerge(orchestrationLayer),
256 Layer.provideMerge(RuntimeReceiptBusLive),
255257 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
256258 Layer.provideMerge(CheckpointStoreLive),
257259 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
......@@ -264,7 +266,7 @@ describe("CheckpointReactor", () => {
264266 const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore));
265267 scope = await Effect.runPromise(Scope.make("sequential"));
266268 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
269271 const createdAt = new Date().toISOString();
270272 await Effect.runPromise(
......@@ -321,6 +323,7 @@ describe("CheckpointReactor", () => {
321323 engine,
322324 provider,
323325 cwd,
326 drain,
324327 };
325328 }
326329
......@@ -449,7 +452,7 @@ describe("CheckpointReactor", () => {
449452 payload: { state: "completed" },
450453 });
451454
452 await Effect.runPromise(Effect.sleep("40 millis"));
455 await harness.drain();
453456 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
454457 const midThread = midReadModel.threads.find(
455458 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
......@@ -643,7 +646,7 @@ describe("CheckpointReactor", () => {
643646 status: "completed",
644647 });
645648
646 await Effect.runPromise(Effect.sleep("40 millis"));
649 await harness.drain();
647650 const readModel = await Effect.runPromise(harness.engine.getReadModel());
648651 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
649652 expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(
apps/server/src/orchestration/Layers/CheckpointReactor.ts+39-10
......@@ -7,7 +7,8 @@ import {
77 type OrchestrationEvent,
88 type ProviderRuntimeEvent,
99} 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
1213import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
1314import {
......@@ -19,6 +20,7 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts
1920import { ProviderService } from "../../provider/Services/ProviderService.ts";
2021import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
2122import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
23import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
2224import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2325import { OrchestrationDispatchError } from "../Errors.ts";
2426import { isGitRepository } from "../../git/isRepo.ts";
......@@ -64,6 +66,7 @@ const make = Effect.gen(function* () {
6466 const orchestrationEngine = yield* OrchestrationEngineService;
6567 const providerService = yield* ProviderService;
6668 const checkpointStore = yield* CheckpointStore;
69 const receiptBus = yield* RuntimeReceiptBus;
6770
6871 const appendRevertFailureActivity = (input: {
6972 readonly threadId: ThreadId;
......@@ -277,6 +280,22 @@ const make = Effect.gen(function* () {
277280 checkpointTurnCount: nextTurnCount,
278281 createdAt: now,
279282 });
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
281300 yield* orchestrationEngine.dispatch({
282301 type: "thread.activity.append",
......@@ -350,6 +369,13 @@ const make = Effect.gen(function* () {
350369 cwd: checkpointCwd,
351370 checkpointRef: baselineCheckpointRef,
352371 });
372 yield* receiptBus.publish({
373 type: "checkpoint.baseline.captured",
374 threadId: thread.id,
375 checkpointTurnCount: currentTurnCount,
376 checkpointRef: baselineCheckpointRef,
377 createdAt: event.createdAt,
378 });
353379 });
354380
355381 const ensurePreTurnBaselineFromDomainTurnStart = Effect.fnUntraced(function* (
......@@ -412,6 +438,13 @@ const make = Effect.gen(function* () {
412438 cwd: checkpointCwd,
413439 checkpointRef: baselineCheckpointRef,
414440 });
441 yield* receiptBus.publish({
442 type: "checkpoint.baseline.captured",
443 threadId,
444 checkpointTurnCount: currentTurnCount,
445 checkpointRef: baselineCheckpointRef,
446 createdAt: event.occurredAt,
447 });
415448 });
416449
417450 const handleRevertRequested = Effect.fnUntraced(function* (
......@@ -603,14 +636,9 @@ const make = Effect.gen(function* () {
603636 }),
604637 );
605638
606 const start: CheckpointReactorShape["start"] = Effect.gen(function* () {
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 );
639 const worker = yield* makeDrainableWorker(processInputSafely);
613640
641 const start: CheckpointReactorShape["start"] = Effect.gen(function* () {
614642 yield* Effect.forkScoped(
615643 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
616644 if (
......@@ -620,7 +648,7 @@ const make = Effect.gen(function* () {
620648 ) {
621649 return Effect.void;
622650 }
623 return Queue.offer(queue, { source: "domain", event }).pipe(Effect.asVoid);
651 return worker.enqueue({ source: "domain", event });
624652 }),
625653 );
626654
......@@ -629,13 +657,14 @@ const make = Effect.gen(function* () {
629657 if (event.type !== "turn.started" && event.type !== "turn.completed") {
630658 return Effect.void;
631659 }
632 return Queue.offer(queue, { source: "runtime", event }).pipe(Effect.asVoid);
660 return worker.enqueue({ source: "runtime", event });
633661 }),
634662 );
635663 });
636664
637665 return {
638666 start,
667 drain: worker.drain,
639668 } satisfies CheckpointReactorShape;
640669});
641670
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+3
......@@ -27,6 +27,7 @@ describe("OrchestrationReactor", () => {
2727 start: Effect.sync(() => {
2828 started.push("provider-runtime-ingestion");
2929 }),
30 drain: Effect.void,
3031 }),
3132 ),
3233 Layer.provideMerge(
......@@ -34,6 +35,7 @@ describe("OrchestrationReactor", () => {
3435 start: Effect.sync(() => {
3536 started.push("provider-command-reactor");
3637 }),
38 drain: Effect.void,
3739 }),
3840 ),
3941 Layer.provideMerge(
......@@ -41,6 +43,7 @@ describe("OrchestrationReactor", () => {
4143 start: Effect.sync(() => {
4244 started.push("checkpoint-reactor");
4345 }),
46 drain: Effect.void,
4447 }),
4548 ),
4649 ),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3-2
......@@ -219,7 +219,7 @@ describe("ProviderCommandReactor", () => {
219219 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
220220 scope = await Effect.runPromise(Scope.make("sequential"));
221221 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
224224 await Effect.runPromise(
225225 engine.dispatch({
......@@ -259,6 +259,7 @@ describe("ProviderCommandReactor", () => {
259259 renameBranch,
260260 generateBranchName,
261261 stateDir,
262 drain,
262263 };
263264 }
264265
......@@ -577,7 +578,7 @@ describe("ProviderCommandReactor", () => {
577578 return thread?.runtimeMode === "approval-required";
578579 });
579580 await waitFor(() => harness.startSession.mock.calls.length === 2);
580 await Effect.runPromise(Effect.sleep("30 millis"));
581 await harness.drain();
581582
582583 expect(harness.stopSession.mock.calls.length).toBe(0);
583584 expect(harness.sendTurn.mock.calls.length).toBe(1);
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+28-35
......@@ -12,7 +12,8 @@ import {
1212 type RuntimeMode,
1313 type TurnId,
1414} 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
1718import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
1819import { GitCore } from "../../git/Services/GitCore.ts";
......@@ -573,16 +574,14 @@ const make = Effect.gen(function* () {
573574 })
574575 .pipe(
575576 Effect.catchCause((cause) =>
576 Effect.gen(function* () {
577 yield* appendProviderFailureActivity({
578 threadId: event.payload.threadId,
579 kind: "provider.user-input.respond.failed",
580 summary: "Provider user input response failed",
581 detail: Cause.pretty(cause),
582 turnId: null,
583 createdAt: event.payload.createdAt,
584 requestId: event.payload.requestId,
585 });
577 appendProviderFailureActivity({
578 threadId: event.payload.threadId,
579 kind: "provider.user-input.respond.failed",
580 summary: "Provider user input response failed",
581 detail: Cause.pretty(cause),
582 turnId: null,
583 createdAt: event.payload.createdAt,
584 requestId: event.payload.requestId,
586585 }),
587586 ),
588587 );
......@@ -665,34 +664,28 @@ const make = Effect.gen(function* () {
665664 }),
666665 );
667666
668 const start: ProviderCommandReactorShape["start"] = Effect.gen(function* () {
669 const queue = yield* Queue.unbounded<ProviderIntentEvent>();
670 yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid));
671
672 yield* Effect.forkScoped(
673 Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processDomainEventSafely))),
674 );
675
676 yield* Effect.forkScoped(
677 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
678 if (
679 event.type !== "thread.runtime-mode-set" &&
680 event.type !== "thread.turn-start-requested" &&
681 event.type !== "thread.turn-interrupt-requested" &&
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 }
667 const worker = yield* makeDrainableWorker(processDomainEventSafely);
668
669 const start: ProviderCommandReactorShape["start"] = Effect.forkScoped(
670 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
671 if (
672 event.type !== "thread.runtime-mode-set" &&
673 event.type !== "thread.turn-start-requested" &&
674 event.type !== "thread.turn-interrupt-requested" &&
675 event.type !== "thread.approval-response-requested" &&
676 event.type !== "thread.user-input-response-requested" &&
677 event.type !== "thread.session-stop-requested"
678 ) {
679 return Effect.void;
680 }
688681
689 return Queue.offer(queue, event).pipe(Effect.asVoid);
690 }),
691 );
692 });
682 return worker.enqueue(event);
683 }),
684 ).pipe(Effect.asVoid);
693685
694686 return {
695687 start,
688 drain: worker.drain,
696689 } satisfies ProviderCommandReactorShape;
697690});
698691
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+7-6
......@@ -159,7 +159,7 @@ describe("ProviderRuntimeIngestion", () => {
159159 const ingestion = await runtime.runPromise(Effect.service(ProviderRuntimeIngestionService));
160160 scope = await Effect.runPromise(Scope.make("sequential"));
161161 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
164164 const createdAt = new Date().toISOString();
165165 await Effect.runPromise(
......@@ -209,6 +209,7 @@ describe("ProviderRuntimeIngestion", () => {
209209 return {
210210 engine,
211211 emit: provider.emit,
212 drain,
212213 };
213214 }
214215
......@@ -377,7 +378,7 @@ describe("ProviderRuntimeIngestion", () => {
377378 threadId: asThreadId("thread-1"),
378379 });
379380
380 await Effect.runPromise(Effect.sleep("40 millis"));
381 await harness.drain();
381382 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
382383 const midThread = midReadModel.threads.find(
383384 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
......@@ -430,7 +431,7 @@ describe("ProviderRuntimeIngestion", () => {
430431 status: "completed",
431432 });
432433
433 await Effect.runPromise(Effect.sleep("40 millis"));
434 await harness.drain();
434435 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
435436 const midThread = midReadModel.threads.find(
436437 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
......@@ -484,7 +485,7 @@ describe("ProviderRuntimeIngestion", () => {
484485 status: "completed",
485486 });
486487
487 await Effect.runPromise(Effect.sleep("40 millis"));
488 await harness.drain();
488489 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
489490 const midThread = midReadModel.threads.find(
490491 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
......@@ -725,7 +726,7 @@ describe("ProviderRuntimeIngestion", () => {
725726 },
726727 });
727728
728 await Effect.runPromise(Effect.sleep("30 millis"));
729 await harness.drain();
729730 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
730731 const midThread = midReadModel.threads.find(
731732 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
......@@ -784,7 +785,7 @@ describe("ProviderRuntimeIngestion", () => {
784785 createdAt: now,
785786 }),
786787 );
787 await Effect.runPromise(Effect.sleep("30 millis"));
788 await harness.drain();
788789
789790 harness.emit({
790791 type: "turn.started",
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+7-9
......@@ -10,7 +10,8 @@ import {
1010 type OrchestrationThreadActivity,
1111 type ProviderRuntimeEvent,
1212} 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
1516import { ProviderService } from "../../provider/Services/ProviderService.ts";
1617import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
......@@ -1118,16 +1119,12 @@ const make = Effect.gen(function* () {
11181119 }),
11191120 );
11201121
1121 const start: ProviderRuntimeIngestionShape["start"] = Effect.gen(function* () {
1122 const inputQueue = yield* Queue.unbounded<RuntimeIngestionInput>();
1123 yield* Effect.addFinalizer(() => Queue.shutdown(inputQueue).pipe(Effect.asVoid));
1122 const worker = yield* makeDrainableWorker(processInputSafely);
11241123
1125 yield* Effect.forkScoped(
1126 Effect.forever(Queue.take(inputQueue).pipe(Effect.flatMap(processInputSafely))),
1127 );
1124 const start: ProviderRuntimeIngestionShape["start"] = Effect.gen(function* () {
11281125 yield* Effect.forkScoped(
11291126 Stream.runForEach(providerService.streamEvents, (event) =>
1130 Queue.offer(inputQueue, { source: "runtime", event }).pipe(Effect.asVoid),
1127 worker.enqueue({ source: "runtime", event }),
11311128 ),
11321129 );
11331130 yield* Effect.forkScoped(
......@@ -1135,13 +1132,14 @@ const make = Effect.gen(function* () {
11351132 if (event.type !== "thread.turn-start-requested") {
11361133 return Effect.void;
11371134 }
1138 return Queue.offer(inputQueue, { source: "domain", event }).pipe(Effect.asVoid);
1135 return worker.enqueue({ source: "domain", event });
11391136 }),
11401137 );
11411138 });
11421139
11431140 return {
11441141 start,
1142 drain: worker.drain,
11451143 } satisfies ProviderRuntimeIngestionShape;
11461144});
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 {
2323 * internal queue.
2424 */
2525 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>;
2632}
2733
2834/**
apps/server/src/orchestration/Services/ProviderCommandReactor.ts+6
......@@ -23,6 +23,12 @@ export interface ProviderCommandReactorShape {
2323 * processing.
2424 */
2525 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>;
2632}
2733
2834/**
apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts+6
......@@ -23,6 +23,12 @@ export interface ProviderRuntimeIngestionShape {
2323 * logging warnings.
2424 */
2525 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>;
2632}
2733
2834/**
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) =>
292292 }
293293
294294 yield* upsertSessionBinding(session, threadId, {
295 ...(input.providerOptions !== undefined
296 ? { providerOptions: input.providerOptions }
297 : {}),
295 providerOptions: input.providerOptions,
298296 });
299297 yield* analytics.record("provider.session.started", {
300298 provider: session.provider,
apps/server/src/serverLayers.ts+2
......@@ -17,6 +17,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma
1717import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
1818import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
1919import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
20import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus";
2021import { ProviderUnsupportedError } from "./provider/Errors";
2122import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
2223import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
......@@ -87,6 +88,7 @@ export function makeServerRuntimeServicesLayer() {
8788 OrchestrationProjectionSnapshotQueryLive,
8889 CheckpointStoreLive,
8990 checkpointDiffQueryLayer,
91 RuntimeReceiptBusLive,
9092 );
9193 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
9294 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/wsServer.test.ts+201-158
......@@ -27,6 +27,8 @@ import {
2727 type ServerProviderStatus,
2828 type KeybindingsConfig,
2929 type ResolvedKeybindingsConfig,
30 type WsPushChannel,
31 type WsPushMessage,
3032 type WsPush,
3133} from "@t3tools/contracts";
3234import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
......@@ -52,13 +54,6 @@ import { GitCommandError, GitManagerError } from "./git/Errors.ts";
5254import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
5355import { 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
6257const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
6358const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
6459const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
......@@ -214,21 +209,97 @@ class MockTerminalManager implements TerminalManagerShape {
214209 readonly dispose: TerminalManagerShape["dispose"] = Effect.void;
215210}
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> {
218284 return new Promise((resolve, reject) => {
219285 const query = token ? `?token=${encodeURIComponent(token)}` : "";
220286 const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`);
221 const pending: PendingMessages = { queue: [], waiters: [] };
222 pendingBySocket.set(ws, pending);
287 const channels: SocketChannels = {
288 push: { queue: [], waiters: [] },
289 response: { queue: [], waiters: [] },
290 };
291 channelsBySocket.set(ws, channels);
223292
224293 ws.on("message", (raw) => {
225294 const parsed = JSON.parse(String(raw));
226 const waiter = pending.waiters.shift();
227 if (waiter) {
228 waiter(parsed);
229 return;
295 if (isWsPushEnvelope(parsed)) {
296 enqueue(channels.push, parsed);
297 } else {
298 const response = asWebSocketResponse(parsed);
299 if (response) {
300 enqueue(channels.response, response);
301 }
230302 }
231 pending.queue.push(parsed);
232303 });
233304
234305 ws.once("open", () => resolve(ws));
......@@ -236,28 +307,31 @@ function connectWs(port: number, token?: string): Promise<WebSocket> {
236307 });
237308}
238309
239function waitForMessage(ws: WebSocket): Promise<unknown> {
240 const pending = pendingBySocket.get(ws);
241 if (!pending) {
242 return Promise.reject(new Error("WebSocket not initialized"));
243 }
310async function connectWs(port: number, token?: string, attempts = 5): Promise<WebSocket> {
311 let lastError: unknown = new Error("WebSocket connection failed");
244312
245 const queued = pending.queue.shift();
246 if (queued !== undefined) {
247 return Promise.resolve(queued);
313 for (let attempt = 0; attempt < attempts; attempt += 1) {
314 try {
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 }
248322 }
249323
250 return new Promise((resolve) => {
251 pending.waiters.push(resolve);
252 });
324 throw lastError;
253325}
254326
255function asWebSocketResponse(message: unknown): WebSocketResponse | null {
256 if (typeof message !== "object" || message === null) return null;
257 if (!("id" in message)) return null;
258 const id = (message as { id?: unknown }).id;
259 if (typeof id !== "string") return null;
260 return message as WebSocketResponse;
327/** Connect and wait for the server.welcome push. Returns [ws, welcomeData]. */
328async function connectAndAwaitWelcome(
329 port: number,
330 token?: string,
331): Promise<[WebSocket, WsPushMessage<typeof WS_CHANNELS.serverWelcome>]> {
332 const ws = await connectWs(port, token);
333 const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome);
334 return [ws, welcome];
261335}
262336
263337async function sendRequest(
......@@ -265,6 +339,9 @@ async function sendRequest(
265339 method: string,
266340 params?: unknown,
267341): Promise<WebSocketResponse> {
342 const channels = channelsBySocket.get(ws);
343 if (!channels) throw new Error("WebSocket not initialized");
344
268345 const id = crypto.randomUUID();
269346 const body =
270347 method === ORCHESTRATION_WS_METHODS.dispatchCommand
......@@ -272,44 +349,53 @@ async function sendRequest(
272349 : params && typeof params === "object" && !Array.isArray(params)
273350 ? { _tag: method, ...(params as Record<string, unknown>) }
274351 : { _tag: method };
275 const message = JSON.stringify({ id, body });
276 ws.send(message);
352 ws.send(JSON.stringify({ id, body }));
277353
278 // Wait for response with matching id
354 // Response channel only contains responses — no push filtering needed
279355 while (true) {
280 const parsed = asWebSocketResponse(await waitForMessage(ws));
281 if (!parsed) {
282 continue;
283 }
284 if (parsed.id === id) {
285 return parsed;
286 }
287 if (parsed.id === "unknown") {
288 return parsed;
356 const response = await dequeue(channels.response, 60_000);
357 if (response.id === id || response.id === "unknown") {
358 return response;
289359 }
290360 }
291361}
292362
293async function waitForPush(
363async function waitForPush<C extends WsPushChannel>(
294364 ws: WebSocket,
295 channel: string,
296 predicate?: (push: WsPush) => boolean,
365 channel: C,
366 predicate?: (push: WsPushMessage<C>) => boolean,
297367 maxMessages = 120,
298): Promise<WsPush> {
299 const take = async (remaining: number): Promise<WsPush> => {
300 if (remaining <= 0) {
301 throw new Error(`Timed out waiting for push on ${channel}`);
302 }
303 const message = (await waitForMessage(ws)) as WsPush;
304 if (message.type !== "push" || message.channel !== channel) {
305 return take(remaining - 1);
306 }
307 if (!predicate || predicate(message)) {
308 return message;
368 idleTimeoutMs = 5_000,
369): Promise<WsPushMessage<C>> {
370 const channels = channelsBySocket.get(ws);
371 if (!channels) throw new Error("WebSocket not initialized");
372
373 for (let remaining = maxMessages; remaining > 0; remaining--) {
374 const push = await dequeue(channels.push, idleTimeoutMs);
375 if (push.channel !== channel) continue;
376 const typed = push as WsPushMessage<C>;
377 if (!predicate || predicate(typed)) return typed;
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;
309396 }
310 return take(remaining - 1);
311 };
312 return take(maxMessages);
397 }
398 throw lastError;
313399}
314400
315401async function requestPath(
......@@ -489,18 +575,15 @@ describe("WebSocket Server", () => {
489575
490576 it("sends welcome message on connect", async () => {
491577 server = await createTestServer({ cwd: "/test/project" });
492 // Get the actual port after listen
493578 const addr = server.address();
494579 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
495580 expect(port).toBeGreaterThan(0);
496581
497 const ws = await connectWs(port);
582 const [ws, welcome] = await connectAndAwaitWelcome(port);
498583 connections.push(ws);
499584
500 const message = (await waitForMessage(ws)) as WsPush;
501 expect(message.type).toBe("push");
502 expect(message.channel).toBe(WS_CHANNELS.serverWelcome);
503 expect(message.data).toEqual({
585 expect(welcome.type).toBe("push");
586 expect(welcome.data).toEqual({
504587 cwd: "/test/project",
505588 projectName: "project",
506589 });
......@@ -589,10 +672,8 @@ describe("WebSocket Server", () => {
589672 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
590673 expect(port).toBeGreaterThan(0);
591674
592 const ws = await connectWs(port);
675 const [ws, welcome] = await connectAndAwaitWelcome(port);
593676 connections.push(ws);
594 const welcome = (await waitForMessage(ws)) as WsPush; // welcome
595 expect(welcome.channel).toBe(WS_CHANNELS.serverWelcome);
596677 expect(welcome.data).toEqual(
597678 expect.objectContaining({
598679 cwd: "/test/bootstrap-workspace",
......@@ -666,9 +747,8 @@ describe("WebSocket Server", () => {
666747 let port = typeof addr === "object" && addr !== null ? addr.port : 0;
667748 expect(port).toBeGreaterThan(0);
668749
669 const firstWs = await connectWs(port);
750 const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port);
670751 connections.push(firstWs);
671 const firstWelcome = (await waitForMessage(firstWs)) as WsPush;
672752 const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string })
673753 .bootstrapProjectId;
674754 const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string })
......@@ -690,10 +770,8 @@ describe("WebSocket Server", () => {
690770 port = typeof addr === "object" && addr !== null ? addr.port : 0;
691771 expect(port).toBeGreaterThan(0);
692772
693 const secondWs = await connectWs(port);
773 const [secondWs, secondWelcome] = await connectAndAwaitWelcome(port);
694774 connections.push(secondWs);
695 const secondWelcome = (await waitForMessage(secondWs)) as WsPush;
696 expect(secondWelcome.channel).toBe(WS_CHANNELS.serverWelcome);
697775 expect(secondWelcome.data).toEqual(
698776 expect.objectContaining({
699777 cwd,
......@@ -717,9 +795,8 @@ describe("WebSocket Server", () => {
717795 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
718796 expect(port).toBeGreaterThan(0);
719797
720 const ws = await connectWs(port);
798 const [ws] = await connectAndAwaitWelcome(port);
721799 connections.push(ws);
722 await waitForMessage(ws);
723800
724801 expect(
725802 logSpy.mock.calls.some(([message]) => {
......@@ -742,12 +819,9 @@ describe("WebSocket Server", () => {
742819 const addr = server.address();
743820 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
744821
745 const ws = await connectWs(port);
822 const [ws] = await connectAndAwaitWelcome(port);
746823 connections.push(ws);
747824
748 // Consume welcome message
749 await waitForMessage(ws);
750
751825 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
752826 expect(response.error).toBeUndefined();
753827 expect(response.result).toEqual({
......@@ -770,9 +844,8 @@ describe("WebSocket Server", () => {
770844 const addr = server.address();
771845 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
772846
773 const ws = await connectWs(port);
847 const [ws] = await connectAndAwaitWelcome(port);
774848 connections.push(ws);
775 await waitForMessage(ws);
776849
777850 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
778851 expect(response.error).toBeUndefined();
......@@ -801,9 +874,8 @@ describe("WebSocket Server", () => {
801874 const addr = server.address();
802875 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
803876
804 const ws = await connectWs(port);
877 const [ws] = await connectAndAwaitWelcome(port);
805878 connections.push(ws);
806 await waitForMessage(ws);
807879
808880 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
809881 expect(response.error).toBeUndefined();
......@@ -841,9 +913,8 @@ describe("WebSocket Server", () => {
841913 const addr = server.address();
842914 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
843915
844 const ws = await connectWs(port);
916 const [ws] = await connectAndAwaitWelcome(port);
845917 connections.push(ws);
846 await waitForMessage(ws);
847918
848919 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
849920 expect(response.error).toBeUndefined();
......@@ -885,32 +956,28 @@ describe("WebSocket Server", () => {
885956 const addr = server.address();
886957 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
887958
888 const ws = await connectWs(port);
959 const [ws] = await connectAndAwaitWelcome(port);
889960 connections.push(ws);
890 await waitForMessage(ws);
891961
892 fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
893 const malformedPush = await waitForPush(
962 const malformedPush = await rewriteKeybindingsAndWaitForPush(
894963 ws,
895 WS_CHANNELS.serverConfigUpdated,
964 keybindingsPath,
965 "{ not-json",
896966 (push) =>
897 Array.isArray((push.data as { issues?: unknown[] }).issues) &&
898 Boolean((push.data as { issues: Array<{ kind: string }> }).issues[0]) &&
899 (push.data as { issues: Array<{ kind: string }> }).issues[0]!.kind ===
900 "keybindings.malformed-config",
967 Array.isArray(push.data.issues) &&
968 Boolean(push.data.issues[0]) &&
969 push.data.issues[0]!.kind === "keybindings.malformed-config",
901970 );
902971 expect(malformedPush.data).toEqual({
903972 issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }],
904973 providers: defaultProviderStatuses,
905974 });
906975
907 fs.writeFileSync(keybindingsPath, "[]", "utf8");
908 const successPush = await waitForPush(
976 const successPush = await rewriteKeybindingsAndWaitForPush(
909977 ws,
910 WS_CHANNELS.serverConfigUpdated,
911 (push) =>
912 Array.isArray((push.data as { issues?: unknown[] }).issues) &&
913 (push.data as { issues: unknown[] }).issues.length === 0,
978 keybindingsPath,
979 "[]",
980 (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0,
914981 );
915982 expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses });
916983 });
......@@ -929,9 +996,8 @@ describe("WebSocket Server", () => {
929996 const addr = server.address();
930997 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
931998
932 const ws = await connectWs(port);
999 const [ws] = await connectAndAwaitWelcome(port);
9331000 connections.push(ws);
934 await waitForMessage(ws);
9351001
9361002 const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, {
9371003 cwd: "/my/workspace",
......@@ -957,11 +1023,9 @@ describe("WebSocket Server", () => {
9571023 const addr = server.address();
9581024 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
9591025
960 const ws = await connectWs(port);
1026 const [ws] = await connectAndAwaitWelcome(port);
9611027 connections.push(ws);
9621028
963 await waitForMessage(ws);
964
9651029 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
9661030 expect(response.error).toBeUndefined();
9671031 const persistedConfig = JSON.parse(
......@@ -991,9 +1055,8 @@ describe("WebSocket Server", () => {
9911055 const addr = server.address();
9921056 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
9931057
994 const ws = await connectWs(port);
1058 const [ws] = await connectAndAwaitWelcome(port);
9951059 connections.push(ws);
996 await waitForMessage(ws);
9971060
9981061 const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, {
9991062 key: "mod+shift+r",
......@@ -1033,12 +1096,9 @@ describe("WebSocket Server", () => {
10331096 const addr = server.address();
10341097 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10351098
1036 const ws = await connectWs(port);
1099 const [ws] = await connectAndAwaitWelcome(port);
10371100 connections.push(ws);
10381101
1039 // Consume welcome push
1040 await waitForMessage(ws);
1041
10421102 const response = await sendRequest(ws, "nonexistent.method");
10431103 expect(response.error).toBeDefined();
10441104 expect(response.error!.message).toContain("Invalid request format");
......@@ -1049,9 +1109,8 @@ describe("WebSocket Server", () => {
10491109 const addr = server.address();
10501110 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10511111
1052 const ws = await connectWs(port);
1112 const [ws] = await connectAndAwaitWelcome(port);
10531113 connections.push(ws);
1054 await waitForMessage(ws);
10551114
10561115 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
10571116 threadId: "thread-missing",
......@@ -1067,9 +1126,8 @@ describe("WebSocket Server", () => {
10671126 const addr = server.address();
10681127 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10691128
1070 const ws = await connectWs(port);
1129 const [ws] = await connectAndAwaitWelcome(port);
10711130 connections.push(ws);
1072 await waitForMessage(ws);
10731131
10741132 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
10751133 threadId: "thread-any",
......@@ -1087,9 +1145,8 @@ describe("WebSocket Server", () => {
10871145 const addr = server.address();
10881146 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10891147
1090 const ws = await connectWs(port);
1148 const [ws] = await connectAndAwaitWelcome(port);
10911149 connections.push(ws);
1092 await waitForMessage(ws);
10931150
10941151 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
10951152 threadId: "thread-missing",
......@@ -1104,9 +1161,8 @@ describe("WebSocket Server", () => {
11041161 const addr = server.address();
11051162 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
11061163
1107 const ws = await connectWs(port);
1164 const [ws] = await connectAndAwaitWelcome(port);
11081165 connections.push(ws);
1109 await waitForMessage(ws);
11101166
11111167 const workspaceRoot = makeTempDir("t3code-ws-diff-project-");
11121168 const createdAt = new Date().toISOString();
......@@ -1183,9 +1239,8 @@ describe("WebSocket Server", () => {
11831239 const addr = server.address();
11841240 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
11851241
1186 const ws = await connectWs(port);
1242 const [ws] = await connectAndAwaitWelcome(port);
11871243 connections.push(ws);
1188 await waitForMessage(ws);
11891244
11901245 const workspaceRoot = makeTempDir("t3code-ws-project-");
11911246 const createdAt = new Date().toISOString();
......@@ -1276,9 +1331,8 @@ describe("WebSocket Server", () => {
12761331 const addr = server.address();
12771332 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
12781333
1279 const ws = await connectWs(port);
1334 const [ws] = await connectAndAwaitWelcome(port);
12801335 connections.push(ws);
1281 await waitForMessage(ws);
12821336
12831337 const open = await sendRequest(ws, WS_METHODS.terminalOpen, {
12841338 threadId: "thread-1",
......@@ -1331,8 +1385,13 @@ describe("WebSocket Server", () => {
13311385 };
13321386 terminalManager.emitEvent(manualEvent);
13331387
1334 const push = await waitForPush(ws, WS_CHANNELS.terminalEvent);
1335 expect((push.data as TerminalEvent).type).toBe("output");
1388 const push = await waitForPush(
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);
13361395 });
13371396
13381397 it("detaches terminal event listener on stop for injected manager", async () => {
......@@ -1355,9 +1414,8 @@ describe("WebSocket Server", () => {
13551414 const addr = server.address();
13561415 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
13571416
1358 const ws = await connectWs(port);
1417 const [ws] = await connectAndAwaitWelcome(port);
13591418 connections.push(ws);
1360 await waitForMessage(ws);
13611419
13621420 const response = await sendRequest(ws, WS_METHODS.terminalOpen, {
13631421 threadId: "",
......@@ -1373,21 +1431,17 @@ describe("WebSocket Server", () => {
13731431 const addr = server.address();
13741432 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
13751433
1376 const ws = await connectWs(port);
1434 const [ws] = await connectAndAwaitWelcome(port);
13771435 connections.push(ws);
13781436
1379 // Consume welcome
1380 await waitForMessage(ws);
1381
13821437 // Send garbage
13831438 ws.send("not json at all");
13841439
1440 // Error response goes to the response channel
1441 const channels = channelsBySocket.get(ws)!;
13851442 let response: WebSocketResponse | null = null;
13861443 for (let attempt = 0; attempt < 5; attempt += 1) {
1387 const message = asWebSocketResponse(await waitForMessage(ws));
1388 if (!message) {
1389 continue;
1390 }
1444 const message = await dequeue(channels.response, 5_000);
13911445 if (message.id === "unknown") {
13921446 response = message;
13931447 break;
......@@ -1420,9 +1474,8 @@ describe("WebSocket Server", () => {
14201474 const addr = server.address();
14211475 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
14221476
1423 const ws = await connectWs(port);
1477 const [ws] = await connectAndAwaitWelcome(port);
14241478 connections.push(ws);
1425 await waitForMessage(ws);
14261479
14271480 ws.send(
14281481 JSON.stringify({
......@@ -1466,9 +1519,8 @@ describe("WebSocket Server", () => {
14661519 const addr = server.address();
14671520 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
14681521
1469 const ws = await connectWs(port);
1522 const [ws] = await connectAndAwaitWelcome(port);
14701523 connections.push(ws);
1471 await waitForMessage(ws);
14721524
14731525 const listResponse = await sendRequest(ws, WS_METHODS.projectsList);
14741526 expect(listResponse.result).toBeUndefined();
......@@ -1503,9 +1555,8 @@ describe("WebSocket Server", () => {
15031555 const addr = server.address();
15041556 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
15051557
1506 const ws = await connectWs(port);
1558 const [ws] = await connectAndAwaitWelcome(port);
15071559 connections.push(ws);
1508 await waitForMessage(ws);
15091560
15101561 const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, {
15111562 cwd: workspace,
......@@ -1529,9 +1580,8 @@ describe("WebSocket Server", () => {
15291580 const addr = server.address();
15301581 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
15311582
1532 const ws = await connectWs(port);
1583 const [ws] = await connectAndAwaitWelcome(port);
15331584 connections.push(ws);
1534 await waitForMessage(ws);
15351585
15361586 const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
15371587 cwd: workspace,
......@@ -1555,9 +1605,8 @@ describe("WebSocket Server", () => {
15551605 const addr = server.address();
15561606 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
15571607
1558 const ws = await connectWs(port);
1608 const [ws] = await connectAndAwaitWelcome(port);
15591609 connections.push(ws);
1560 await waitForMessage(ws);
15611610
15621611 const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, {
15631612 cwd: workspace,
......@@ -1603,9 +1652,8 @@ describe("WebSocket Server", () => {
16031652 const addr = server.address();
16041653 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
16051654
1606 const ws = await connectWs(port);
1655 const [ws] = await connectAndAwaitWelcome(port);
16071656 connections.push(ws);
1608 await waitForMessage(ws);
16091657
16101658 const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" });
16111659 expect(listResponse.error).toBeUndefined();
......@@ -1652,9 +1700,8 @@ describe("WebSocket Server", () => {
16521700 const addr = server.address();
16531701 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
16541702
1655 const ws = await connectWs(port);
1703 const [ws] = await connectAndAwaitWelcome(port);
16561704 connections.push(ws);
1657 await waitForMessage(ws);
16581705
16591706 const response = await sendRequest(ws, WS_METHODS.gitStatus, {
16601707 cwd: "/test",
......@@ -1692,9 +1739,8 @@ describe("WebSocket Server", () => {
16921739 const addr = server.address();
16931740 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
16941741
1695 const ws = await connectWs(port);
1742 const [ws] = await connectAndAwaitWelcome(port);
16961743 connections.push(ws);
1697 await waitForMessage(ws);
16981744
16991745 const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, {
17001746 cwd: "/test",
......@@ -1741,9 +1787,8 @@ describe("WebSocket Server", () => {
17411787 const addr = server.address();
17421788 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
17431789
1744 const ws = await connectWs(port);
1790 const [ws] = await connectAndAwaitWelcome(port);
17451791 connections.push(ws);
1746 await waitForMessage(ws);
17471792
17481793 const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, {
17491794 cwd: "/test",
......@@ -1764,9 +1809,7 @@ describe("WebSocket Server", () => {
17641809
17651810 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");
17681813 connections.push(authorizedWs);
1769 const welcome = (await waitForMessage(authorizedWs)) as WsPush;
1770 expect(welcome.channel).toBe(WS_CHANNELS.serverWelcome);
17711814 });
17721815});
apps/server/src/wsServer.ts+69-102
......@@ -20,13 +20,12 @@ import {
2020 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
2121 ProjectId,
2222 ThreadId,
23 TerminalEvent,
2423 WS_CHANNELS,
2524 WS_METHODS,
2625 WebSocketRequest,
27 WsPush,
26 type WsResponse as WsResponseMessage,
2827 WsResponse,
29 ServerProviderStatus,
28 type WsPushEnvelopeBase,
3029} from "@t3tools/contracts";
3130import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
3231import {
......@@ -37,6 +36,7 @@ import {
3736 Layer,
3837 Path,
3938 Ref,
39 Result,
4040 Schema,
4141 Scope,
4242 ServiceMap,
......@@ -66,6 +66,7 @@ import {
6666 normalizeAttachmentRelativePath,
6767 resolveAttachmentRelativePath,
6868} from "./attachmentPaths";
69
6970import {
7071 createAttachmentId,
7172 resolveAttachmentPath,
......@@ -74,6 +75,9 @@ import {
7475import { parseBase64DataUrl } from "./imageMime.ts";
7576import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
7677import { 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
7882/**
7983 * ServerShape - Service API for server lifecycle control.
......@@ -195,6 +199,9 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {
195199 return Struct.omit(body, ["_tag"]);
196200}
197201
202const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse));
203const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest);
204
198205export type ServerCoreRuntimeServices =
199206 | OrchestrationEngineService
200207 | ProjectionSnapshotQuery
......@@ -261,38 +268,33 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
261268 ),
262269 );
263270
271 const providerStatuses = yield* providerHealth.getStatuses;
272
264273 const clients = yield* Ref.make(new Set<WebSocket>());
265274 const logger = createLogger("ws");
275 const readiness = yield* makeServerReadiness;
266276
267 function logOutgoingPush(push: WsPush, recipients: number) {
277 function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) {
268278 if (!logWebSocketEvents) return;
269279 logger.event("outgoing push", {
270280 channel: push.channel,
281 sequence: push.sequence,
271282 recipients,
272283 payload: push.data,
273284 });
274285 }
275286
276 const encodePush = Schema.encodeEffect(Schema.fromJsonString(WsPush));
277 const broadcastPush = Effect.fnUntraced(function* (push: WsPush) {
278 const message = yield* encodePush(push);
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 });
287 const pushBus = yield* makeServerPushBus({
288 clients,
289 logOutgoingPush,
295290 });
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
297299 const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {
298300 readonly command: ClientOrchestrationCommand;
......@@ -474,7 +476,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
474476 }
475477 }),
476478 ).pipe(Effect.exit);
477 if (streamExit._tag === "Failure") {
479 if (Exit.isFailure(streamExit)) {
478480 if (!res.destroyed) {
479481 res.destroy();
480482 }
......@@ -605,43 +607,19 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
605607 const subscriptionsScope = yield* Scope.make("sequential");
606608 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
625610 yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
626 broadcastPush({
627 type: "push",
628 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
629 data: event,
630 }),
611 pushBus.publishAll(ORCHESTRATION_WS_CHANNELS.domainEvent, event),
631612 ).pipe(Effect.forkIn(subscriptionsScope));
632613
633 yield* Stream.runForEach(keybindingsManager.changes, (event) =>
634 broadcastPush({
635 type: "push",
636 channel: WS_CHANNELS.serverConfigUpdated,
637 data: {
638 issues: event.issues,
639 providers,
640 },
614 yield* Stream.runForEach(keybindingsManager.streamChanges, (event) =>
615 pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, {
616 issues: event.issues,
617 providers: providerStatuses,
641618 }),
642619 ).pipe(Effect.forkIn(subscriptionsScope));
643620
644621 yield* Scope.provide(orchestrationReactor.start, subscriptionsScope);
622 yield* readiness.markOrchestrationSubscriptionsReady;
645623
646624 let welcomeBootstrapProjectId: ProjectId | undefined;
647625 let welcomeBootstrapThreadId: ThreadId | undefined;
......@@ -712,23 +690,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
712690 const runPromise = Effect.runPromiseWith(runtimeServices);
713691
714692 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(
715 (event) => void Effect.runPromise(onTerminalEvent(event)),
693 (event) => void Effect.runPromise(pushBus.publishAll(WS_CHANNELS.terminalEvent, event)),
716694 );
717695 yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents()));
696 yield* readiness.markTerminalSubscriptionsReady;
718697
719698 yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe(
720699 Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })),
721700 );
701 yield* readiness.markHttpListening;
722702
723703 yield* Effect.addFinalizer(() =>
724 Effect.all([
725 closeAllClients,
726 closeWebSocketServer.pipe(
727 Effect.catch((error) =>
728 Effect.logWarning("failed to close web socket server", { cause: error }),
729 ),
730 ),
731 ]),
704 Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]),
732705 );
733706
734707 const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) {
......@@ -900,7 +873,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
900873 keybindingsConfigPath,
901874 keybindings: keybindingsConfig.keybindings,
902875 issues: keybindingsConfig.issues,
903 providers,
876 providers: providerStatuses,
904877 availableEditors,
905878 };
906879
......@@ -920,44 +893,40 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
920893 });
921894
922895 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
925902 const messageText = websocketRawToString(raw);
926903 if (messageText === null) {
927 const errorResponse = yield* encodeResponse({
904 return yield* sendWsResponse({
928905 id: "unknown",
929906 error: { message: "Invalid request format: Failed to read message" },
930907 });
931 ws.send(errorResponse);
932 return;
933908 }
934909
935 const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText);
936 if (request._tag === "Failure") {
937 const errorResponse = yield* encodeResponse({
910 const request = decodeWebSocketRequest(messageText);
911 if (Result.isFailure(request)) {
912 return yield* sendWsResponse({
938913 id: "unknown",
939 error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` },
914 error: { message: `Invalid request format: ${formatSchemaError(request.failure)}` },
940915 });
941 ws.send(errorResponse);
942 return;
943916 }
944917
945 const result = yield* Effect.exit(routeRequest(request.value));
946 if (result._tag === "Failure") {
947 const errorResponse = yield* encodeResponse({
948 id: request.value.id,
918 const result = yield* Effect.exit(routeRequest(request.success));
919 if (Exit.isFailure(result)) {
920 return yield* sendWsResponse({
921 id: request.success.id,
949922 error: { message: Cause.pretty(result.cause) },
950923 });
951 ws.send(errorResponse);
952 return;
953924 }
954925
955 const response = yield* encodeResponse({
956 id: request.value.id,
926 return yield* sendWsResponse({
927 id: request.success.id,
957928 result: result.value,
958929 });
959
960 ws.send(response);
961930 });
962931
963932 httpServer.on("upgrade", (request, socket, head) => {
......@@ -985,30 +954,28 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
985954 });
986955
987956 wss.on("connection", (ws) => {
988 void runPromise(Ref.update(clients, (clients) => clients.add(ws)));
989
990957 const segments = cwd.split(/[/\\]/).filter(Boolean);
991958 const projectName = segments[segments.length - 1] ?? "project";
992959
993 const welcome: WsPush = {
994 type: "push",
995 channel: WS_CHANNELS.serverWelcome,
996 data: {
997 cwd,
998 projectName,
999 ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}),
1000 ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}),
1001 },
960 const welcomeData = {
961 cwd,
962 projectName,
963 ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}),
964 ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}),
1002965 };
1003 logOutgoingPush(welcome, 1);
1004 ws.send(JSON.stringify(welcome));
966 // Send welcome before adding to broadcast set so publishAll calls
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
1006977 ws.on("message", (raw) => {
1007 void runPromise(
1008 handleMessage(ws, raw).pipe(
1009 Effect.catch((error) => Effect.logError("Error handling message", error)),
1010 ),
1011 );
978 void runPromise(handleMessage(ws, raw).pipe(Effect.ignoreCause({ log: true })));
1012979 });
1013980
1014981 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";
2525import { estimateTimelineMessageHeight } from "./timelineHeight";
2626
2727const 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}$/;
2829const PROJECT_ID = "project-1" as ProjectId;
2930const NOW_ISO = "2026-03-04T12:00:00.000Z";
3031const BASE_TIME_MS = Date.parse(NOW_ISO);
......@@ -85,6 +86,7 @@ interface MountedChatView {
8586 cleanup: () => Promise<void>;
8687 measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;
8788 setViewport: (viewport: ViewportSpec) => Promise<void>;
89 router: ReturnType<typeof getRouter>;
8890}
8991
9092function isoAt(offsetSeconds: number): string {
......@@ -245,6 +247,46 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
245247 };
246248}
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
248290function createDraftOnlySnapshot(): OrchestrationReadModel {
249291 const snapshot = createSnapshotForTargetUser({
250292 targetMessageId: "msg-user-draft-target" as MessageId,
......@@ -361,6 +403,7 @@ const worker = setupWorker(
361403 client.send(
362404 JSON.stringify({
363405 type: "push",
406 sequence: 1,
364407 channel: WS_CHANNELS.serverWelcome,
365408 data: fixture.welcome,
366409 }),
......@@ -448,6 +491,22 @@ async function waitForElement<T extends Element>(
448491 return element;
449492}
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
451510async function waitForComposerEditor(): Promise<HTMLElement> {
452511 return waitForElement(
453512 () => document.querySelector<HTMLElement>('[contenteditable="true"]'),
......@@ -593,6 +652,7 @@ async function mountChatView(options: {
593652 await setViewport(viewport);
594653 await waitForProductionStyles();
595654 },
655 router,
596656 };
597657}
598658
......@@ -934,6 +994,60 @@ describe("ChatView timeline estimator parity (full app)", () => {
934994 }
935995 });
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
9371051 it("keeps long proposed plans lightweight until the user expands them", async () => {
9381052 const mounted = await mountChatView({
9391053 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.tsx-4
......@@ -613,7 +613,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
613613 (store) => store.syncPersistedAttachments,
614614 );
615615 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
616 const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread);
617616 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
618617 const getDraftThreadByProjectId = useComposerDraftStore(
619618 (store) => store.getDraftThreadByProjectId,
......@@ -2788,9 +2787,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
27882787 createdAt: messageCreatedAt,
27892788 });
27902789 turnStartSucceeded = true;
2791 if (isFirstMessage) {
2792 clearDraftThread(threadIdForSend);
2793 }
27942790 })().catch(async (err: unknown) => {
27952791 if (createdServerThreadForLocalDraft && !turnStartSucceeded) {
27962792 await api.orchestration
apps/web/src/components/ComposerPromptEditor.tsx+1
......@@ -748,6 +748,7 @@ function ComposerPromptEditorInner({
748748 "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",
749749 className,
750750 )}
751 data-testid="composer-editor"
751752 aria-placeholder={placeholder}
752753 placeholder={<span />}
753754 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() {
14771477 <button
14781478 type="button"
14791479 aria-label={`Create new thread in ${project.name}`}
1480 data-testid="new-thread-button"
14801481 />
14811482 }
14821483 showOnHover
apps/web/src/composerDraftStore.ts+17
......@@ -1279,3 +1279,20 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
12791279export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState {
12801280 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
12811281}
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";
1515import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
1616import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
1717import { readNativeApi } from "../nativeApi";
18import { useComposerDraftStore } from "../composerDraftStore";
18import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore";
1919import { useStore } from "../store";
2020import { useTerminalStateStore } from "../terminalStateStore";
2121import { preferredTerminalEditor } from "../terminal-links";
......@@ -140,7 +140,6 @@ function EventRouter() {
140140 const navigate = useNavigate();
141141 const pathname = useRouterState({ select: (state) => state.location.pathname });
142142 const pathnameRef = useRef(pathname);
143 const lastConfigIssuesSignatureRef = useRef<string | null>(null);
144143 const handledBootstrapThreadIdRef = useRef<string | null>(null);
145144
146145 pathnameRef.current = pathname;
......@@ -159,6 +158,7 @@ function EventRouter() {
159158 if (disposed) return;
160159 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
161160 syncServerReadModel(snapshot);
161 clearPromotedDraftThreads(new Set(snapshot.threads.map((t) => t.id)));
162162 const draftThreadIds = Object.keys(
163163 useComposerDraftStore.getState().draftThreadsByThreadId,
164164 ) as ThreadId[];
......@@ -255,14 +255,13 @@ function EventRouter() {
255255 handledBootstrapThreadIdRef.current = payload.bootstrapThreadId;
256256 })().catch(() => undefined);
257257 });
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;
258262 const unsubServerConfigUpdated = onServerConfigUpdated((payload) => {
259 const signature = JSON.stringify(payload.issues);
260 if (lastConfigIssuesSignatureRef.current === signature) {
261 return;
262 }
263 lastConfigIssuesSignatureRef.current = signature;
264
265263 void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() });
264 if (!subscribed) return;
266265 const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings."));
267266 if (!issue) {
268267 toastManager.add({
......@@ -297,6 +296,7 @@ function EventRouter() {
297296 },
298297 });
299298 });
299 subscribed = true;
300300 return () => {
301301 disposed = true;
302302 needsProviderInvalidation = false;
apps/web/src/wsNativeApi.test.ts+65-95
......@@ -1,12 +1,18 @@
11import {
22 CommandId,
33 type ContextMenuItem,
4 EventId,
45 ORCHESTRATION_WS_CHANNELS,
56 ORCHESTRATION_WS_METHODS,
7 type OrchestrationEvent,
68 ProjectId,
79 ThreadId,
10 type WsPushChannel,
11 type WsPushData,
12 type WsPushMessage,
813 WS_CHANNELS,
914 WS_METHODS,
15 type WsPush,
1016 type ServerProviderStatus,
1117} from "@t3tools/contracts";
1218import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
......@@ -19,26 +25,38 @@ const showContextMenuFallbackMock =
1925 position?: { x: number; y: number },
2026 ) => Promise<T | null>
2127 >();
22const channelListeners = new Map<string, Set<(data: unknown) => void>>();
23const subscribeMock = vi.fn<(channel: string, listener: (data: unknown) => void) => () => void>(
24 (channel, listener) => {
25 const listeners = channelListeners.get(channel) ?? new Set<(data: unknown) => void>();
26 listeners.add(listener);
27 channelListeners.set(channel, listeners);
28 return () => {
29 listeners.delete(listener);
30 if (listeners.size === 0) {
31 channelListeners.delete(channel);
32 }
33 };
34 },
35);
28const channelListeners = new Map<string, Set<(message: WsPush) => void>>();
29const latestPushByChannel = new Map<string, WsPush>();
30const subscribeMock = vi.fn<
31 (
32 channel: string,
33 listener: (message: WsPush) => void,
34 options?: { replayLatest?: boolean },
35 ) => () => void
36>((channel, listener, options) => {
37 const listeners = channelListeners.get(channel) ?? new Set<(message: WsPush) => void>();
38 listeners.add(listener);
39 channelListeners.set(channel, listeners);
40 const latest = latestPushByChannel.get(channel);
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
3752vi.mock("./wsTransport", () => {
3853 return {
3954 WsTransport: class MockWsTransport {
4055 request = requestMock;
4156 subscribe = subscribeMock;
57 getLatestPush(channel: string) {
58 return latestPushByChannel.get(channel) ?? null;
59 }
4260 },
4361 };
4462});
......@@ -47,11 +65,20 @@ vi.mock("./contextMenuFallback", () => ({
4765 showContextMenuFallback: showContextMenuFallbackMock,
4866}));
4967
50function emitPush(channel: string, data: unknown): void {
68let nextPushSequence = 1;
69
70function emitPush<C extends WsPushChannel>(channel: C, data: WsPushData<C>): void {
5171 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);
5279 if (!listeners) return;
5380 for (const listener of listeners) {
54 listener(data);
81 listener(message);
5582 }
5683}
5784
......@@ -81,6 +108,8 @@ beforeEach(() => {
81108 showContextMenuFallbackMock.mockReset();
82109 subscribeMock.mockClear();
83110 channelListeners.clear();
111 latestPushByChannel.clear();
112 nextPushSequence = 1;
84113 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
85114});
86115
......@@ -90,7 +119,6 @@ afterEach(() => {
90119
91120describe("wsNativeApi", () => {
92121 it("delivers and caches valid server.welcome payloads", async () => {
93 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
94122 const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
95123
96124 createWsNativeApi();
......@@ -108,7 +136,6 @@ describe("wsNativeApi", () => {
108136
109137 expect(lateListener).toHaveBeenCalledTimes(1);
110138 expect(lateListener).toHaveBeenCalledWith(expect.objectContaining(payload));
111 expect(warnSpy).not.toHaveBeenCalled();
112139 });
113140
114141 it("preserves bootstrap ids from server.welcome payloads", async () => {
......@@ -121,8 +148,8 @@ describe("wsNativeApi", () => {
121148 emitPush(WS_CHANNELS.serverWelcome, {
122149 cwd: "/tmp/workspace",
123150 projectName: "t3-code",
124 bootstrapProjectId: "project-1",
125 bootstrapThreadId: "thread-1",
151 bootstrapProjectId: ProjectId.makeUnsafe("project-1"),
152 bootstrapThreadId: ThreadId.makeUnsafe("thread-1"),
126153 });
127154
128155 expect(listener).toHaveBeenCalledTimes(1);
......@@ -136,31 +163,26 @@ describe("wsNativeApi", () => {
136163 );
137164 });
138165
139 it("ignores invalid server.welcome payloads and keeps subscription active", async () => {
140 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
166 it("delivers successive server.welcome payloads to active listeners", async () => {
141167 const { createWsNativeApi, onServerWelcome } = await import("./wsNativeApi");
142168
143169 createWsNativeApi();
144170 const listener = vi.fn();
145171 onServerWelcome(listener);
146172
147 emitPush(WS_CHANNELS.serverWelcome, { cwd: 42, projectName: "t3-code" });
173 emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/one", projectName: "one" });
148174 emitPush(WS_CHANNELS.serverWelcome, { cwd: "/tmp/workspace", projectName: "t3-code" });
149175
150 expect(listener).toHaveBeenCalledTimes(1);
151 expect(listener).toHaveBeenCalledWith(
152 expect.objectContaining({ cwd: "/tmp/workspace", projectName: "t3-code" }),
176 expect(listener).toHaveBeenCalledTimes(2);
177 expect(listener).toHaveBeenLastCalledWith(
178 expect.objectContaining({
179 cwd: "/tmp/workspace",
180 projectName: "t3-code",
181 }),
153182 );
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 });
160183 });
161184
162185 it("delivers and caches valid server.configUpdated payloads", async () => {
163 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
164186 const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
165187
166188 createWsNativeApi();
......@@ -186,11 +208,9 @@ describe("wsNativeApi", () => {
186208 onServerConfigUpdated(lateListener);
187209 expect(lateListener).toHaveBeenCalledTimes(1);
188210 expect(lateListener).toHaveBeenCalledWith(payload);
189 expect(warnSpy).not.toHaveBeenCalled();
190211 });
191212
192 it("drops malformed server.configUpdated payloads", async () => {
193 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
213 it("delivers successive server.configUpdated payloads to active listeners", async () => {
194214 const { createWsNativeApi, onServerConfigUpdated } = await import("./wsNativeApi");
195215
196216 createWsNativeApi();
......@@ -198,24 +218,22 @@ describe("wsNativeApi", () => {
198218 onServerConfigUpdated(listener);
199219
200220 emitPush(WS_CHANNELS.serverConfigUpdated, {
201 issues: [{ kind: "keybindings.invalid-entry", message: "missing index" }],
221 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
202222 providers: defaultProviders,
203223 });
204224 emitPush(WS_CHANNELS.serverConfigUpdated, {
205 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
225 issues: [],
206226 providers: defaultProviders,
207227 });
208228
209 expect(listener).toHaveBeenCalledTimes(1);
210 expect(listener).toHaveBeenCalledWith({
211 issues: [{ kind: "keybindings.malformed-config", message: "bad json" }],
229 expect(listener).toHaveBeenCalledTimes(2);
230 expect(listener).toHaveBeenLastCalledWith({
231 issues: [],
212232 providers: defaultProviders,
213233 });
214 expect(warnSpy).toHaveBeenCalledTimes(1);
215234 });
216235
217236 it("forwards valid terminal and orchestration events", async () => {
218 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
219237 const { createWsNativeApi } = await import("./wsNativeApi");
220238
221239 const api = createWsNativeApi();
......@@ -236,9 +254,9 @@ describe("wsNativeApi", () => {
236254
237255 const orchestrationEvent = {
238256 sequence: 1,
239 eventId: "event-1",
257 eventId: EventId.makeUnsafe("event-1"),
240258 aggregateKind: "project",
241 aggregateId: "project-1",
259 aggregateId: ProjectId.makeUnsafe("project-1"),
242260 occurredAt: "2026-02-24T00:00:00.000Z",
243261 commandId: null,
244262 causationEventId: null,
......@@ -246,7 +264,7 @@ describe("wsNativeApi", () => {
246264 metadata: {},
247265 type: "project.created",
248266 payload: {
249 projectId: "project-1",
267 projectId: ProjectId.makeUnsafe("project-1"),
250268 title: "Project",
251269 workspaceRoot: "/tmp/workspace",
252270 defaultModel: null,
......@@ -254,61 +272,13 @@ describe("wsNativeApi", () => {
254272 createdAt: "2026-02-24T00:00:00.000Z",
255273 updatedAt: "2026-02-24T00:00:00.000Z",
256274 },
257 } as const;
275 } satisfies Extract<OrchestrationEvent, { type: "project.created" }>;
258276 emitPush(ORCHESTRATION_WS_CHANNELS.domainEvent, orchestrationEvent);
259277
260278 expect(onTerminalEvent).toHaveBeenCalledTimes(1);
261279 expect(onTerminalEvent).toHaveBeenCalledWith(terminalEvent);
262280 expect(onDomainEvent).toHaveBeenCalledTimes(1);
263281 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 });
312282 });
313283
314284 it("wraps orchestration dispatch commands in the command envelope", async () => {
apps/web/src/wsNativeApi.ts+16-45
......@@ -1,16 +1,13 @@
11import {
2 OrchestrationEvent,
32 ORCHESTRATION_WS_CHANNELS,
43 ORCHESTRATION_WS_METHODS,
54 type ContextMenuItem,
65 type NativeApi,
76 ServerConfigUpdatedPayload,
8 TerminalEvent,
97 WS_CHANNELS,
108 WS_METHODS,
11 WsWelcomePayload,
9 type WsWelcomePayload,
1210} from "@t3tools/contracts";
13import { Cause, Schema } from "effect";
1411
1512import { showContextMenuFallback } from "./contextMenuFallback";
1613import { WsTransport } from "./wsTransport";
......@@ -18,24 +15,6 @@ import { WsTransport } from "./wsTransport";
1815let instance: { api: NativeApi; transport: WsTransport } | null = null;
1916const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
2017const 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
4019/**
4120 * Subscribe to the server welcome message. If a welcome was already received
......@@ -45,10 +24,10 @@ const decodeAndWarnOnFailure = <T>(
4524export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void {
4625 welcomeListeners.add(listener);
4726
48 // Replay cached welcome for late subscribers
49 if (lastWelcome) {
27 const latestWelcome = instance?.transport.getLatestPush(WS_CHANNELS.serverWelcome)?.data ?? null;
28 if (latestWelcome) {
5029 try {
51 listener(lastWelcome);
30 listener(latestWelcome);
5231 } catch {
5332 // Swallow listener errors
5433 }
......@@ -68,9 +47,11 @@ export function onServerConfigUpdated(
6847): () => void {
6948 serverConfigUpdatedListeners.add(listener);
7049
71 if (lastServerConfigUpdated) {
50 const latestConfig =
51 instance?.transport.getLatestPush(WS_CHANNELS.serverConfigUpdated)?.data ?? null;
52 if (latestConfig) {
7253 try {
73 listener(lastServerConfigUpdated);
54 listener(latestConfig);
7455 } catch {
7556 // Swallow listener errors
7657 }
......@@ -86,12 +67,8 @@ export function createWsNativeApi(): NativeApi {
8667
8768 const transport = new WsTransport();
8869
89 // Listen for server welcome and forward to registered listeners.
90 // Also cache it so late subscribers (React effects) get it immediately.
91 transport.subscribe(WS_CHANNELS.serverWelcome, (data) => {
92 const payload = decodeAndWarnOnFailure(WsWelcomePayload, data);
93 if (!payload) return;
94 lastWelcome = payload;
70 transport.subscribe(WS_CHANNELS.serverWelcome, (message) => {
71 const payload = message.data;
9572 for (const listener of welcomeListeners) {
9673 try {
9774 listener(payload);
......@@ -100,10 +77,8 @@ export function createWsNativeApi(): NativeApi {
10077 }
10178 }
10279 });
103 transport.subscribe(WS_CHANNELS.serverConfigUpdated, (data) => {
104 const payload = decodeAndWarnOnFailure(ServerConfigUpdatedPayload, data);
105 if (!payload) return;
106 lastServerConfigUpdated = payload;
80 transport.subscribe(WS_CHANNELS.serverConfigUpdated, (message) => {
81 const payload = message.data;
10782 for (const listener of serverConfigUpdatedListeners) {
10883 try {
10984 listener(payload);
......@@ -134,10 +109,7 @@ export function createWsNativeApi(): NativeApi {
134109 restart: (input) => transport.request(WS_METHODS.terminalRestart, input),
135110 close: (input) => transport.request(WS_METHODS.terminalClose, input),
136111 onEvent: (callback) =>
137 transport.subscribe(WS_CHANNELS.terminalEvent, (data) => {
138 const payload = decodeAndWarnOnFailure(TerminalEvent, data);
139 if (payload) callback(payload);
140 }),
112 transport.subscribe(WS_CHANNELS.terminalEvent, (message) => callback(message.data)),
141113 },
142114 projects: {
143115 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
......@@ -199,10 +171,9 @@ export function createWsNativeApi(): NativeApi {
199171 replayEvents: (fromSequenceExclusive) =>
200172 transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),
201173 onDomainEvent: (callback) =>
202 transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (data) => {
203 const payload = decodeAndWarnOnFailure(OrchestrationEvent, data);
204 if (payload) callback(payload);
205 }),
174 transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (message) =>
175 callback(message.data),
176 ),
206177 },
207178 };
208179
apps/web/src/wsTransport.test.ts+53-19
......@@ -1,3 +1,4 @@
1import { WS_CHANNELS } from "@t3tools/contracts";
12import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
23
34import { WsTransport } from "./wsTransport";
......@@ -90,18 +91,24 @@ describe("WsTransport", () => {
9091 socket.open();
9192
9293 const listener = vi.fn();
93 transport.subscribe("providers.event", listener);
94 transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);
9495
9596 socket.serverMessage(
9697 JSON.stringify({
9798 type: "push",
98 channel: "providers.event",
99 data: { status: "ok" },
99 sequence: 1,
100 channel: WS_CHANNELS.serverConfigUpdated,
101 data: { issues: [], providers: [] },
100102 }),
101103 );
102104
103105 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
106113 transport.dispose();
107114 });
......@@ -137,12 +144,13 @@ describe("WsTransport", () => {
137144 socket.open();
138145
139146 const listener = vi.fn();
140 transport.subscribe("providers.event", listener);
147 transport.subscribe(WS_CHANNELS.serverConfigUpdated, listener);
141148
142149 socket.serverMessage("{ invalid-json");
143150 socket.serverMessage(
144151 JSON.stringify({
145152 type: "push",
153 sequence: 2,
146154 channel: 42,
147155 data: { bad: true },
148156 }),
......@@ -150,26 +158,52 @@ describe("WsTransport", () => {
150158 socket.serverMessage(
151159 JSON.stringify({
152160 type: "push",
153 channel: "providers.event",
154 data: { ok: true },
161 sequence: 3,
162 channel: WS_CHANNELS.serverConfigUpdated,
163 data: { issues: [], providers: [] },
155164 }),
156165 );
157166
158167 expect(listener).toHaveBeenCalledTimes(1);
159 expect(listener).toHaveBeenCalledWith({ ok: true });
160 expect(warnSpy).toHaveBeenCalledTimes(2);
161 expect(warnSpy).toHaveBeenNthCalledWith(1, "Dropped inbound WebSocket envelope", {
162 reason: "decode-failed",
163 issue:
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}}',
168 expect(listener).toHaveBeenCalledWith({
169 type: "push",
170 sequence: 3,
171 channel: WS_CHANNELS.serverConfigUpdated,
172 data: { issues: [], providers: [] },
171173 });
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: [] });
173207 transport.dispose();
174208 });
175209});
apps/web/src/wsTransport.ts+124-53
......@@ -1,7 +1,15 @@
1import { WebSocketResponse, WsPush, WsResponse } from "@t3tools/contracts";
2import { Cause, Schema } from "effect";
1import {
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
614interface PendingRequest {
715 resolve: (result: unknown) => void;
......@@ -9,12 +17,20 @@ interface PendingRequest {
917 timeout: ReturnType<typeof setTimeout>;
1018}
1119
20interface SubscribeOptions {
21 readonly replayLatest?: boolean;
22}
23
24type TransportState = "connecting" | "open" | "reconnecting" | "closed" | "disposed";
25
1226const REQUEST_TIMEOUT_MS = 60_000;
1327const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
14const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse));
15const isWsPushEnvelope = Schema.is(WsPush);
28const decodeWsResponse = decodeUnknownJsonResult(WsResponseSchema);
1629const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse);
1730
31const isWsPushMessage = (value: WsResponseMessage): value is WsPush =>
32 "type" in value && value.type === "push";
33
1834interface WsRequestEnvelope {
1935 id: string;
2036 body: {
......@@ -23,20 +39,28 @@ interface WsRequestEnvelope {
2339 };
2440}
2541
42function asError(value: unknown, fallback: string): Error {
43 if (value instanceof Error) {
44 return value;
45 }
46 return new Error(fallback);
47}
48
2649export class WsTransport {
2750 private ws: WebSocket | null = null;
2851 private nextId = 1;
2952 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[] = [];
3156 private reconnectAttempt = 0;
3257 private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
3358 private disposed = false;
59 private state: TransportState = "connecting";
3460 private readonly url: string;
3561
3662 constructor(url?: string) {
3763 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.
4064 const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
4165 this.url =
4266 url ??
......@@ -52,9 +76,11 @@ export class WsTransport {
5276 if (typeof method !== "string" || method.length === 0) {
5377 throw new Error("Request method is required");
5478 }
79
5580 const id = String(this.nextId++);
5681 const body = params != null ? { ...params, _tag: method } : { _tag: method };
5782 const message: WsRequestEnvelope = { id, body };
83 const encoded = JSON.stringify(message);
5884
5985 return new Promise<T>((resolve, reject) => {
6086 const timeout = setTimeout(() => {
......@@ -68,28 +94,53 @@ export class WsTransport {
6894 timeout,
6995 });
7096
71 this.send(message);
97 this.send(encoded);
7298 });
7399 }
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 {
76106 let channelListeners = this.listeners.get(channel);
77107 if (!channelListeners) {
78 channelListeners = new Set();
108 channelListeners = new Set<(message: WsPush) => void>();
79109 this.listeners.set(channel, channelListeners);
80110 }
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
83124 return () => {
84 channelListeners!.delete(listener);
85 if (channelListeners!.size === 0) {
125 channelListeners?.delete(wrappedListener);
126 if (channelListeners?.size === 0) {
86127 this.listeners.delete(channel);
87128 }
88129 };
89130 }
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
91141 dispose() {
92142 this.disposed = true;
143 this.state = "disposed";
93144 if (this.reconnectTimer !== null) {
94145 clearTimeout(this.reconnectTimer);
95146 this.reconnectTimer = null;
......@@ -99,18 +150,24 @@ export class WsTransport {
99150 pending.reject(new Error("Transport disposed"));
100151 }
101152 this.pending.clear();
153 this.outboundQueue.length = 0;
102154 this.ws?.close();
103155 this.ws = null;
104156 }
105157
106158 private connect() {
107 if (this.disposed) return;
159 if (this.disposed) {
160 return;
161 }
108162
163 this.state = this.reconnectAttempt > 0 ? "reconnecting" : "connecting";
109164 const ws = new WebSocket(this.url);
110165
111166 ws.addEventListener("open", () => {
112167 this.ws = ws;
168 this.state = "open";
113169 this.reconnectAttempt = 0;
170 this.flushQueue();
114171 });
115172
116173 ws.addEventListener("message", (event) => {
......@@ -118,34 +175,37 @@ export class WsTransport {
118175 });
119176
120177 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";
122186 this.scheduleReconnect();
123187 });
124188
125189 ws.addEventListener("error", () => {
126 // close event will fire after error
190 // close will follow
127191 });
128192 }
129193
130194 private handleMessage(raw: unknown) {
131 const exit = decodeWsResponseFromJson(raw);
132 if (exit._tag === "Failure") {
133 console.warn("Dropped inbound WebSocket envelope", {
134 reason: "decode-failed",
135 raw,
136 issue: Cause.pretty(exit.cause),
137 });
195 const result = decodeWsResponse(raw);
196 if (Result.isFailure(result)) {
197 console.warn("Dropped inbound WebSocket envelope", formatSchemaError(result.failure));
138198 return;
139199 }
140 const message = exit.value;
141200
142 // Push event
143 if (isWsPushEnvelope(message)) {
201 const message = result.success;
202 if (isWsPushMessage(message)) {
203 this.latestPushByChannel.set(message.channel, message);
144204 const channelListeners = this.listeners.get(message.channel);
145205 if (channelListeners) {
146206 for (const listener of channelListeners) {
147207 try {
148 listener(message.data);
208 listener(message);
149209 } catch {
150210 // Swallow listener errors
151211 }
......@@ -154,57 +214,68 @@ export class WsTransport {
154214 return;
155215 }
156216
157 // Response to a request
158217 if (!isWebSocketResponseEnvelope(message)) {
159218 return;
160219 }
161220
162221 const pending = this.pending.get(message.id);
163 if (!pending) return;
222 if (!pending) {
223 return;
224 }
164225
165226 clearTimeout(pending.timeout);
166227 this.pending.delete(message.id);
167228
168229 if (message.error) {
169230 pending.reject(new Error(message.error.message));
170 } else {
171 pending.resolve(message.result);
231 return;
172232 }
233
234 pending.resolve(message.result);
173235 }
174236
175 private send(message: WsRequestEnvelope) {
176 if (this.ws?.readyState === WebSocket.OPEN) {
177 this.ws.send(JSON.stringify(message));
237 private send(encodedMessage: string) {
238 if (this.disposed) {
178239 return;
179240 }
180241
181 // If not connected, wait for connection
182 const waitForOpen = () => {
183 const check = setInterval(() => {
184 if (this.disposed) {
185 clearInterval(check);
186 return;
187 }
188 if (this.ws?.readyState === WebSocket.OPEN) {
189 clearInterval(check);
190 this.ws.send(JSON.stringify(message));
191 }
192 }, 50);
242 this.outboundQueue.push(encodedMessage);
243 try {
244 this.flushQueue();
245 } catch {
246 // Swallow: flushQueue has queued the message for retry on reconnect
247 }
248 }
193249
194 // Give up after timeout (the pending request will time out on its own)
195 setTimeout(() => clearInterval(check), REQUEST_TIMEOUT_MS);
196 };
197 waitForOpen();
250 private flushQueue() {
251 if (this.ws?.readyState !== WebSocket.OPEN) {
252 return;
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 }
198267 }
199268
200269 private scheduleReconnect() {
201 if (this.disposed) return;
270 if (this.disposed || this.reconnectTimer !== null) {
271 return;
272 }
202273
203274 const delay =
204275 RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ??
205276 RECONNECT_DELAYS_MS[0]!;
206277
207 this.reconnectAttempt++;
278 this.reconnectAttempt += 1;
208279 this.reconnectTimer = setTimeout(() => {
209280 this.reconnectTimer = null;
210281 this.connect();
apps/web/vitest.browser.config.ts+4-1
......@@ -15,7 +15,10 @@ export default mergeConfig(
1515 },
1616 },
1717 test: {
18 include: ["src/components/ChatView.browser.tsx"],
18 include: [
19 "src/components/ChatView.browser.tsx",
20 "src/components/KeybindingsToast.browser.tsx",
21 ],
1922 browser: {
2023 enabled: true,
2124 provider: playwright(),
packages/contracts/src/ws.test.ts+43-3
......@@ -1,11 +1,11 @@
11import { assert, it } from "@effect/vitest";
22import { Effect, Schema } from "effect";
33
4import { ORCHESTRATION_WS_METHODS } from "./orchestration";
5import { WS_METHODS } from "./ws";
6import { WebSocketRequest } from "./ws";
4import { ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS } from "./orchestration";
5import { WebSocketRequest, WsResponse, WS_CHANNELS, WS_METHODS } from "./ws";
76
87const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest);
8const decodeWsResponse = Schema.decodeUnknownEffect(WsResponse);
99
1010it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () =>
1111 Effect.gen(function* () {
......@@ -72,3 +72,43 @@ it.effect("accepts git.preparePullRequestThread requests", () =>
7272 assert.strictEqual(parsed.body._tag, WS_METHODS.gitPreparePullRequestThread);
7373 }),
7474);
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 @@
11import { Schema, Struct } from "effect";
2import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
2import { NonNegativeInt, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas";
33
44import {
55 ClientOrchestrationCommand,
6 OrchestrationEvent,
7 ORCHESTRATION_WS_CHANNELS,
68 OrchestrationGetFullThreadDiffInput,
79 ORCHESTRATION_WS_METHODS,
810 OrchestrationGetSnapshotInput,
......@@ -25,6 +27,7 @@ import {
2527import {
2628 TerminalClearInput,
2729 TerminalCloseInput,
30 TerminalEvent,
2831 TerminalOpenInput,
2932 TerminalResizeInput,
3033 TerminalRestartInput,
......@@ -33,6 +36,7 @@ import {
3336import { KeybindingRule } from "./keybindings";
3437import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project";
3538import { OpenInEditorInput } from "./editor";
39import { ServerConfigUpdatedPayload } from "./server";
3640
3741// ── WebSocket RPC Method Names ───────────────────────────────────────
3842
......@@ -154,19 +158,8 @@ export const WebSocketResponse = Schema.Struct({
154158});
155159export type WebSocketResponse = typeof WebSocketResponse.Type;
156160
157export const WsPush = Schema.Struct({
158 type: Schema.Literal("push"),
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 ───────────────────────────────────────────
161export const WsPushSequence = NonNegativeInt;
162export type WsPushSequence = typeof WsPushSequence.Type;
170163
171164export const WsWelcomePayload = Schema.Struct({
172165 cwd: TrimmedNonEmptyString,
......@@ -175,3 +168,66 @@ export const WsWelcomePayload = Schema.Struct({
175168 bootstrapThreadId: Schema.optional(ThreadId),
176169});
177170export 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 @@
2323 "./Net": {
2424 "types": "./src/Net.ts",
2525 "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"
2634 }
2735 },
2836 "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};