History

nice refactor

Julius Marminge committed 8 months ago

Commit da3d8e21 parent 5cbb6ea4 files · +522 −596Browse files

Unified · Split

4 files
  1. apps/server/src/main.test.ts +23 −14
  2. apps/server/src/main.ts +26 −24
  3. apps/server/src/wsServer.test.ts +112 −106
  4. apps/server/src/wsServer.ts +361 −452
apps/server/src/main.test.ts+23−14
@@ -1,3 +1,4 @@
1+import * as Http from "node:http";
12 import { NodeServices } from "@effect/platform-node";
23 import { assert, it, vi } from "@effect/vitest";
34 import * as ConfigProvider from "effect/ConfigProvider";
@@ -10,13 +11,17 @@ import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; · 6 unmodified lines
1011 import { Open, type OpenShape } from "./open";
1112 import { Server, type ServerShape, type ServerOptions } from "./wsServer";
1213
13−const start = vi.fn(async () => undefined);
14−const stop = vi.fn(async () => undefined);
15−const createServer = vi.fn((_: ServerOptions) => ({
16− start,
17− stop,
18− httpServer: undefined as never,
19−}));
14+const start = vi.fn(() => undefined);
15+const stop = vi.fn(() => undefined);
16+const createServer = vi.fn((_: ServerOptions) =>
17+ Effect.acquireRelease(
18+ Effect.sync(() => {
19+ start();
20+ return {} as unknown as Http.Server;
21+ }),
22+ () => Effect.sync(() => stop()),
23+ ),
24+);
2025 const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
2126
2227 const testLayer = Layer.mergeAll(
@@ -47,13 +52,17 @@ const runCli = ( · 24 unmodified lines
4752
4853 beforeEach(() => {
4954 vi.clearAllMocks();
50− start.mockImplementation(async () => undefined);
51− stop.mockImplementation(async () => undefined);
52− createServer.mockImplementation((_: ServerOptions) => ({
53− start,
54− stop,
55− httpServer: undefined as never,
56− }));
55+ start.mockImplementation(() => undefined);
56+ stop.mockImplementation(() => undefined);
57+ createServer.mockImplementation((_: ServerOptions) =>
58+ Effect.acquireRelease(
59+ Effect.sync(() => {
60+ start();
61+ return {} as unknown as Http.Server;
62+ }),
63+ () => Effect.sync(() => stop()),
64+ ),
65+ );
5766 findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
5867 });
5968
apps/server/src/main.ts+26−24
@@ -6,7 +6,12 @@ import { Command, Flag } from "effect/unstable/cli"; · 5 unmodified lines
66
77 import { fixPath } from "./fixPath";
88 import { Open } from "./open";
9−import { Server } from "./wsServer";
9+import {
10+ makeServerPersistenceLayer,
11+ makeServerProviderLayer,
12+ makeServerRuntimeServicesLayer,
13+ Server,
14+} from "./wsServer";
1015
1116 const DEFAULT_PORT = 3773;
1217
@@ -168,29 +173,26 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) { · 155 unmodified lines
168173 });
169174 }
170175
171− yield* Effect.acquireRelease(
172− Effect.tryPromise({
173− try: async () => {
174− const server = createServer({
175− port: config.port,
176− host: config.mode === "desktop" ? "127.0.0.1" : undefined,
177− cwd: config.cwd,
178− autoBootstrapProjectFromCwd: config.mode === "web",
179− stateDir: config.stateDir,
180− staticDir: config.staticDir,
181− devUrl: config.devUrl?.href,
182− authToken: config.authToken,
183− });
184− await server.start();
185− return server;
186− },
187− catch: (cause) => new StartupError({ message: "Failed to start server", cause }),
188− }),
189− (server) =>
190− Effect.tryPromise({
191− try: () => server.stop(),
192− catch: (cause) => new StartupError({ message: "Failed to stop server", cause }),
193− }).pipe(Effect.catch((error) => Effect.logError("failed to stop server cleanly", { error }))),
176+ const persistenceLayer = makeServerPersistenceLayer(config.stateDir);
177+ const providerLayer = makeServerProviderLayer(config.stateDir);
178+ const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
179+ const serverRuntimeLayer = Layer.merge(
180+ makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),
181+ infrastructureLayer,
182+ );
183+
184+ yield* createServer({
185+ port: config.port,
186+ host: config.mode === "desktop" ? "127.0.0.1" : undefined,
187+ cwd: config.cwd,
188+ autoBootstrapProjectFromCwd: config.mode === "web",
189+ stateDir: config.stateDir,
190+ staticDir: config.staticDir,
191+ devUrl: config.devUrl?.href,
192+ authToken: config.authToken,
193+ }).pipe(
194+ Effect.provide(serverRuntimeLayer),
195+ Effect.mapError((cause) => new StartupError({ message: "Failed to start server", cause })),
194196 );
195197
196198 const url = `http://localhost:${config.port}`;
apps/server/src/wsServer.test.ts+112−106
@@ -1,11 +1,12 @@
1+import * as Http from "node:http";
12 import fs from "node:fs";
23 import os from "node:os";
34 import path from "node:path";
45 import { EventEmitter } from "node:events";
56
6−import { Effect, Layer, PubSub, Stream } from "effect";
7+import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";
78 import { describe, expect, it, afterEach, vi } from "vitest";
8−import { createServer } from "./wsServer";
9+import { createServer, makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./wsServer";
910 import WebSocket from "ws";
1011
1112 import {
@@ -49,7 +50,8 @@ interface PendingMessages { · 37 unmodified lines
4950 const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
5051
5152 const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
52−const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
53+const asProviderSessionId = (value: string): ProviderSessionId =>
54+ ProviderSessionId.makeUnsafe(value);
5355 const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
5456 const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
5557 const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
@@ -286,7 +288,8 @@ function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybinding · 230 unmodified lines
286288 }
287289
288290 describe("WebSocket Server", () => {
289− let server: ReturnType<typeof createServer> | null = null;
291+ let server: Http.Server | null = null;
292+ let serverScope: Scope.Closeable | null = null;
290293 const connections: WebSocket[] = [];
291294 const tempDirs: string[] = [];
292295
@@ -296,7 +299,7 @@ describe("WebSocket Server", () => { · 3 unmodified lines
296299 return dir;
297300 }
298301
299− function createTestServer(
302+ async function createTestServer(
300303 options: {
301304 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, any>;
302305 cwd?: string;
@@ -312,23 +315,53 @@ describe("WebSocket Server", () => { · 9 unmodified lines
312315 };
313316 terminalManager?: TerminalManager;
314317 } = {},
315− ): ReturnType<typeof createServer> {
318+ ): Promise<Http.Server> {
319+ if (serverScope) {
320+ throw new Error("Test server is already running");
321+ }
322+
316323 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");
317− return createServer({
318− port: 0,
319− cwd: options.cwd ?? "/test/project",
320− stateDir,
321− persistenceLayer: options.persistenceLayer ?? SqlitePersistenceMemory,
322− ...(options.autoBootstrapProjectFromCwd !== undefined
323− ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd }
324− : {}),
325− ...(options.devUrl ? { devUrl: options.devUrl } : {}),
326− ...(options.authToken ? { authToken: options.authToken } : {}),
327− ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}),
328− ...(options.open ? { open: options.open } : {}),
329− ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
330− ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),
331− });
324+ const scope = await Effect.runPromise(Scope.make("sequential"));
325+ const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
326+ const providerLayer = options.providerLayer ?? makeServerProviderLayer(stateDir);
327+ const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
328+ const runtimeLayer = Layer.merge(
329+ makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),
330+ infrastructureLayer,
331+ );
332+ const runtimeServices = await Effect.runPromise(
333+ Layer.build(runtimeLayer).pipe(Scope.provide(scope)),
334+ );
335+
336+ try {
337+ const runtime = await Effect.runPromise(
338+ createServer({
339+ port: 0,
340+ cwd: options.cwd ?? "/test/project",
341+ stateDir,
342+ ...(options.autoBootstrapProjectFromCwd !== undefined
343+ ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd }
344+ : {}),
345+ ...(options.devUrl ? { devUrl: options.devUrl } : {}),
346+ ...(options.authToken ? { authToken: options.authToken } : {}),
347+ ...(options.open ? { open: options.open } : {}),
348+ ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
349+ ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),
350+ }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)),
351+ );
352+ serverScope = scope;
353+ return runtime;
354+ } catch (error) {
355+ await Effect.runPromise(Scope.close(scope, Exit.void));
356+ throw error;
357+ }
358+ }
359+
360+ async function closeTestServer() {
361+ if (!serverScope) return;
362+ const scope = serverScope;
363+ serverScope = null;
364+ await Effect.runPromise(Scope.close(scope, Exit.void));
332365 }
333366
334367 afterEach(async () => {
@@ -336,9 +369,7 @@ describe("WebSocket Server", () => { · 1 unmodified line
336369 ws.close();
337370 }
338371 connections.length = 0;
339− if (server) {
340− await server.stop();
341− }
372+ await closeTestServer();
342373 server = null;
343374 for (const dir of tempDirs.splice(0, tempDirs.length)) {
344375 fs.rmSync(dir, { recursive: true, force: true });
@@ -347,10 +378,9 @@ describe("WebSocket Server", () => { · 2 unmodified lines
347378 });
348379
349380 it("sends welcome message on connect", async () => {
350− server = createTestServer({ cwd: "/test/project" });
381+ server = await createTestServer({ cwd: "/test/project" });
351382 // Get the actual port after listen
352− await server.start();
353− const addr = server.httpServer.address();
383+ const addr = server.address();
354384 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
355385 expect(port).toBeGreaterThan(0);
356386
@@ -367,12 +397,11 @@ describe("WebSocket Server", () => { · 10 unmodified lines
367397 });
368398
369399 it("bootstraps the cwd project on startup when enabled", async () => {
370− server = createTestServer({
400+ server = await createTestServer({
371401 cwd: "/test/bootstrap-workspace",
372402 autoBootstrapProjectFromCwd: true,
373403 });
374− await server.start();
375− const addr = server.httpServer.address();
404+ const addr = server.address();
376405 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
377406 expect(port).toBeGreaterThan(0);
378407
@@ -402,12 +431,11 @@ describe("WebSocket Server", () => { · 23 unmodified lines
402431 // Keep test output clean while verifying websocket logs.
403432 });
404433
405− server = createTestServer({
434+ server = await createTestServer({
406435 cwd: "/test/project",
407436 devUrl: "http://localhost:5173",
408437 });
409− await server.start();
410− const addr = server.httpServer.address();
438+ const addr = server.address();
411439 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
412440 expect(port).toBeGreaterThan(0);
413441
@@ -430,9 +458,8 @@ describe("WebSocket Server", () => { · 16 unmodified lines
430458 it("responds to server.getConfig", async () => {
431459 const fakeHome = makeTempDir("t3code-home-");
432460 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
433− server = createTestServer({ cwd: "/my/workspace" });
434− await server.start();
435− const addr = server.httpServer.address();
461+ server = await createTestServer({ cwd: "/my/workspace" });
462+ const addr = server.address();
436463 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
437464
438465 const ws = await connectWs(port);
@@ -459,9 +486,8 @@ describe("WebSocket Server", () => { · 20 unmodified lines
459486 },
460487 };
461488
462− server = createTestServer({ cwd: "/my/workspace", open: openService });
463− await server.start();
464− const addr = server.httpServer.address();
489+ server = await createTestServer({ cwd: "/my/workspace", open: openService });
490+ const addr = server.address();
465491 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
466492
467493 const ws = await connectWs(port);
@@ -491,9 +517,8 @@ describe("WebSocket Server", () => { · 23 unmodified lines
491517 );
492518 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
493519
494− server = createTestServer({ cwd: "/my/workspace" });
495− await server.start();
496− const addr = server.httpServer.address();
520+ server = await createTestServer({ cwd: "/my/workspace" });
521+ const addr = server.address();
497522 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
498523
499524 const ws = await connectWs(port);
@@ -528,9 +553,8 @@ describe("WebSocket Server", () => { · 28 unmodified lines
528553 );
529554 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
530555
531− server = createTestServer({ cwd: "/my/workspace" });
532− await server.start();
533− const addr = server.httpServer.address();
556+ server = await createTestServer({ cwd: "/my/workspace" });
557+ const addr = server.address();
534558 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
535559
536560 const ws = await connectWs(port);
@@ -563,9 +587,8 @@ describe("WebSocket Server", () => { · 26 unmodified lines
563587 );
564588 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
565589
566− server = createTestServer({ cwd: "/my/workspace" });
567− await server.start();
568− const addr = server.httpServer.address();
590+ server = await createTestServer({ cwd: "/my/workspace" });
591+ const addr = server.address();
569592 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
570593
571594 const ws = await connectWs(port);
@@ -598,9 +621,8 @@ describe("WebSocket Server", () => { · 26 unmodified lines
598621 );
599622 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
600623
601− server = createTestServer({ cwd: "/my/workspace" });
602− await server.start();
603− const addr = server.httpServer.address();
624+ server = await createTestServer({ cwd: "/my/workspace" });
625+ const addr = server.address();
604626 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
605627
606628 const ws = await connectWs(port);
@@ -637,9 +659,8 @@ describe("WebSocket Server", () => { · 30 unmodified lines
637659 );
638660 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
639661
640− server = createTestServer({ cwd: "/my/workspace" });
641− await server.start();
642− const addr = server.httpServer.address();
662+ server = await createTestServer({ cwd: "/my/workspace" });
663+ const addr = server.address();
643664 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
644665
645666 const ws = await connectWs(port);
@@ -690,9 +711,8 @@ describe("WebSocket Server", () => { · 44 unmodified lines
690711 );
691712 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
692713
693− server = createTestServer({ cwd: "/my/workspace" });
694− await server.start();
695− const addr = server.httpServer.address();
714+ server = await createTestServer({ cwd: "/my/workspace" });
715+ const addr = server.address();
696716 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
697717
698718 const ws = await connectWs(port);
@@ -721,9 +741,8 @@ describe("WebSocket Server", () => { · 22 unmodified lines
721741 fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8");
722742 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
723743
724− server = createTestServer({ cwd: "/my/workspace" });
725− await server.start();
726− const addr = server.httpServer.address();
744+ server = await createTestServer({ cwd: "/my/workspace" });
745+ const addr = server.address();
727746 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
728747
729748 const ws = await connectWs(port);
@@ -745,9 +764,8 @@ describe("WebSocket Server", () => { · 15 unmodified lines
745764 });
746765
747766 it("returns error for unknown methods", async () => {
748− server = createTestServer({ cwd: "/test" });
749− await server.start();
750− const addr = server.httpServer.address();
767+ server = await createTestServer({ cwd: "/test" });
768+ const addr = server.address();
751769 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
752770
753771 const ws = await connectWs(port);
@@ -762,9 +780,8 @@ describe("WebSocket Server", () => { · 8 unmodified lines
762780 });
763781
764782 it("returns error when requesting turn diff for unknown thread", async () => {
765− server = createTestServer({ cwd: "/test" });
766− await server.start();
767− const addr = server.httpServer.address();
783+ server = await createTestServer({ cwd: "/test" });
784+ const addr = server.address();
768785 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
769786
770787 const ws = await connectWs(port);
@@ -781,9 +798,8 @@ describe("WebSocket Server", () => { · 10 unmodified lines
781798 });
782799
783800 it("returns error when requesting full thread diff for unknown thread", async () => {
784− server = createTestServer({ cwd: "/test" });
785− await server.start();
786− const addr = server.httpServer.address();
801+ server = await createTestServer({ cwd: "/test" });
802+ const addr = server.address();
787803 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
788804
789805 const ws = await connectWs(port);
@@ -799,9 +815,8 @@ describe("WebSocket Server", () => { · 9 unmodified lines
799815 });
800816
801817 it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => {
802− server = createTestServer({ cwd: "/test" });
803− await server.start();
804− const addr = server.httpServer.address();
818+ server = await createTestServer({ cwd: "/test" });
819+ const addr = server.address();
805820 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
806821
807822 const ws = await connectWs(port);
@@ -873,12 +888,11 @@ describe("WebSocket Server", () => { · 65 unmodified lines
873888 };
874889 const providerLayer = Layer.succeed(ProviderService, providerService);
875890
876− server = createTestServer({
891+ server = await createTestServer({
877892 cwd: "/test",
878893 providerLayer,
879894 });
880− await server.start();
881− const addr = server.httpServer.address();
895+ const addr = server.address();
882896 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
883897
884898 const ws = await connectWs(port);
@@ -941,7 +955,9 @@ describe("WebSocket Server", () => { · 56 unmodified lines
941955
942956 const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
943957 const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };
944− return event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1";
958+ return (
959+ event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1"
960+ );
945961 });
946962
947963 const domainEvent = domainPush.data as {
@@ -956,12 +972,11 @@ describe("WebSocket Server", () => { · 8 unmodified lines
956972 it("routes terminal RPC methods and broadcasts terminal events", async () => {
957973 const cwd = makeTempDir("t3code-ws-terminal-cwd-");
958974 const terminalManager = new MockTerminalManager();
959− server = createTestServer({
975+ server = await createTestServer({
960976 cwd: "/test",
961977 terminalManager: terminalManager as unknown as TerminalManager,
962978 });
963− await server.start();
964− const addr = server.httpServer.address();
979+ const addr = server.address();
965980 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
966981
967982 const ws = await connectWs(port);
@@ -1027,24 +1042,22 @@ describe("WebSocket Server", () => { · 59 unmodified lines
10271042
10281043 it("detaches terminal event listener on stop for injected manager", async () => {
10291044 const terminalManager = new MockTerminalManager();
1030− server = createTestServer({
1045+ server = await createTestServer({
10311046 cwd: "/test",
10321047 terminalManager: terminalManager as unknown as TerminalManager,
10331048 });
1034− await server.start();
10351049
10361050 expect(terminalManager.listenerCount("event")).toBe(1);
10371051
1038− await server.stop();
1052+ await closeTestServer();
10391053 server = null;
10401054
10411055 expect(terminalManager.listenerCount("event")).toBe(0);
10421056 });
10431057
10441058 it("returns validation errors for invalid terminal open params", async () => {
1045− server = createTestServer({ cwd: "/test" });
1046− await server.start();
1047− const addr = server.httpServer.address();
1059+ server = await createTestServer({ cwd: "/test" });
1060+ const addr = server.address();
10481061 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10491062
10501063 const ws = await connectWs(port);
@@ -1061,9 +1074,8 @@ describe("WebSocket Server", () => { · 10 unmodified lines
10611074 });
10621075
10631076 it("handles invalid JSON gracefully", async () => {
1064− server = createTestServer({ cwd: "/test" });
1065− await server.start();
1066− const addr = server.httpServer.address();
1077+ server = await createTestServer({ cwd: "/test" });
1078+ const addr = server.address();
10671079 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10681080
10691081 const ws = await connectWs(port);
@@ -1096,9 +1108,8 @@ describe("WebSocket Server", () => { · 26 unmodified lines
10961108 });
10971109
10981110 it("returns errors for removed projects CRUD methods", async () => {
1099− server = createTestServer({ cwd: "/test" });
1100− await server.start();
1101− const addr = server.httpServer.address();
1111+ server = await createTestServer({ cwd: "/test" });
1112+ const addr = server.address();
11021113 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
11031114
11041115 const ws = await connectWs(port);
@@ -1134,9 +1145,8 @@ describe("WebSocket Server", () => { · 29 unmodified lines
11341145 fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
11351146 fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
11361147
1137− server = createTestServer({ cwd: "/test" });
1138− await server.start();
1139− const addr = server.httpServer.address();
1148+ server = await createTestServer({ cwd: "/test" });
1149+ const addr = server.address();
11401150 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
11411151
11421152 const ws = await connectWs(port);
@@ -1161,9 +1171,8 @@ describe("WebSocket Server", () => { · 18 unmodified lines
11611171 it("supports git methods over websocket", async () => {
11621172 const repoCwd = makeTempDir("t3code-ws-git-project-");
11631173
1164− server = createTestServer({ cwd: "/test" });
1165− await server.start();
1166− const addr = server.httpServer.address();
1174+ server = await createTestServer({ cwd: "/test" });
1175+ const addr = server.address();
11671176 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
11681177
11691178 const ws = await connectWs(port);
@@ -1209,9 +1218,8 @@ describe("WebSocket Server", () => { · 39 unmodified lines
12091218 runStackedAction: vi.fn(),
12101219 };
12111220
1212− server = createTestServer({ cwd: "/test", gitManager });
1213− await server.start();
1214− const addr = server.httpServer.address();
1221+ server = await createTestServer({ cwd: "/test", gitManager });
1222+ const addr = server.address();
12151223 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
12161224
12171225 const ws = await connectWs(port);
@@ -1232,9 +1240,8 @@ describe("WebSocket Server", () => { · 14 unmodified lines
12321240 runStackedAction: vi.fn().mockRejectedValue(new Error("Cannot push from detached HEAD.")),
12331241 };
12341242
1235− server = createTestServer({ cwd: "/test", gitManager });
1236− await server.start();
1237− const addr = server.httpServer.address();
1243+ server = await createTestServer({ cwd: "/test", gitManager });
1244+ const addr = server.address();
12381245 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
12391246
12401247 const ws = await connectWs(port);
@@ -1254,9 +1261,8 @@ describe("WebSocket Server", () => { · 13 unmodified lines
12541261 });
12551262
12561263 it("rejects websocket connections without a valid auth token", async () => {
1257− server = createTestServer({ cwd: "/test", authToken: "secret-token" });
1258− await server.start();
1259− const addr = server.httpServer.address();
1264+ server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
1265+ const addr = server.address();
12601266 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
12611267
12621268 await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");
apps/server/src/wsServer.ts+361−452
@@ -12,11 +12,12 @@ import { · 11 unmodified lines
1212 WS_CHANNELS,
1313 WS_METHODS,
1414 WebSocketRequest,
15+ WebSocketResponse,
1516 WsPush,
1617 WsResponse,
1718 } from "@t3tools/contracts";
18−import { NodeServices } from "@effect/platform-node";
19−import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, ServiceMap, Stream } from "effect";
19+import { NodeHttpServer, NodeServices } from "@effect/platform-node";
20+import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect";
2021 import { WebSocketServer, type WebSocket } from "ws";
2122
2223 import { createLogger } from "./logger";
@@ -40,34 +41,21 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma · 17 unmodified lines
4041 import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
4142 import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
4243 import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
43−import {
44− OrchestrationEngineService,
45− type OrchestrationEngineShape,
46−} from "./orchestration/Services/OrchestrationEngine";
47−import {
48− ProjectionSnapshotQuery,
49− type ProjectionSnapshotQueryShape,
50−} from "./orchestration/Services/ProjectionSnapshotQuery";
44+import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
45+import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
5146 import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
5247 import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
53−import assert from "node:assert";
5448 import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
5549 import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
5650 import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
5751 import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
5852 import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
5953 import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
60−import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
54+import { ProviderService } from "./provider/Services/ProviderService";
6155 import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
6256 import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
63−import {
64− CheckpointStore,
65− type CheckpointStoreShape,
66−} from "./checkpointing/Services/CheckpointStore";
67−import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
57+import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
6858 import * as SqlClient from "effect/unstable/sql/SqlClient";
69−import * as SqlError from "effect/unstable/sql/SqlError";
70−import * as Migrator from "effect/unstable/sql/Migrator";
7159 import { clamp } from "effect/Number";
7260 import { OpenDefaultService, type OpenShape } from "./open";
7361
@@ -92,37 +80,24 @@ export interface ServerOptions { · 18 unmodified lines
9280 cwd: string;
9381 autoBootstrapProjectFromCwd?: boolean | undefined;
9482 stateDir?: string | undefined;
95− persistenceLayer?:
96− | Layer.Layer<SqlClient.SqlClient, SqlError.SqlError | Migrator.MigrationError>
97− | undefined;
9883 staticDir?: string | undefined;
9984 devUrl?: string | undefined;
10085 logWebSocketEvents?: boolean | undefined;
10186 gitManager?: GitManager | undefined;
10287 terminalManager?: TerminalManager | undefined;
103− providerLayer?: Layer.Layer<ProviderService, unknown> | undefined;
10488 authToken?: string | undefined;
10589 open?: OpenShape | undefined;
10690 }
10791
108−export interface ServerRuntime {
109− readonly start: () => Promise<void>;
110− readonly stop: () => Promise<void>;
111− readonly httpServer: http.Server;
112−}
113−
11492 export interface ServerShape {
115− readonly createServer: (options: ServerOptions) => ServerRuntime;
93+ readonly createServer: (
94+ options: ServerOptions,
95+ ) => Effect.Effect<http.Server, unknown, Scope.Scope | ServerRuntimeServices>;
11696 readonly stopSignal: Effect.Effect<unknown, never>;
11797 }
11898
11999 export class Server extends ServiceMap.Service<Server, ServerShape>()("server/Server") {}
120100
121−export const ServerLive = Layer.succeed(Server, {
122− createServer,
123− stopSignal: Effect.never,
124−} satisfies ServerShape);
125−
126101 const isServerNotRunningError = (error: unknown): boolean => {
127102 if (!(error instanceof Error)) return false;
128103 const maybeCode = (error as NodeJS.ErrnoException).code;
@@ -195,47 +170,104 @@ function checkpointRefForThreadTurn(threadId: string, turnCount: number): Checkp · 66 unmodified lines
195170 return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`);
196171 }
197172
198−type EffectRuntime = ManagedRuntime.ManagedRuntime<
173+export type ServerCoreRuntimeServices =
199174 | OrchestrationEngineService
200175 | ProjectionSnapshotQuery
201− | ProviderService
202176 | CheckpointStore
203− | OrchestrationReactor,
204− unknown
205−>;
177+ | OrchestrationReactor;
178+
179+export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService;
206180
207−export function createServer(options: ServerOptions): ServerRuntime {
181+export function makeServerPersistenceLayer(stateDir: string) {
182+ const dbPath = path.join(stateDir, "orchestration.sqlite");
183+ return makeSqlitePersistenceLive(dbPath);
184+}
185+
186+export function makeServerProviderLayer(
187+ stateDir: string,
188+): Layer.Layer<ProviderService, unknown, SqlClient.SqlClient> {
189+ const providerLogsDir = path.join(stateDir, "logs", "providers");
190+ const codexAdapterLayer = makeCodexAdapterLive({
191+ nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),
192+ });
193+ const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer));
194+ const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
195+ Layer.provide(ProviderSessionRuntimeRepositoryLive),
196+ );
197+ return makeProviderServiceLive({
198+ canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
199+ }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
200+}
201+
202+export function makeServerRuntimeServicesLayer(): Layer.Layer<
203+ ServerCoreRuntimeServices,
204+ unknown,
205+ SqlClient.SqlClient | ProviderService
206+> {
207+ const orchestrationLayer = OrchestrationEngineLive.pipe(
208+ Layer.provide(OrchestrationProjectionPipelineLive),
209+ Layer.provide(OrchestrationEventStoreLive),
210+ Layer.provide(OrchestrationCommandReceiptRepositoryLive),
211+ );
212+ const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
213+
214+ const runtimeServicesLayer = Layer.mergeAll(
215+ orchestrationLayer,
216+ OrchestrationProjectionSnapshotQueryLive,
217+ checkpointStoreLayer,
218+ );
219+ const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
220+ Layer.provideMerge(runtimeServicesLayer),
221+ );
222+ const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
223+ Layer.provideMerge(runtimeServicesLayer),
224+ );
225+ const checkpointReactorLayer = CheckpointReactorLive.pipe(
226+ Layer.provideMerge(runtimeServicesLayer),
227+ );
228+ const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
229+ Layer.provideMerge(runtimeIngestionLayer),
230+ Layer.provideMerge(providerCommandReactorLayer),
231+ Layer.provideMerge(checkpointReactorLayer),
232+ );
233+
234+ return orchestrationReactorLayer;
235+}
236+
237+export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
238+ "ServerLifecycleError",
239+ {
240+ operation: Schema.String,
241+ cause: Schema.optional(Schema.Defect),
242+ },
243+) {}
244+
245+class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("RouteRequestError", {
246+ message: Schema.String,
247+}) {}
248+
249+export const createServer = Effect.fn(function* (
250+ options: ServerOptions,
251+): Effect.fn.Return<http.Server, unknown, Scope.Scope | ServerRuntimeServices> {
208252 const {
209253 port,
210254 host,
211255 cwd,
212256 autoBootstrapProjectFromCwd = false,
213− stateDir,
214− persistenceLayer: customPersistenceLayer,
215257 staticDir,
216258 devUrl,
217259 logWebSocketEvents: explicitLogWsEvents,
218260 gitManager = new GitManager(),
219261 terminalManager = new TerminalManager(),
220− providerLayer: customProviderLayer,
221262 authToken,
222263 open: openService = OpenDefaultService,
223264 } = options;
224265
225− const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");
226− let effectRuntime: EffectRuntime | null = null;
227− let orchestrationEngine: OrchestrationEngineShape | null = null;
228− let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null;
229− let providerService: ProviderServiceShape | null = null;
230− let checkpointStore: CheckpointStoreShape | null = null;
231266 const clients = new Set<WebSocket>();
232267 const logger = createLogger("ws");
233268 const logWebSocketEvents =
234269 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
235270 let keybindingsConfig = loadResolvedKeybindingsConfig(logger);
236− let orchestrationDomainEventLogger: EventNdjsonLogger | undefined;
237− let orchestrationCommandLogger: EventNdjsonLogger | undefined;
238− let subscriptionsScope: Scope.Closeable | null = null;
239271
240272 function logOutgoingPush(push: WsPush, recipients: number) {
241273 if (!logWebSocketEvents) return;
@@ -246,86 +278,6 @@ export function createServer(options: ServerOptions): ServerRuntime { · 4 unmodified lines
246278 });
247279 }
248280
249− const getOrchestrationEngine = () => {
250− assert(orchestrationEngine, "Orchestration engine is not started");
251− return orchestrationEngine;
252− };
253−
254− const getEffectRuntime = () => {
255− assert(effectRuntime, "Effect runtime is not started");
256− return effectRuntime;
257− };
258−
259− const getProjectionSnapshotQuery = () => {
260− assert(projectionSnapshotQuery, "Projection snapshot query is not started");
261− return projectionSnapshotQuery;
262− };
263−
264− const getCheckpointStore = () => {
265− assert(checkpointStore, "Checkpoint store is not started");
266− return checkpointStore;
267− };
268−
269− async function bootstrapProjectForCwd() {
270− if (!autoBootstrapProjectFromCwd) {
271− return;
272− }
273−
274− const runtime = getEffectRuntime();
275− const snapshotQuery = getProjectionSnapshotQuery();
276− const engine = getOrchestrationEngine();
277− const snapshot = await runtime.runPromise(snapshotQuery.getSnapshot());
278− const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd);
279− if (existing) {
280− return;
281− }
282−
283− const createdAt = new Date().toISOString();
284− const projectName = path.basename(cwd) || "project";
285− await runtime.runPromise(
286− engine.dispatchUnknownCommand({
287− type: "project.create",
288− commandId: crypto.randomUUID(),
289− projectId: crypto.randomUUID(),
290− title: projectName,
291− workspaceRoot: cwd,
292− defaultModel: "gpt-5-codex",
293− createdAt,
294− }),
295− );
296− }
297−
298− const attachSubscriptionss = Effect.gen(function* () {
299− const orchestrationEngine = yield* OrchestrationEngineService;
300− const orchestrationReactor = yield* OrchestrationReactor;
301− subscriptionsScope = yield* Scope.make("sequential");
302−
303− yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
304− Effect.sync(() => {
305− orchestrationDomainEventLogger?.write({
306− observedAt: new Date().toISOString(),
307− event,
308− });
309− const push: WsPush = {
310− type: "push",
311− channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
312− data: event,
313− };
314− const message = JSON.stringify(push);
315− let recipients = 0;
316− for (const client of clients) {
317− if (client.readyState === client.OPEN) {
318− client.send(message);
319− recipients += 1;
320− }
321− }
322− logOutgoingPush(push, recipients);
323− }),
324− ).pipe(Effect.forkIn(subscriptionsScope));
325−
326− yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope));
327− });
328−
329281 const onTerminalEvent = (event: TerminalEvent) => {
330282 const push: WsPush = {
331283 type: "push",
@@ -342,7 +294,6 @@ export function createServer(options: ServerOptions): ServerRuntime { · 10 unmodified lines
342294 }
343295 logOutgoingPush(push, recipients);
344296 };
345− terminalManager.on("event", onTerminalEvent);
346297
347298 // HTTP server — serves static files or redirects to Vite dev server
348299 const httpServer = http.createServer((req, res) => {
@@ -403,104 +354,85 @@ export function createServer(options: ServerOptions): ServerRuntime { · 54 unmodified lines
403354 // WebSocket server — upgrades from the HTTP server
404355 const wss = new WebSocketServer({ noServer: true });
405356
406− httpServer.on("upgrade", (request, socket, head) => {
407− if (authToken) {
408− let providedToken: string | null = null;
409− try {
410− const url = new URL(request.url ?? "/", `http://localhost:${port}`);
411− providedToken = url.searchParams.get("token");
412− } catch {
413− rejectUpgrade(socket, 400, "Invalid WebSocket URL");
414− return;
415− }
416−
417− if (providedToken !== authToken) {
418− rejectUpgrade(socket, 401, "Unauthorized WebSocket connection");
419− return;
357+ const closeWebSocketServer = Effect.callback<void, ServerLifecycleError>((resume) => {
358+ wss.close((error) => {
359+ if (error && !isServerNotRunningError(error)) {
360+ resume(
361+ Effect.fail(
362+ new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }),
363+ ),
364+ );
365+ } else {
366+ resume(Effect.void);
420367 }
421− }
422−
423− wss.handleUpgrade(request, socket, head, (ws) => {
424− wss.emit("connection", ws, request);
425368 });
426369 });
427370
428− wss.on("connection", (ws) => {
429− clients.add(ws);
430−
431− // Send welcome message with project info
432− const segments = cwd.split(/[/\\]/).filter(Boolean);
433− const projectName = segments[segments.length - 1] ?? "project";
434−
435− const welcome: WsPush = {
436− type: "push",
437− channel: WS_CHANNELS.serverWelcome,
438− data: { cwd, projectName },
439− };
440− logOutgoingPush(welcome, 1);
441− ws.send(JSON.stringify(welcome));
371+ const closeAllClients = () => {
372+ for (const client of clients) {
373+ client.close();
374+ }
375+ clients.clear();
376+ };
442377
443− ws.on("message", (raw) => {
444− void handleMessage(ws, raw);
445− });
378+ const listenOptions = host ? { host, port } : { port };
446379
447− ws.on("close", () => {
448− clients.delete(ws);
449− });
380+ const orchestrationEngine = yield* OrchestrationEngineService;
381+ const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
382+ const liveCheckpointStore = yield* CheckpointStore;
383+ const liveProviderService = yield* ProviderService;
384+ const orchestrationReactor = yield* OrchestrationReactor;
450385
451− ws.on("error", () => {
452− clients.delete(ws);
453− });
454− });
386+ const subscriptionsScope = yield* Scope.make("sequential");
387+ yield* Effect.addFinalizer(() =>
388+ Scope.close(subscriptionsScope, Exit.void).pipe(Effect.orElseSucceed(() => undefined)),
389+ );
455390
456− async function handleMessage(ws: WebSocket, raw: unknown) {
457− const messageText = websocketRawToString(raw);
458− if (messageText === null) {
459− const errorResponse: WsResponse = {
460− id: "unknown",
461− error: { message: "Invalid request format" },
391+ yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
392+ Effect.sync(() => {
393+ const push: WsPush = {
394+ type: "push",
395+ channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
396+ data: event,
462397 };
463− ws.send(JSON.stringify(errorResponse));
464− return;
465− }
398+ const message = JSON.stringify(push);
399+ let recipients = 0;
400+ for (const client of clients) {
401+ if (client.readyState === client.OPEN) {
402+ client.send(message);
403+ recipients += 1;
404+ }
405+ }
406+ logOutgoingPush(push, recipients);
407+ }),
408+ ).pipe(Effect.forkIn(subscriptionsScope));
466409
467− let request: WebSocketRequest;
468− try {
469− request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(messageText);
470− } catch {
471− const errorResponse: WsResponse = {
472− id: "unknown",
473− error: { message: "Invalid request format" },
474− };
475− ws.send(JSON.stringify(errorResponse));
476− return;
477− }
410+ yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope));
478411
479− try {
480− const result = await routeRequest(request);
481− const response: WsResponse = { id: request.id, result };
482− ws.send(JSON.stringify(response));
483− } catch (err) {
484− const message = err instanceof Error ? err.message : "Unknown server error";
485− const response: WsResponse = {
486− id: request.id,
487− error: { message },
488− };
489− ws.send(JSON.stringify(response));
412+ if (autoBootstrapProjectFromCwd) {
413+ const snapshot = yield* projectionReadModelQuery.getSnapshot();
414+ const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd);
415+ if (!existing) {
416+ const createdAt = new Date().toISOString();
417+ const projectName = path.basename(cwd) || "project";
418+ yield* orchestrationEngine.dispatchUnknownCommand({
419+ type: "project.create",
420+ commandId: crypto.randomUUID(),
421+ projectId: crypto.randomUUID(),
422+ title: projectName,
423+ workspaceRoot: cwd,
424+ defaultModel: "gpt-5-codex",
425+ createdAt,
426+ });
490427 }
491428 }
492429
493− async function routeRequest(request: WebSocketRequest): Promise<unknown> {
494− const orchestrationEngine = getOrchestrationEngine();
495− const projectionReadModelQuery = getProjectionSnapshotQuery();
496− const effectRuntime = getEffectRuntime();
497− const liveCheckpointStore = getCheckpointStore();
498−
499− const computeTurnDiff = async (input: {
430+ const routeRequest = Effect.fn(function* (request: WebSocketRequest) {
431+ const computeTurnDiff = Effect.fn(function* (input: {
500432 readonly threadId: string;
501433 readonly fromTurnCount: number;
502434 readonly toTurnCount: number;
503− }) => {
435+ }) {
504436 if (input.fromTurnCount > input.toTurnCount) {
505437 throw new Error(
506438 `Invalid turn diff range for thread '${input.threadId}': from ${input.fromTurnCount} exceeds to ${input.toTurnCount}.`,
@@ -516,7 +448,7 @@ export function createServer(options: ServerOptions): ServerRuntime { · 9 unmodified lines
516448 };
517449 }
518450
519− const snapshot = await effectRuntime.runPromise(projectionReadModelQuery.getSnapshot());
451+ const snapshot = yield* projectionReadModelQuery.getSnapshot();
520452 const thread = snapshot.threads.find((entry) => entry.id === input.threadId);
521453 if (!thread) {
522454 throw new Error(`Thread '${input.threadId}' not found.`);
@@ -562,20 +494,19 @@ export function createServer(options: ServerOptions): ServerRuntime { · 39 unmodified lines
562494 );
563495 }
564496
565− const [fromExists, toExists] = await Promise.all([
566− effectRuntime.runPromise(
497+ const [fromExists, toExists] = yield* Effect.all(
498+ [
567499 liveCheckpointStore.hasCheckpointRef({
568500 cwd: workspaceCwd,
569501 checkpointRef: fromCheckpointRef,
570502 }),
571− ),
572− effectRuntime.runPromise(
573503 liveCheckpointStore.hasCheckpointRef({
574504 cwd: workspaceCwd,
575505 checkpointRef: toCheckpointRef,
576506 }),
577− ),
578− ]);
507+ ],
508+ { concurrency: "unbounded" },
509+ );
579510 if (!fromExists) {
580511 throw new Error(
581512 `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`,
@@ -587,304 +518,282 @@ export function createServer(options: ServerOptions): ServerRuntime { · 5 unmodified lines
587518 );
588519 }
589520
590− const diff = await effectRuntime.runPromise(
591− liveCheckpointStore.diffCheckpoints({
592− cwd: workspaceCwd,
593− fromCheckpointRef,
594− toCheckpointRef,
595− fallbackFromToHead: false,
596− }),
597− );
521+ const diff = yield* liveCheckpointStore.diffCheckpoints({
522+ cwd: workspaceCwd,
523+ fromCheckpointRef,
524+ toCheckpointRef,
525+ fallbackFromToHead: false,
526+ });
598527 return {
599528 threadId: input.threadId,
600529 fromTurnCount: input.fromTurnCount,
601530 toTurnCount: input.toTurnCount,
602531 diff,
603532 };
604− };
533+ });
605534
606535 switch (request.body._tag) {
607− case ORCHESTRATION_WS_METHODS.getSnapshot: {
608− return effectRuntime.runPromise(projectionReadModelQuery.getSnapshot());
609− }
536+ case ORCHESTRATION_WS_METHODS.getSnapshot:
537+ return yield* projectionReadModelQuery.getSnapshot();
610538
611539 case ORCHESTRATION_WS_METHODS.dispatchCommand: {
612− orchestrationCommandLogger?.write({
613− observedAt: new Date().toISOString(),
614− command: request.body.command,
615− });
616− return effectRuntime.runPromise(orchestrationEngine.dispatch(request.body.command));
540+ const { command } = request.body;
541+ return yield* orchestrationEngine.dispatch(command);
617542 }
618543
619544 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
620545 const body = stripRequestTag(request.body);
621− return computeTurnDiff(body);
546+ return yield* computeTurnDiff(body);
622547 }
623548
624549 case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
625550 const body = stripRequestTag(request.body);
626− return computeTurnDiff({
551+ return yield* computeTurnDiff({
627552 threadId: body.threadId,
628553 fromTurnCount: 0,
629554 toTurnCount: body.toTurnCount,
630555 });
631556 }
632557
633− case ORCHESTRATION_WS_METHODS.replayEvents:
634− return effectRuntime.runPromise(
635− Stream.runCollect(
636− orchestrationEngine.readEvents(
637− clamp(request.body.fromSequenceExclusive, {
638− maximum: Number.MAX_SAFE_INTEGER,
639− minimum: 0,
640− }),
641− ),
642− ).pipe(Effect.map((events) => Array.from(events))),
643− );
558+ case ORCHESTRATION_WS_METHODS.replayEvents: {
559+ const { fromSequenceExclusive } = request.body;
560+ return yield* Stream.runCollect(
561+ orchestrationEngine.readEvents(
562+ clamp(fromSequenceExclusive, {
563+ maximum: Number.MAX_SAFE_INTEGER,
564+ minimum: 0,
565+ }),
566+ ),
567+ ).pipe(Effect.map((events) => Array.from(events)));
568+ }
644569
645− case WS_METHODS.projectsSearchEntries:
646− return searchWorkspaceEntries(stripRequestTag(request.body));
570+ case WS_METHODS.projectsSearchEntries: {
571+ const body = stripRequestTag(request.body);
572+ return yield* Effect.promise(() => searchWorkspaceEntries(body));
573+ }
647574
648575 case WS_METHODS.shellOpenInEditor: {
649− await Effect.runPromise(openService.openInEditor(stripRequestTag(request.body)));
650− return undefined;
576+ const body = stripRequestTag(request.body);
577+ return yield* openService.openInEditor(body);
651578 }
652579
653− case WS_METHODS.gitStatus:
654− return gitManager.status(stripRequestTag(request.body));
580+ case WS_METHODS.gitStatus: {
581+ const body = stripRequestTag(request.body);
582+ return yield* Effect.promise(() => gitManager.status(body));
583+ }
655584
656− case WS_METHODS.gitPull:
657− return pullGitBranch(stripRequestTag(request.body));
585+ case WS_METHODS.gitPull: {
586+ const body = stripRequestTag(request.body);
587+ return yield* Effect.promise(() => pullGitBranch(body));
588+ }
658589
659− case WS_METHODS.gitRunStackedAction:
660− return gitManager.runStackedAction(stripRequestTag(request.body));
661− case WS_METHODS.gitListBranches:
662− return listGitBranches(stripRequestTag(request.body));
590+ case WS_METHODS.gitRunStackedAction: {
591+ const body = stripRequestTag(request.body);
592+ return yield* Effect.promise(() => gitManager.runStackedAction(body));
593+ }
663594
664− case WS_METHODS.gitCreateWorktree:
665− return createGitWorktree(stripRequestTag(request.body));
595+ case WS_METHODS.gitListBranches: {
596+ const body = stripRequestTag(request.body);
597+ return yield* Effect.promise(() => listGitBranches(body));
598+ }
666599
667− case WS_METHODS.gitRemoveWorktree:
668− return removeGitWorktree(stripRequestTag(request.body));
600+ case WS_METHODS.gitCreateWorktree: {
601+ const body = stripRequestTag(request.body);
602+ return yield* Effect.promise(() => createGitWorktree(body));
603+ }
669604
670− case WS_METHODS.gitCreateBranch:
671− return createGitBranch(stripRequestTag(request.body));
605+ case WS_METHODS.gitRemoveWorktree: {
606+ const body = stripRequestTag(request.body);
607+ return yield* Effect.promise(() => removeGitWorktree(body));
608+ }
672609
673− case WS_METHODS.gitCheckout:
674− return checkoutGitBranch(stripRequestTag(request.body));
610+ case WS_METHODS.gitCreateBranch: {
611+ const body = stripRequestTag(request.body);
612+ return yield* Effect.promise(() => createGitBranch(body));
613+ }
675614
676− case WS_METHODS.gitInit:
677− return initGitRepo(stripRequestTag(request.body));
615+ case WS_METHODS.gitCheckout: {
616+ const body = stripRequestTag(request.body);
617+ return yield* Effect.promise(() => checkoutGitBranch(body));
618+ }
678619
679− case WS_METHODS.terminalOpen:
680− return terminalManager.open(stripRequestTag(request.body));
620+ case WS_METHODS.gitInit: {
621+ const body = stripRequestTag(request.body);
622+ return yield* Effect.promise(() => initGitRepo(body));
623+ }
681624
682− case WS_METHODS.terminalWrite:
683− await terminalManager.write(stripRequestTag(request.body));
684− return undefined;
625+ case WS_METHODS.terminalOpen: {
626+ const body = stripRequestTag(request.body);
627+ return yield* Effect.promise(() => terminalManager.open(body));
628+ }
685629
686− case WS_METHODS.terminalResize:
687− await terminalManager.resize(stripRequestTag(request.body));
688− return undefined;
630+ case WS_METHODS.terminalWrite: {
631+ const body = stripRequestTag(request.body);
632+ return yield* Effect.promise(() => terminalManager.write(body));
633+ }
689634
690− case WS_METHODS.terminalClear:
691− await terminalManager.clear(stripRequestTag(request.body));
692− return undefined;
635+ case WS_METHODS.terminalResize: {
636+ const body = stripRequestTag(request.body);
637+ return yield* Effect.promise(() => terminalManager.resize(body));
638+ }
639+
640+ case WS_METHODS.terminalClear: {
641+ const body = stripRequestTag(request.body);
642+ return yield* Effect.promise(() => terminalManager.clear(body));
643+ }
693644
694− case WS_METHODS.terminalRestart:
695− return terminalManager.restart(stripRequestTag(request.body));
645+ case WS_METHODS.terminalRestart: {
646+ const body = stripRequestTag(request.body);
647+ return yield* Effect.promise(() => terminalManager.restart(body));
648+ }
696649
697− case WS_METHODS.terminalClose:
698− await terminalManager.close(stripRequestTag(request.body));
699− return undefined;
650+ case WS_METHODS.terminalClose: {
651+ const body = stripRequestTag(request.body);
652+ return yield* Effect.promise(() => terminalManager.close(body));
653+ }
700654
701655 case WS_METHODS.serverGetConfig:
702− return {
703− cwd,
704− keybindings: keybindingsConfig,
705− };
656+ return { cwd, keybindings: keybindingsConfig };
706657
707− case WS_METHODS.serverUpsertKeybinding:
708− keybindingsConfig = upsertKeybindingRule(logger, stripRequestTag(request.body));
709− return {
710− keybindings: keybindingsConfig,
711− };
658+ case WS_METHODS.serverUpsertKeybinding: {
659+ const body = stripRequestTag(request.body);
660+ keybindingsConfig = upsertKeybindingRule(logger, body);
661+ return { keybindings: keybindingsConfig };
662+ }
712663
713664 default: {
714665 const _exhaustiveCheck: never = request.body;
715− throw new Error(`Unknown method: ${String(_exhaustiveCheck)}`);
716− }
717− }
718− }
719−
720− async function createEffectRuntime() {
721− const dbPath = path.join(resolvedStateDir, "orchestration.sqlite");
722− const providerLogsDir = path.join(resolvedStateDir, "logs", "providers");
723− const orchestrationLogsDir = path.join(resolvedStateDir, "logs", "orchestration");
724− orchestrationDomainEventLogger = makeEventNdjsonLogger(
725− path.join(orchestrationLogsDir, "orchestration-domain.ndjson"),
726− );
727− orchestrationCommandLogger = makeEventNdjsonLogger(
728− path.join(orchestrationLogsDir, "orchestration-command.ndjson"),
729− );
730− const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath);
731− const orchestrationLayer = OrchestrationEngineLive.pipe(
732− Layer.provide(OrchestrationProjectionPipelineLive),
733− Layer.provide(OrchestrationEventStoreLive),
734− Layer.provide(OrchestrationCommandReceiptRepositoryLive),
735− );
736− const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
737− const providerLayer =
738− customProviderLayer ??
739− (() => {
740− const codexAdapterLayer = makeCodexAdapterLive({
741− nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),
666+ return yield* new RouteRequestError({
667+ message: `Unknown method: ${String(_exhaustiveCheck)}`,
742668 });
743− const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
744− Layer.provide(codexAdapterLayer),
745− );
746− const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
747− Layer.provide(ProviderSessionRuntimeRepositoryLive),
748− );
749− return makeProviderServiceLive({
750− canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
751− }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
752− })();
753−
754− const runtimeServicesLayer = Layer.mergeAll(
755− orchestrationLayer,
756− OrchestrationProjectionSnapshotQueryLive,
757− checkpointStoreLayer,
758− providerLayer,
759− );
760− const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
761− Layer.provideMerge(runtimeServicesLayer),
762− );
763− const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
764− Layer.provideMerge(runtimeServicesLayer),
765− );
766− const checkpointReactorLayer = CheckpointReactorLive.pipe(
767− Layer.provideMerge(runtimeServicesLayer),
768− );
769− const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
770− Layer.provideMerge(runtimeIngestionLayer),
771− Layer.provideMerge(providerCommandReactorLayer),
772− Layer.provideMerge(checkpointReactorLayer),
773− );
774− const layer = orchestrationReactorLayer.pipe(
775− Layer.provide(persistenceLayer),
776− Layer.provideMerge(NodeServices.layer),
777− );
778− const runtime = ManagedRuntime.make(layer);
779−
780− try {
781− const [
782− nextOrchestrationEngine,
783− nextProjectionSnapshotQuery,
784− nextCheckpointStore,
785− nextProviderService,
786− ] = await Promise.all([
787− runtime.runPromise(Effect.service(OrchestrationEngineService)),
788− runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
789− runtime.runPromise(Effect.service(CheckpointStore)),
790− runtime.runPromise(Effect.service(ProviderService)),
791− ]);
792− orchestrationEngine = nextOrchestrationEngine;
793− projectionSnapshotQuery = nextProjectionSnapshotQuery;
794− checkpointStore = nextCheckpointStore;
795− providerService = nextProviderService;
796− await runtime.runPromise(attachSubscriptionss);
797− } catch (error) {
798− await runtime.dispose().catch(() => undefined);
799− throw error;
669+ }
800670 }
671+ });
801672
802− return runtime;
803− }
673+ const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) {
674+ const encodeResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse));
804675
805− async function disposeEffectRuntime() {
806− if (subscriptionsScope && effectRuntime) {
807− await effectRuntime.runPromise(Scope.close(subscriptionsScope, Exit.void));
808− subscriptionsScope = null;
676+ const messageText = websocketRawToString(raw);
677+ if (messageText === null) {
678+ const errorResponse = yield* encodeResponse(
679+ new WebSocketResponse({
680+ id: "unknown",
681+ error: { message: "Invalid request format: Failed to read message" },
682+ }),
683+ );
684+ ws.send(errorResponse);
685+ return;
809686 }
810− orchestrationDomainEventLogger?.close();
811− orchestrationDomainEventLogger = undefined;
812− orchestrationCommandLogger?.close();
813− orchestrationCommandLogger = undefined;
814−
815− const runtime = effectRuntime;
816− const liveProviderService = providerService;
817− effectRuntime = null;
818− orchestrationEngine = null;
819− projectionSnapshotQuery = null;
820− checkpointStore = null;
821− providerService = null;
822−
823− if (!runtime) {
687+
688+ const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText);
689+ if (request._tag === "Failure") {
690+ const errorResponse = yield* encodeResponse(
691+ new WebSocketResponse({
692+ id: "unknown",
693+ error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` },
694+ }),
695+ );
696+ ws.send(errorResponse);
824697 return;
825698 }
826699
827− if (liveProviderService) {
828− await runtime.runPromise(liveProviderService.stopAll()).catch(() => undefined);
700+ const result = yield* Effect.exit(routeRequest(request.value));
701+ if (result._tag === "Failure") {
702+ const errorResponse = yield* encodeResponse(
703+ new WebSocketResponse({
704+ id: request.value.id,
705+ error: { message: Cause.pretty(result.cause) },
706+ }),
707+ );
708+ ws.send(errorResponse);
709+ return;
829710 }
830711
831− await runtime.dispose();
832− }
712+ const response = yield* encodeResponse(
713+ new WebSocketResponse({
714+ id: request.value.id,
715+ result: result.value,
716+ }),
717+ );
718+ ws.send(response);
719+ });
833720
834− async function start() {
835− effectRuntime = await createEffectRuntime();
836− await bootstrapProjectForCwd();
721+ httpServer.on("upgrade", (request, socket, head) => {
722+ if (authToken) {
723+ let providedToken: string | null = null;
724+ try {
725+ const url = new URL(request.url ?? "/", `http://localhost:${port}`);
726+ providedToken = url.searchParams.get("token");
727+ } catch {
728+ rejectUpgrade(socket, 400, "Invalid WebSocket URL");
729+ return;
730+ }
837731
838− return new Promise<void>((resolve, reject) => {
839− const onError = (error: Error) => {
840− httpServer.off("error", onError);
841− void disposeEffectRuntime().finally(() => reject(error));
842− };
843− httpServer.once("error", onError);
844− const onListening = () => {
845− httpServer.off("error", onError);
846− resolve();
847− };
848− if (host) {
849− httpServer.listen(port, host, onListening);
732+ if (providedToken !== authToken) {
733+ rejectUpgrade(socket, 401, "Unauthorized WebSocket connection");
850734 return;
851735 }
852− httpServer.listen(port, onListening);
736+ }
737+
738+ wss.handleUpgrade(request, socket, head, (ws) => {
739+ wss.emit("connection", ws, request);
853740 });
854− }
741+ });
855742
856− async function stop(): Promise<void> {
857− await disposeEffectRuntime();
858− terminalManager.off("event", onTerminalEvent);
859− terminalManager.dispose();
743+ wss.on("connection", (ws) => {
744+ clients.add(ws);
860745
861− for (const client of clients) {
862− client.close();
863− }
864− clients.clear();
746+ const segments = cwd.split(/[/\\]/).filter(Boolean);
747+ const projectName = segments[segments.length - 1] ?? "project";
865748
866− const closeWebSocketServer = new Promise<void>((resolve, reject) => {
867− wss.close((error) => {
868− if (error && !isServerNotRunningError(error)) {
869− reject(error);
870− return;
871− }
872− resolve();
873− });
749+ const welcome: WsPush = {
750+ type: "push",
751+ channel: WS_CHANNELS.serverWelcome,
752+ data: { cwd, projectName },
753+ };
754+ logOutgoingPush(welcome, 1);
755+ ws.send(JSON.stringify(welcome));
756+
757+ ws.on("message", (raw) => {
758+ void Effect.runPromise(handleMessage(ws, raw));
874759 });
875760
876− const closeHttpServer = new Promise<void>((resolve, reject) => {
877− httpServer.close((error) => {
878− if (error && !isServerNotRunningError(error)) {
879− reject(error);
880− return;
881− }
882− resolve();
883− });
761+ ws.on("close", () => {
762+ clients.delete(ws);
884763 });
885764
886− await Promise.all([closeWebSocketServer, closeHttpServer]);
887− }
765+ ws.on("error", () => {
766+ clients.delete(ws);
767+ });
768+ });
888769
889− return { start, stop, httpServer };
890−}
770+ yield* Effect.addFinalizer(() =>
771+ Effect.catch(liveProviderService.stopAll(), (cause) =>
772+ Effect.logWarning("failed to stop provider service", { cause }),
773+ ),
774+ );
775+
776+ terminalManager.on("event", onTerminalEvent);
777+ yield* Effect.addFinalizer(() =>
778+ Effect.sync(() => {
779+ terminalManager.off("event", onTerminalEvent);
780+ terminalManager.dispose();
781+ }),
782+ );
783+
784+ yield* NodeHttpServer.make(() => httpServer, listenOptions);
785+
786+ yield* Effect.addFinalizer(() =>
787+ Effect.all([
788+ Effect.sync(() => closeAllClients()),
789+ closeWebSocketServer.pipe(Effect.orElseSucceed(() => undefined)),
790+ ]),
791+ );
792+
793+ return httpServer;
794+});
795+
796+export const ServerLive = Layer.succeed(Server, {
797+ createServer,
798+ stopSignal: Effect.never,
799+} satisfies ServerShape);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t