authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 17:18:49-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
logda3d8e2ecb572e107b4ceec41d8966fe2f8f3db1
tree47396eb2ba13b7690ff42eeb085f00b11dde3fe4
parent5cbb6ea728903f4a9aa25441f663fe65e456f2c9

nice refactor


4 files changed, 522 insertions(+), 596 deletions(-)

apps/server/src/main.test.ts+23-14
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as Http from "node:http";
1import { NodeServices } from "@effect/platform-node";2import { NodeServices } from "@effect/platform-node";
2import { assert, it, vi } from "@effect/vitest";3import { assert, it, vi } from "@effect/vitest";
3import * as ConfigProvider from "effect/ConfigProvider";4import * as ConfigProvider from "effect/ConfigProvider";
...@@ -10,13 +11,17 @@ import { CliConfig, makeCliCommand, type CliConfigShape } from "./main";...@@ -10,13 +11,17 @@ import { CliConfig, makeCliCommand, type CliConfigShape } from "./main";
10import { Open, type OpenShape } from "./open";11import { Open, type OpenShape } from "./open";
11import { Server, type ServerShape, type ServerOptions } from "./wsServer";12import { Server, type ServerShape, type ServerOptions } from "./wsServer";
1213
13const start = vi.fn(async () => undefined);14const start = vi.fn(() => undefined);
14const stop = vi.fn(async () => undefined);15const stop = vi.fn(() => undefined);
15const createServer = vi.fn((_: ServerOptions) => ({16const createServer = vi.fn((_: ServerOptions) =>
16 start,17 Effect.acquireRelease(
17 stop,18 Effect.sync(() => {
18 httpServer: undefined as never,19 start();
19}));20 return {} as unknown as Http.Server;
21 }),
22 () => Effect.sync(() => stop()),
23 ),
24);
20const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));25const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
2126
22const testLayer = Layer.mergeAll(27const testLayer = Layer.mergeAll(
...@@ -47,13 +52,17 @@ const runCli = (...@@ -47,13 +52,17 @@ const runCli = (
4752
48beforeEach(() => {53beforeEach(() => {
49 vi.clearAllMocks();54 vi.clearAllMocks();
50 start.mockImplementation(async () => undefined);55 start.mockImplementation(() => undefined);
51 stop.mockImplementation(async () => undefined);56 stop.mockImplementation(() => undefined);
52 createServer.mockImplementation((_: ServerOptions) => ({57 createServer.mockImplementation((_: ServerOptions) =>
53 start,58 Effect.acquireRelease(
54 stop,59 Effect.sync(() => {
55 httpServer: undefined as never,60 start();
56 }));61 return {} as unknown as Http.Server;
62 }),
63 () => Effect.sync(() => stop()),
64 ),
65 );
57 findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));66 findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
58});67});
5968
apps/server/src/main.ts+26-24
...@@ -6,7 +6,12 @@ import { Command, Flag } from "effect/unstable/cli";...@@ -6,7 +6,12 @@ import { Command, Flag } from "effect/unstable/cli";
66
7import { fixPath } from "./fixPath";7import { fixPath } from "./fixPath";
8import { Open } from "./open";8import { Open } from "./open";
9import { Server } from "./wsServer";9import {
10 makeServerPersistenceLayer,
11 makeServerProviderLayer,
12 makeServerRuntimeServicesLayer,
13 Server,
14} from "./wsServer";
1015
11const DEFAULT_PORT = 3773;16const DEFAULT_PORT = 3773;
1217
...@@ -168,29 +173,26 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) {...@@ -168,29 +173,26 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) {
168 });173 });
169 }174 }
170175
171 yield* Effect.acquireRelease(176 const persistenceLayer = makeServerPersistenceLayer(config.stateDir);
172 Effect.tryPromise({177 const providerLayer = makeServerProviderLayer(config.stateDir);
173 try: async () => {178 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
174 const server = createServer({179 const serverRuntimeLayer = Layer.merge(
175 port: config.port,180 makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),
176 host: config.mode === "desktop" ? "127.0.0.1" : undefined,181 infrastructureLayer,
177 cwd: config.cwd,182 );
178 autoBootstrapProjectFromCwd: config.mode === "web",183
179 stateDir: config.stateDir,184 yield* createServer({
180 staticDir: config.staticDir,185 port: config.port,
181 devUrl: config.devUrl?.href,186 host: config.mode === "desktop" ? "127.0.0.1" : undefined,
182 authToken: config.authToken,187 cwd: config.cwd,
183 });188 autoBootstrapProjectFromCwd: config.mode === "web",
184 await server.start();189 stateDir: config.stateDir,
185 return server;190 staticDir: config.staticDir,
186 },191 devUrl: config.devUrl?.href,
187 catch: (cause) => new StartupError({ message: "Failed to start server", cause }),192 authToken: config.authToken,
188 }),193 }).pipe(
189 (server) =>194 Effect.provide(serverRuntimeLayer),
190 Effect.tryPromise({195 Effect.mapError((cause) => new StartupError({ message: "Failed to start server", cause })),
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 }))),
194 );196 );
195197
196 const url = `http://localhost:${config.port}`;198 const url = `http://localhost:${config.port}`;
apps/server/src/wsServer.test.ts+112-106
...@@ -1,11 +1,12 @@...@@ -1,11 +1,12 @@
1import * as Http from "node:http";
1import fs from "node:fs";2import fs from "node:fs";
2import os from "node:os";3import os from "node:os";
3import path from "node:path";4import path from "node:path";
4import { EventEmitter } from "node:events";5import { EventEmitter } from "node:events";
56
6import { Effect, Layer, PubSub, Stream } from "effect";7import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";
7import { describe, expect, it, afterEach, vi } from "vitest";8import { describe, expect, it, afterEach, vi } from "vitest";
8import { createServer } from "./wsServer";9import { createServer, makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./wsServer";
9import WebSocket from "ws";10import WebSocket from "ws";
1011
11import {12import {
...@@ -49,7 +50,8 @@ interface PendingMessages {...@@ -49,7 +50,8 @@ interface PendingMessages {
49const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();50const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
5051
51const asEventId = (value: string): EventId => EventId.makeUnsafe(value);52const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
52const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);53const asProviderSessionId = (value: string): ProviderSessionId =>
54 ProviderSessionId.makeUnsafe(value);
53const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);55const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
54const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);56const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
55const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);57const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
...@@ -286,7 +288,8 @@ function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybinding...@@ -286,7 +288,8 @@ function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybinding
286}288}
287289
288describe("WebSocket Server", () => {290describe("WebSocket Server", () => {
289 let server: ReturnType<typeof createServer> | null = null;291 let server: Http.Server | null = null;
292 let serverScope: Scope.Closeable | null = null;
290 const connections: WebSocket[] = [];293 const connections: WebSocket[] = [];
291 const tempDirs: string[] = [];294 const tempDirs: string[] = [];
292295
...@@ -296,7 +299,7 @@ describe("WebSocket Server", () => {...@@ -296,7 +299,7 @@ describe("WebSocket Server", () => {
296 return dir;299 return dir;
297 }300 }
298301
299 function createTestServer(302 async function createTestServer(
300 options: {303 options: {
301 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, any>;304 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, any>;
302 cwd?: string;305 cwd?: string;
...@@ -312,23 +315,53 @@ describe("WebSocket Server", () => {...@@ -312,23 +315,53 @@ describe("WebSocket Server", () => {
312 };315 };
313 terminalManager?: TerminalManager;316 terminalManager?: TerminalManager;
314 } = {},317 } = {},
315 ): ReturnType<typeof createServer> {318 ): Promise<Http.Server> {
319 if (serverScope) {
320 throw new Error("Test server is already running");
321 }
322
316 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");323 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");
317 return createServer({324 const scope = await Effect.runPromise(Scope.make("sequential"));
318 port: 0,325 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
319 cwd: options.cwd ?? "/test/project",326 const providerLayer = options.providerLayer ?? makeServerProviderLayer(stateDir);
320 stateDir,327 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
321 persistenceLayer: options.persistenceLayer ?? SqlitePersistenceMemory,328 const runtimeLayer = Layer.merge(
322 ...(options.autoBootstrapProjectFromCwd !== undefined329 makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),
323 ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd }330 infrastructureLayer,
324 : {}),331 );
325 ...(options.devUrl ? { devUrl: options.devUrl } : {}),332 const runtimeServices = await Effect.runPromise(
326 ...(options.authToken ? { authToken: options.authToken } : {}),333 Layer.build(runtimeLayer).pipe(Scope.provide(scope)),
327 ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}),334 );
328 ...(options.open ? { open: options.open } : {}),335
329 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),336 try {
330 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),337 const runtime = await Effect.runPromise(
331 });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));
332 }365 }
333366
334 afterEach(async () => {367 afterEach(async () => {
...@@ -336,9 +369,7 @@ describe("WebSocket Server", () => {...@@ -336,9 +369,7 @@ describe("WebSocket Server", () => {
336 ws.close();369 ws.close();
337 }370 }
338 connections.length = 0;371 connections.length = 0;
339 if (server) {372 await closeTestServer();
340 await server.stop();
341 }
342 server = null;373 server = null;
343 for (const dir of tempDirs.splice(0, tempDirs.length)) {374 for (const dir of tempDirs.splice(0, tempDirs.length)) {
344 fs.rmSync(dir, { recursive: true, force: true });375 fs.rmSync(dir, { recursive: true, force: true });
...@@ -347,10 +378,9 @@ describe("WebSocket Server", () => {...@@ -347,10 +378,9 @@ describe("WebSocket Server", () => {
347 });378 });
348379
349 it("sends welcome message on connect", async () => {380 it("sends welcome message on connect", async () => {
350 server = createTestServer({ cwd: "/test/project" });381 server = await createTestServer({ cwd: "/test/project" });
351 // Get the actual port after listen382 // Get the actual port after listen
352 await server.start();383 const addr = server.address();
353 const addr = server.httpServer.address();
354 const port = typeof addr === "object" && addr !== null ? addr.port : 0;384 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
355 expect(port).toBeGreaterThan(0);385 expect(port).toBeGreaterThan(0);
356386
...@@ -367,12 +397,11 @@ describe("WebSocket Server", () => {...@@ -367,12 +397,11 @@ describe("WebSocket Server", () => {
367 });397 });
368398
369 it("bootstraps the cwd project on startup when enabled", async () => {399 it("bootstraps the cwd project on startup when enabled", async () => {
370 server = createTestServer({400 server = await createTestServer({
371 cwd: "/test/bootstrap-workspace",401 cwd: "/test/bootstrap-workspace",
372 autoBootstrapProjectFromCwd: true,402 autoBootstrapProjectFromCwd: true,
373 });403 });
374 await server.start();404 const addr = server.address();
375 const addr = server.httpServer.address();
376 const port = typeof addr === "object" && addr !== null ? addr.port : 0;405 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
377 expect(port).toBeGreaterThan(0);406 expect(port).toBeGreaterThan(0);
378407
...@@ -402,12 +431,11 @@ describe("WebSocket Server", () => {...@@ -402,12 +431,11 @@ describe("WebSocket Server", () => {
402 // Keep test output clean while verifying websocket logs.431 // Keep test output clean while verifying websocket logs.
403 });432 });
404433
405 server = createTestServer({434 server = await createTestServer({
406 cwd: "/test/project",435 cwd: "/test/project",
407 devUrl: "http://localhost:5173",436 devUrl: "http://localhost:5173",
408 });437 });
409 await server.start();438 const addr = server.address();
410 const addr = server.httpServer.address();
411 const port = typeof addr === "object" && addr !== null ? addr.port : 0;439 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
412 expect(port).toBeGreaterThan(0);440 expect(port).toBeGreaterThan(0);
413441
...@@ -430,9 +458,8 @@ describe("WebSocket Server", () => {...@@ -430,9 +458,8 @@ describe("WebSocket Server", () => {
430 it("responds to server.getConfig", async () => {458 it("responds to server.getConfig", async () => {
431 const fakeHome = makeTempDir("t3code-home-");459 const fakeHome = makeTempDir("t3code-home-");
432 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);460 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
433 server = createTestServer({ cwd: "/my/workspace" });461 server = await createTestServer({ cwd: "/my/workspace" });
434 await server.start();462 const addr = server.address();
435 const addr = server.httpServer.address();
436 const port = typeof addr === "object" && addr !== null ? addr.port : 0;463 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
437464
438 const ws = await connectWs(port);465 const ws = await connectWs(port);
...@@ -459,9 +486,8 @@ describe("WebSocket Server", () => {...@@ -459,9 +486,8 @@ describe("WebSocket Server", () => {
459 },486 },
460 };487 };
461488
462 server = createTestServer({ cwd: "/my/workspace", open: openService });489 server = await createTestServer({ cwd: "/my/workspace", open: openService });
463 await server.start();490 const addr = server.address();
464 const addr = server.httpServer.address();
465 const port = typeof addr === "object" && addr !== null ? addr.port : 0;491 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
466492
467 const ws = await connectWs(port);493 const ws = await connectWs(port);
...@@ -491,9 +517,8 @@ describe("WebSocket Server", () => {...@@ -491,9 +517,8 @@ describe("WebSocket Server", () => {
491 );517 );
492 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);518 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
493519
494 server = createTestServer({ cwd: "/my/workspace" });520 server = await createTestServer({ cwd: "/my/workspace" });
495 await server.start();521 const addr = server.address();
496 const addr = server.httpServer.address();
497 const port = typeof addr === "object" && addr !== null ? addr.port : 0;522 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
498523
499 const ws = await connectWs(port);524 const ws = await connectWs(port);
...@@ -528,9 +553,8 @@ describe("WebSocket Server", () => {...@@ -528,9 +553,8 @@ describe("WebSocket Server", () => {
528 );553 );
529 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);554 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
530555
531 server = createTestServer({ cwd: "/my/workspace" });556 server = await createTestServer({ cwd: "/my/workspace" });
532 await server.start();557 const addr = server.address();
533 const addr = server.httpServer.address();
534 const port = typeof addr === "object" && addr !== null ? addr.port : 0;558 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
535559
536 const ws = await connectWs(port);560 const ws = await connectWs(port);
...@@ -563,9 +587,8 @@ describe("WebSocket Server", () => {...@@ -563,9 +587,8 @@ describe("WebSocket Server", () => {
563 );587 );
564 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);588 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
565589
566 server = createTestServer({ cwd: "/my/workspace" });590 server = await createTestServer({ cwd: "/my/workspace" });
567 await server.start();591 const addr = server.address();
568 const addr = server.httpServer.address();
569 const port = typeof addr === "object" && addr !== null ? addr.port : 0;592 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
570593
571 const ws = await connectWs(port);594 const ws = await connectWs(port);
...@@ -598,9 +621,8 @@ describe("WebSocket Server", () => {...@@ -598,9 +621,8 @@ describe("WebSocket Server", () => {
598 );621 );
599 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);622 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
600623
601 server = createTestServer({ cwd: "/my/workspace" });624 server = await createTestServer({ cwd: "/my/workspace" });
602 await server.start();625 const addr = server.address();
603 const addr = server.httpServer.address();
604 const port = typeof addr === "object" && addr !== null ? addr.port : 0;626 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
605627
606 const ws = await connectWs(port);628 const ws = await connectWs(port);
...@@ -637,9 +659,8 @@ describe("WebSocket Server", () => {...@@ -637,9 +659,8 @@ describe("WebSocket Server", () => {
637 );659 );
638 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);660 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
639661
640 server = createTestServer({ cwd: "/my/workspace" });662 server = await createTestServer({ cwd: "/my/workspace" });
641 await server.start();663 const addr = server.address();
642 const addr = server.httpServer.address();
643 const port = typeof addr === "object" && addr !== null ? addr.port : 0;664 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
644665
645 const ws = await connectWs(port);666 const ws = await connectWs(port);
...@@ -690,9 +711,8 @@ describe("WebSocket Server", () => {...@@ -690,9 +711,8 @@ describe("WebSocket Server", () => {
690 );711 );
691 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);712 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
692713
693 server = createTestServer({ cwd: "/my/workspace" });714 server = await createTestServer({ cwd: "/my/workspace" });
694 await server.start();715 const addr = server.address();
695 const addr = server.httpServer.address();
696 const port = typeof addr === "object" && addr !== null ? addr.port : 0;716 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
697717
698 const ws = await connectWs(port);718 const ws = await connectWs(port);
...@@ -721,9 +741,8 @@ describe("WebSocket Server", () => {...@@ -721,9 +741,8 @@ describe("WebSocket Server", () => {
721 fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8");741 fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8");
722 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);742 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
723743
724 server = createTestServer({ cwd: "/my/workspace" });744 server = await createTestServer({ cwd: "/my/workspace" });
725 await server.start();745 const addr = server.address();
726 const addr = server.httpServer.address();
727 const port = typeof addr === "object" && addr !== null ? addr.port : 0;746 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
728747
729 const ws = await connectWs(port);748 const ws = await connectWs(port);
...@@ -745,9 +764,8 @@ describe("WebSocket Server", () => {...@@ -745,9 +764,8 @@ describe("WebSocket Server", () => {
745 });764 });
746765
747 it("returns error for unknown methods", async () => {766 it("returns error for unknown methods", async () => {
748 server = createTestServer({ cwd: "/test" });767 server = await createTestServer({ cwd: "/test" });
749 await server.start();768 const addr = server.address();
750 const addr = server.httpServer.address();
751 const port = typeof addr === "object" && addr !== null ? addr.port : 0;769 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
752770
753 const ws = await connectWs(port);771 const ws = await connectWs(port);
...@@ -762,9 +780,8 @@ describe("WebSocket Server", () => {...@@ -762,9 +780,8 @@ describe("WebSocket Server", () => {
762 });780 });
763781
764 it("returns error when requesting turn diff for unknown thread", async () => {782 it("returns error when requesting turn diff for unknown thread", async () => {
765 server = createTestServer({ cwd: "/test" });783 server = await createTestServer({ cwd: "/test" });
766 await server.start();784 const addr = server.address();
767 const addr = server.httpServer.address();
768 const port = typeof addr === "object" && addr !== null ? addr.port : 0;785 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
769786
770 const ws = await connectWs(port);787 const ws = await connectWs(port);
...@@ -781,9 +798,8 @@ describe("WebSocket Server", () => {...@@ -781,9 +798,8 @@ describe("WebSocket Server", () => {
781 });798 });
782799
783 it("returns error when requesting full thread diff for unknown thread", async () => {800 it("returns error when requesting full thread diff for unknown thread", async () => {
784 server = createTestServer({ cwd: "/test" });801 server = await createTestServer({ cwd: "/test" });
785 await server.start();802 const addr = server.address();
786 const addr = server.httpServer.address();
787 const port = typeof addr === "object" && addr !== null ? addr.port : 0;803 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
788804
789 const ws = await connectWs(port);805 const ws = await connectWs(port);
...@@ -799,9 +815,8 @@ describe("WebSocket Server", () => {...@@ -799,9 +815,8 @@ describe("WebSocket Server", () => {
799 });815 });
800816
801 it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => {817 it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => {
802 server = createTestServer({ cwd: "/test" });818 server = await createTestServer({ cwd: "/test" });
803 await server.start();819 const addr = server.address();
804 const addr = server.httpServer.address();
805 const port = typeof addr === "object" && addr !== null ? addr.port : 0;820 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
806821
807 const ws = await connectWs(port);822 const ws = await connectWs(port);
...@@ -873,12 +888,11 @@ describe("WebSocket Server", () => {...@@ -873,12 +888,11 @@ describe("WebSocket Server", () => {
873 };888 };
874 const providerLayer = Layer.succeed(ProviderService, providerService);889 const providerLayer = Layer.succeed(ProviderService, providerService);
875890
876 server = createTestServer({891 server = await createTestServer({
877 cwd: "/test",892 cwd: "/test",
878 providerLayer,893 providerLayer,
879 });894 });
880 await server.start();895 const addr = server.address();
881 const addr = server.httpServer.address();
882 const port = typeof addr === "object" && addr !== null ? addr.port : 0;896 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
883897
884 const ws = await connectWs(port);898 const ws = await connectWs(port);
...@@ -941,7 +955,9 @@ describe("WebSocket Server", () => {...@@ -941,7 +955,9 @@ describe("WebSocket Server", () => {
941955
942 const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {956 const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
943 const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };957 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 );
945 });961 });
946962
947 const domainEvent = domainPush.data as {963 const domainEvent = domainPush.data as {
...@@ -956,12 +972,11 @@ describe("WebSocket Server", () => {...@@ -956,12 +972,11 @@ describe("WebSocket Server", () => {
956 it("routes terminal RPC methods and broadcasts terminal events", async () => {972 it("routes terminal RPC methods and broadcasts terminal events", async () => {
957 const cwd = makeTempDir("t3code-ws-terminal-cwd-");973 const cwd = makeTempDir("t3code-ws-terminal-cwd-");
958 const terminalManager = new MockTerminalManager();974 const terminalManager = new MockTerminalManager();
959 server = createTestServer({975 server = await createTestServer({
960 cwd: "/test",976 cwd: "/test",
961 terminalManager: terminalManager as unknown as TerminalManager,977 terminalManager: terminalManager as unknown as TerminalManager,
962 });978 });
963 await server.start();979 const addr = server.address();
964 const addr = server.httpServer.address();
965 const port = typeof addr === "object" && addr !== null ? addr.port : 0;980 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
966981
967 const ws = await connectWs(port);982 const ws = await connectWs(port);
...@@ -1027,24 +1042,22 @@ describe("WebSocket Server", () => {...@@ -1027,24 +1042,22 @@ describe("WebSocket Server", () => {
10271042
1028 it("detaches terminal event listener on stop for injected manager", async () => {1043 it("detaches terminal event listener on stop for injected manager", async () => {
1029 const terminalManager = new MockTerminalManager();1044 const terminalManager = new MockTerminalManager();
1030 server = createTestServer({1045 server = await createTestServer({
1031 cwd: "/test",1046 cwd: "/test",
1032 terminalManager: terminalManager as unknown as TerminalManager,1047 terminalManager: terminalManager as unknown as TerminalManager,
1033 });1048 });
1034 await server.start();
10351049
1036 expect(terminalManager.listenerCount("event")).toBe(1);1050 expect(terminalManager.listenerCount("event")).toBe(1);
10371051
1038 await server.stop();1052 await closeTestServer();
1039 server = null;1053 server = null;
10401054
1041 expect(terminalManager.listenerCount("event")).toBe(0);1055 expect(terminalManager.listenerCount("event")).toBe(0);
1042 });1056 });
10431057
1044 it("returns validation errors for invalid terminal open params", async () => {1058 it("returns validation errors for invalid terminal open params", async () => {
1045 server = createTestServer({ cwd: "/test" });1059 server = await createTestServer({ cwd: "/test" });
1046 await server.start();1060 const addr = server.address();
1047 const addr = server.httpServer.address();
1048 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1061 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10491062
1050 const ws = await connectWs(port);1063 const ws = await connectWs(port);
...@@ -1061,9 +1074,8 @@ describe("WebSocket Server", () => {...@@ -1061,9 +1074,8 @@ describe("WebSocket Server", () => {
1061 });1074 });
10621075
1063 it("handles invalid JSON gracefully", async () => {1076 it("handles invalid JSON gracefully", async () => {
1064 server = createTestServer({ cwd: "/test" });1077 server = await createTestServer({ cwd: "/test" });
1065 await server.start();1078 const addr = server.address();
1066 const addr = server.httpServer.address();
1067 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1079 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10681080
1069 const ws = await connectWs(port);1081 const ws = await connectWs(port);
...@@ -1096,9 +1108,8 @@ describe("WebSocket Server", () => {...@@ -1096,9 +1108,8 @@ describe("WebSocket Server", () => {
1096 });1108 });
10971109
1098 it("returns errors for removed projects CRUD methods", async () => {1110 it("returns errors for removed projects CRUD methods", async () => {
1099 server = createTestServer({ cwd: "/test" });1111 server = await createTestServer({ cwd: "/test" });
1100 await server.start();1112 const addr = server.address();
1101 const addr = server.httpServer.address();
1102 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1113 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
11031114
1104 const ws = await connectWs(port);1115 const ws = await connectWs(port);
...@@ -1134,9 +1145,8 @@ describe("WebSocket Server", () => {...@@ -1134,9 +1145,8 @@ describe("WebSocket Server", () => {
1134 fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });1145 fs.mkdirSync(path.join(workspace, ".git"), { recursive: true });
1135 fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");1146 fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
11361147
1137 server = createTestServer({ cwd: "/test" });1148 server = await createTestServer({ cwd: "/test" });
1138 await server.start();1149 const addr = server.address();
1139 const addr = server.httpServer.address();
1140 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1150 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
11411151
1142 const ws = await connectWs(port);1152 const ws = await connectWs(port);
...@@ -1161,9 +1171,8 @@ describe("WebSocket Server", () => {...@@ -1161,9 +1171,8 @@ describe("WebSocket Server", () => {
1161 it("supports git methods over websocket", async () => {1171 it("supports git methods over websocket", async () => {
1162 const repoCwd = makeTempDir("t3code-ws-git-project-");1172 const repoCwd = makeTempDir("t3code-ws-git-project-");
11631173
1164 server = createTestServer({ cwd: "/test" });1174 server = await createTestServer({ cwd: "/test" });
1165 await server.start();1175 const addr = server.address();
1166 const addr = server.httpServer.address();
1167 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1176 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
11681177
1169 const ws = await connectWs(port);1178 const ws = await connectWs(port);
...@@ -1209,9 +1218,8 @@ describe("WebSocket Server", () => {...@@ -1209,9 +1218,8 @@ describe("WebSocket Server", () => {
1209 runStackedAction: vi.fn(),1218 runStackedAction: vi.fn(),
1210 };1219 };
12111220
1212 server = createTestServer({ cwd: "/test", gitManager });1221 server = await createTestServer({ cwd: "/test", gitManager });
1213 await server.start();1222 const addr = server.address();
1214 const addr = server.httpServer.address();
1215 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1223 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
12161224
1217 const ws = await connectWs(port);1225 const ws = await connectWs(port);
...@@ -1232,9 +1240,8 @@ describe("WebSocket Server", () => {...@@ -1232,9 +1240,8 @@ describe("WebSocket Server", () => {
1232 runStackedAction: vi.fn().mockRejectedValue(new Error("Cannot push from detached HEAD.")),1240 runStackedAction: vi.fn().mockRejectedValue(new Error("Cannot push from detached HEAD.")),
1233 };1241 };
12341242
1235 server = createTestServer({ cwd: "/test", gitManager });1243 server = await createTestServer({ cwd: "/test", gitManager });
1236 await server.start();1244 const addr = server.address();
1237 const addr = server.httpServer.address();
1238 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1245 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
12391246
1240 const ws = await connectWs(port);1247 const ws = await connectWs(port);
...@@ -1254,9 +1261,8 @@ describe("WebSocket Server", () => {...@@ -1254,9 +1261,8 @@ describe("WebSocket Server", () => {
1254 });1261 });
12551262
1256 it("rejects websocket connections without a valid auth token", async () => {1263 it("rejects websocket connections without a valid auth token", async () => {
1257 server = createTestServer({ cwd: "/test", authToken: "secret-token" });1264 server = await createTestServer({ cwd: "/test", authToken: "secret-token" });
1258 await server.start();1265 const addr = server.address();
1259 const addr = server.httpServer.address();
1260 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1266 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
12611267
1262 await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");1268 await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed");
apps/server/src/wsServer.ts+361-452
...@@ -12,11 +12,12 @@ import {...@@ -12,11 +12,12 @@ import {
12 WS_CHANNELS,12 WS_CHANNELS,
13 WS_METHODS,13 WS_METHODS,
14 WebSocketRequest,14 WebSocketRequest,
15 WebSocketResponse,
15 WsPush,16 WsPush,
16 WsResponse,17 WsResponse,
17} from "@t3tools/contracts";18} from "@t3tools/contracts";
18import { NodeServices } from "@effect/platform-node";19import { NodeHttpServer, NodeServices } from "@effect/platform-node";
19import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, ServiceMap, Stream } from "effect";20import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect";
20import { WebSocketServer, type WebSocket } from "ws";21import { WebSocketServer, type WebSocket } from "ws";
2122
22import { createLogger } from "./logger";23import { createLogger } from "./logger";
...@@ -40,34 +41,21 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma...@@ -40,34 +41,21 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma
40import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";41import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
41import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";42import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
42import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";43import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
43import {44import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
44 OrchestrationEngineService,45import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
45 type OrchestrationEngineShape,
46} from "./orchestration/Services/OrchestrationEngine";
47import {
48 ProjectionSnapshotQuery,
49 type ProjectionSnapshotQueryShape,
50} from "./orchestration/Services/ProjectionSnapshotQuery";
51import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";46import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
52import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";47import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
53import assert from "node:assert";
54import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";48import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
55import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";49import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
56import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";50import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
57import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";51import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
58import { makeProviderServiceLive } from "./provider/Layers/ProviderService";52import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
59import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";53import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
60import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";54import { ProviderService } from "./provider/Services/ProviderService";
61import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";55import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
62import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";56import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
63import {57import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
64 CheckpointStore,
65 type CheckpointStoreShape,
66} from "./checkpointing/Services/CheckpointStore";
67import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
68import * as SqlClient from "effect/unstable/sql/SqlClient";58import * as SqlClient from "effect/unstable/sql/SqlClient";
69import * as SqlError from "effect/unstable/sql/SqlError";
70import * as Migrator from "effect/unstable/sql/Migrator";
71import { clamp } from "effect/Number";59import { clamp } from "effect/Number";
72import { OpenDefaultService, type OpenShape } from "./open";60import { OpenDefaultService, type OpenShape } from "./open";
7361
...@@ -92,37 +80,24 @@ export interface ServerOptions {...@@ -92,37 +80,24 @@ export interface ServerOptions {
92 cwd: string;80 cwd: string;
93 autoBootstrapProjectFromCwd?: boolean | undefined;81 autoBootstrapProjectFromCwd?: boolean | undefined;
94 stateDir?: string | undefined;82 stateDir?: string | undefined;
95 persistenceLayer?:
96 | Layer.Layer<SqlClient.SqlClient, SqlError.SqlError | Migrator.MigrationError>
97 | undefined;
98 staticDir?: string | undefined;83 staticDir?: string | undefined;
99 devUrl?: string | undefined;84 devUrl?: string | undefined;
100 logWebSocketEvents?: boolean | undefined;85 logWebSocketEvents?: boolean | undefined;
101 gitManager?: GitManager | undefined;86 gitManager?: GitManager | undefined;
102 terminalManager?: TerminalManager | undefined;87 terminalManager?: TerminalManager | undefined;
103 providerLayer?: Layer.Layer<ProviderService, unknown> | undefined;
104 authToken?: string | undefined;88 authToken?: string | undefined;
105 open?: OpenShape | undefined;89 open?: OpenShape | undefined;
106}90}
10791
108export interface ServerRuntime {
109 readonly start: () => Promise<void>;
110 readonly stop: () => Promise<void>;
111 readonly httpServer: http.Server;
112}
113
114export interface ServerShape {92export interface ServerShape {
115 readonly createServer: (options: ServerOptions) => ServerRuntime;93 readonly createServer: (
94 options: ServerOptions,
95 ) => Effect.Effect<http.Server, unknown, Scope.Scope | ServerRuntimeServices>;
116 readonly stopSignal: Effect.Effect<unknown, never>;96 readonly stopSignal: Effect.Effect<unknown, never>;
117}97}
11898
119export class Server extends ServiceMap.Service<Server, ServerShape>()("server/Server") {}99export class Server extends ServiceMap.Service<Server, ServerShape>()("server/Server") {}
120100
121export const ServerLive = Layer.succeed(Server, {
122 createServer,
123 stopSignal: Effect.never,
124} satisfies ServerShape);
125
126const isServerNotRunningError = (error: unknown): boolean => {101const isServerNotRunningError = (error: unknown): boolean => {
127 if (!(error instanceof Error)) return false;102 if (!(error instanceof Error)) return false;
128 const maybeCode = (error as NodeJS.ErrnoException).code;103 const maybeCode = (error as NodeJS.ErrnoException).code;
...@@ -195,47 +170,104 @@ function checkpointRefForThreadTurn(threadId: string, turnCount: number): Checkp...@@ -195,47 +170,104 @@ function checkpointRefForThreadTurn(threadId: string, turnCount: number): Checkp
195 return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`);170 return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`);
196}171}
197172
198type EffectRuntime = ManagedRuntime.ManagedRuntime<173export type ServerCoreRuntimeServices =
199 | OrchestrationEngineService174 | OrchestrationEngineService
200 | ProjectionSnapshotQuery175 | ProjectionSnapshotQuery
201 | ProviderService
202 | CheckpointStore176 | CheckpointStore
203 | OrchestrationReactor,177 | OrchestrationReactor;
204 unknown178
205>;179export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService;
206180
207export function createServer(options: ServerOptions): ServerRuntime {181export function makeServerPersistenceLayer(stateDir: string) {
182 const dbPath = path.join(stateDir, "orchestration.sqlite");
183 return makeSqlitePersistenceLive(dbPath);
184}
185
186export 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
202export 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
237export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
238 "ServerLifecycleError",
239 {
240 operation: Schema.String,
241 cause: Schema.optional(Schema.Defect),
242 },
243) {}
244
245class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("RouteRequestError", {
246 message: Schema.String,
247}) {}
248
249export const createServer = Effect.fn(function* (
250 options: ServerOptions,
251): Effect.fn.Return<http.Server, unknown, Scope.Scope | ServerRuntimeServices> {
208 const {252 const {
209 port,253 port,
210 host,254 host,
211 cwd,255 cwd,
212 autoBootstrapProjectFromCwd = false,256 autoBootstrapProjectFromCwd = false,
213 stateDir,
214 persistenceLayer: customPersistenceLayer,
215 staticDir,257 staticDir,
216 devUrl,258 devUrl,
217 logWebSocketEvents: explicitLogWsEvents,259 logWebSocketEvents: explicitLogWsEvents,
218 gitManager = new GitManager(),260 gitManager = new GitManager(),
219 terminalManager = new TerminalManager(),261 terminalManager = new TerminalManager(),
220 providerLayer: customProviderLayer,
221 authToken,262 authToken,
222 open: openService = OpenDefaultService,263 open: openService = OpenDefaultService,
223 } = options;264 } = 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;
231 const clients = new Set<WebSocket>();266 const clients = new Set<WebSocket>();
232 const logger = createLogger("ws");267 const logger = createLogger("ws");
233 const logWebSocketEvents =268 const logWebSocketEvents =
234 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);269 explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl);
235 let keybindingsConfig = loadResolvedKeybindingsConfig(logger);270 let keybindingsConfig = loadResolvedKeybindingsConfig(logger);
236 let orchestrationDomainEventLogger: EventNdjsonLogger | undefined;
237 let orchestrationCommandLogger: EventNdjsonLogger | undefined;
238 let subscriptionsScope: Scope.Closeable | null = null;
239271
240 function logOutgoingPush(push: WsPush, recipients: number) {272 function logOutgoingPush(push: WsPush, recipients: number) {
241 if (!logWebSocketEvents) return;273 if (!logWebSocketEvents) return;
...@@ -246,86 +278,6 @@ export function createServer(options: ServerOptions): ServerRuntime {...@@ -246,86 +278,6 @@ export function createServer(options: ServerOptions): ServerRuntime {
246 });278 });
247 }279 }
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
329 const onTerminalEvent = (event: TerminalEvent) => {281 const onTerminalEvent = (event: TerminalEvent) => {
330 const push: WsPush = {282 const push: WsPush = {
331 type: "push",283 type: "push",
...@@ -342,7 +294,6 @@ export function createServer(options: ServerOptions): ServerRuntime {...@@ -342,7 +294,6 @@ export function createServer(options: ServerOptions): ServerRuntime {
342 }294 }
343 logOutgoingPush(push, recipients);295 logOutgoingPush(push, recipients);
344 };296 };
345 terminalManager.on("event", onTerminalEvent);
346297
347 // HTTP server — serves static files or redirects to Vite dev server298 // HTTP server — serves static files or redirects to Vite dev server
348 const httpServer = http.createServer((req, res) => {299 const httpServer = http.createServer((req, res) => {
...@@ -403,104 +354,85 @@ export function createServer(options: ServerOptions): ServerRuntime {...@@ -403,104 +354,85 @@ export function createServer(options: ServerOptions): ServerRuntime {
403 // WebSocket server — upgrades from the HTTP server354 // WebSocket server — upgrades from the HTTP server
404 const wss = new WebSocketServer({ noServer: true });355 const wss = new WebSocketServer({ noServer: true });
405356
406 httpServer.on("upgrade", (request, socket, head) => {357 const closeWebSocketServer = Effect.callback<void, ServerLifecycleError>((resume) => {
407 if (authToken) {358 wss.close((error) => {
408 let providedToken: string | null = null;359 if (error && !isServerNotRunningError(error)) {
409 try {360 resume(
410 const url = new URL(request.url ?? "/", `http://localhost:${port}`);361 Effect.fail(
411 providedToken = url.searchParams.get("token");362 new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }),
412 } catch {363 ),
413 rejectUpgrade(socket, 400, "Invalid WebSocket URL");364 );
414 return;365 } else {
415 }366 resume(Effect.void);
416
417 if (providedToken !== authToken) {
418 rejectUpgrade(socket, 401, "Unauthorized WebSocket connection");
419 return;
420 }367 }
421 }
422
423 wss.handleUpgrade(request, socket, head, (ws) => {
424 wss.emit("connection", ws, request);
425 });368 });
426 });369 });
427370
428 wss.on("connection", (ws) => {371 const closeAllClients = () => {
429 clients.add(ws);372 for (const client of clients) {
430373 client.close();
431 // Send welcome message with project info374 }
432 const segments = cwd.split(/[/\\]/).filter(Boolean);375 clients.clear();
433 const projectName = segments[segments.length - 1] ?? "project";376 };
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));
442377
443 ws.on("message", (raw) => {378 const listenOptions = host ? { host, port } : { port };
444 void handleMessage(ws, raw);
445 });
446379
447 ws.on("close", () => {380 const orchestrationEngine = yield* OrchestrationEngineService;
448 clients.delete(ws);381 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
449 });382 const liveCheckpointStore = yield* CheckpointStore;
383 const liveProviderService = yield* ProviderService;
384 const orchestrationReactor = yield* OrchestrationReactor;
450385
451 ws.on("error", () => {386 const subscriptionsScope = yield* Scope.make("sequential");
452 clients.delete(ws);387 yield* Effect.addFinalizer(() =>
453 });388 Scope.close(subscriptionsScope, Exit.void).pipe(Effect.orElseSucceed(() => undefined)),
454 });389 );
455390
456 async function handleMessage(ws: WebSocket, raw: unknown) {391 yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
457 const messageText = websocketRawToString(raw);392 Effect.sync(() => {
458 if (messageText === null) {393 const push: WsPush = {
459 const errorResponse: WsResponse = {394 type: "push",
460 id: "unknown",395 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
461 error: { message: "Invalid request format" },396 data: event,
462 };397 };
463 ws.send(JSON.stringify(errorResponse));398 const message = JSON.stringify(push);
464 return;399 let recipients = 0;
465 }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;410 yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope));
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 }
478411
479 try {412 if (autoBootstrapProjectFromCwd) {
480 const result = await routeRequest(request);413 const snapshot = yield* projectionReadModelQuery.getSnapshot();
481 const response: WsResponse = { id: request.id, result };414 const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd);
482 ws.send(JSON.stringify(response));415 if (!existing) {
483 } catch (err) {416 const createdAt = new Date().toISOString();
484 const message = err instanceof Error ? err.message : "Unknown server error";417 const projectName = path.basename(cwd) || "project";
485 const response: WsResponse = {418 yield* orchestrationEngine.dispatchUnknownCommand({
486 id: request.id,419 type: "project.create",
487 error: { message },420 commandId: crypto.randomUUID(),
488 };421 projectId: crypto.randomUUID(),
489 ws.send(JSON.stringify(response));422 title: projectName,
423 workspaceRoot: cwd,
424 defaultModel: "gpt-5-codex",
425 createdAt,
426 });
490 }427 }
491 }428 }
492429
493 async function routeRequest(request: WebSocketRequest): Promise<unknown> {430 const routeRequest = Effect.fn(function* (request: WebSocketRequest) {
494 const orchestrationEngine = getOrchestrationEngine();431 const computeTurnDiff = Effect.fn(function* (input: {
495 const projectionReadModelQuery = getProjectionSnapshotQuery();
496 const effectRuntime = getEffectRuntime();
497 const liveCheckpointStore = getCheckpointStore();
498
499 const computeTurnDiff = async (input: {
500 readonly threadId: string;432 readonly threadId: string;
501 readonly fromTurnCount: number;433 readonly fromTurnCount: number;
502 readonly toTurnCount: number;434 readonly toTurnCount: number;
503 }) => {435 }) {
504 if (input.fromTurnCount > input.toTurnCount) {436 if (input.fromTurnCount > input.toTurnCount) {
505 throw new Error(437 throw new Error(
506 `Invalid turn diff range for thread '${input.threadId}': from ${input.fromTurnCount} exceeds to ${input.toTurnCount}.`,438 `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 {...@@ -516,7 +448,7 @@ export function createServer(options: ServerOptions): ServerRuntime {
516 };448 };
517 }449 }
518450
519 const snapshot = await effectRuntime.runPromise(projectionReadModelQuery.getSnapshot());451 const snapshot = yield* projectionReadModelQuery.getSnapshot();
520 const thread = snapshot.threads.find((entry) => entry.id === input.threadId);452 const thread = snapshot.threads.find((entry) => entry.id === input.threadId);
521 if (!thread) {453 if (!thread) {
522 throw new Error(`Thread '${input.threadId}' not found.`);454 throw new Error(`Thread '${input.threadId}' not found.`);
...@@ -562,20 +494,19 @@ export function createServer(options: ServerOptions): ServerRuntime {...@@ -562,20 +494,19 @@ export function createServer(options: ServerOptions): ServerRuntime {
562 );494 );
563 }495 }
564496
565 const [fromExists, toExists] = await Promise.all([497 const [fromExists, toExists] = yield* Effect.all(
566 effectRuntime.runPromise(498 [
567 liveCheckpointStore.hasCheckpointRef({499 liveCheckpointStore.hasCheckpointRef({
568 cwd: workspaceCwd,500 cwd: workspaceCwd,
569 checkpointRef: fromCheckpointRef,501 checkpointRef: fromCheckpointRef,
570 }),502 }),
571 ),
572 effectRuntime.runPromise(
573 liveCheckpointStore.hasCheckpointRef({503 liveCheckpointStore.hasCheckpointRef({
574 cwd: workspaceCwd,504 cwd: workspaceCwd,
575 checkpointRef: toCheckpointRef,505 checkpointRef: toCheckpointRef,
576 }),506 }),
577 ),507 ],
578 ]);508 { concurrency: "unbounded" },
509 );
579 if (!fromExists) {510 if (!fromExists) {
580 throw new Error(511 throw new Error(
581 `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`,512 `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`,
...@@ -587,304 +518,282 @@ export function createServer(options: ServerOptions): ServerRuntime {...@@ -587,304 +518,282 @@ export function createServer(options: ServerOptions): ServerRuntime {
587 );518 );
588 }519 }
589520
590 const diff = await effectRuntime.runPromise(521 const diff = yield* liveCheckpointStore.diffCheckpoints({
591 liveCheckpointStore.diffCheckpoints({522 cwd: workspaceCwd,
592 cwd: workspaceCwd,523 fromCheckpointRef,
593 fromCheckpointRef,524 toCheckpointRef,
594 toCheckpointRef,525 fallbackFromToHead: false,
595 fallbackFromToHead: false,526 });
596 }),
597 );
598 return {527 return {
599 threadId: input.threadId,528 threadId: input.threadId,
600 fromTurnCount: input.fromTurnCount,529 fromTurnCount: input.fromTurnCount,
601 toTurnCount: input.toTurnCount,530 toTurnCount: input.toTurnCount,
602 diff,531 diff,
603 };532 };
604 };533 });
605534
606 switch (request.body._tag) {535 switch (request.body._tag) {
607 case ORCHESTRATION_WS_METHODS.getSnapshot: {536 case ORCHESTRATION_WS_METHODS.getSnapshot:
608 return effectRuntime.runPromise(projectionReadModelQuery.getSnapshot());537 return yield* projectionReadModelQuery.getSnapshot();
609 }
610538
611 case ORCHESTRATION_WS_METHODS.dispatchCommand: {539 case ORCHESTRATION_WS_METHODS.dispatchCommand: {
612 orchestrationCommandLogger?.write({540 const { command } = request.body;
613 observedAt: new Date().toISOString(),541 return yield* orchestrationEngine.dispatch(command);
614 command: request.body.command,
615 });
616 return effectRuntime.runPromise(orchestrationEngine.dispatch(request.body.command));
617 }542 }
618543
619 case ORCHESTRATION_WS_METHODS.getTurnDiff: {544 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
620 const body = stripRequestTag(request.body);545 const body = stripRequestTag(request.body);
621 return computeTurnDiff(body);546 return yield* computeTurnDiff(body);
622 }547 }
623548
624 case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {549 case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
625 const body = stripRequestTag(request.body);550 const body = stripRequestTag(request.body);
626 return computeTurnDiff({551 return yield* computeTurnDiff({
627 threadId: body.threadId,552 threadId: body.threadId,
628 fromTurnCount: 0,553 fromTurnCount: 0,
629 toTurnCount: body.toTurnCount,554 toTurnCount: body.toTurnCount,
630 });555 });
631 }556 }
632557
633 case ORCHESTRATION_WS_METHODS.replayEvents:558 case ORCHESTRATION_WS_METHODS.replayEvents: {
634 return effectRuntime.runPromise(559 const { fromSequenceExclusive } = request.body;
635 Stream.runCollect(560 return yield* Stream.runCollect(
636 orchestrationEngine.readEvents(561 orchestrationEngine.readEvents(
637 clamp(request.body.fromSequenceExclusive, {562 clamp(fromSequenceExclusive, {
638 maximum: Number.MAX_SAFE_INTEGER,563 maximum: Number.MAX_SAFE_INTEGER,
639 minimum: 0,564 minimum: 0,
640 }),565 }),
641 ),566 ),
642 ).pipe(Effect.map((events) => Array.from(events))),567 ).pipe(Effect.map((events) => Array.from(events)));
643 );568 }
644569
645 case WS_METHODS.projectsSearchEntries:570 case WS_METHODS.projectsSearchEntries: {
646 return searchWorkspaceEntries(stripRequestTag(request.body));571 const body = stripRequestTag(request.body);
572 return yield* Effect.promise(() => searchWorkspaceEntries(body));
573 }
647574
648 case WS_METHODS.shellOpenInEditor: {575 case WS_METHODS.shellOpenInEditor: {
649 await Effect.runPromise(openService.openInEditor(stripRequestTag(request.body)));576 const body = stripRequestTag(request.body);
650 return undefined;577 return yield* openService.openInEditor(body);
651 }578 }
652579
653 case WS_METHODS.gitStatus:580 case WS_METHODS.gitStatus: {
654 return gitManager.status(stripRequestTag(request.body));581 const body = stripRequestTag(request.body);
582 return yield* Effect.promise(() => gitManager.status(body));
583 }
655584
656 case WS_METHODS.gitPull:585 case WS_METHODS.gitPull: {
657 return pullGitBranch(stripRequestTag(request.body));586 const body = stripRequestTag(request.body);
587 return yield* Effect.promise(() => pullGitBranch(body));
588 }
658589
659 case WS_METHODS.gitRunStackedAction:590 case WS_METHODS.gitRunStackedAction: {
660 return gitManager.runStackedAction(stripRequestTag(request.body));591 const body = stripRequestTag(request.body);
661 case WS_METHODS.gitListBranches:592 return yield* Effect.promise(() => gitManager.runStackedAction(body));
662 return listGitBranches(stripRequestTag(request.body));593 }
663594
664 case WS_METHODS.gitCreateWorktree:595 case WS_METHODS.gitListBranches: {
665 return createGitWorktree(stripRequestTag(request.body));596 const body = stripRequestTag(request.body);
597 return yield* Effect.promise(() => listGitBranches(body));
598 }
666599
667 case WS_METHODS.gitRemoveWorktree:600 case WS_METHODS.gitCreateWorktree: {
668 return removeGitWorktree(stripRequestTag(request.body));601 const body = stripRequestTag(request.body);
602 return yield* Effect.promise(() => createGitWorktree(body));
603 }
669604
670 case WS_METHODS.gitCreateBranch:605 case WS_METHODS.gitRemoveWorktree: {
671 return createGitBranch(stripRequestTag(request.body));606 const body = stripRequestTag(request.body);
607 return yield* Effect.promise(() => removeGitWorktree(body));
608 }
672609
673 case WS_METHODS.gitCheckout:610 case WS_METHODS.gitCreateBranch: {
674 return checkoutGitBranch(stripRequestTag(request.body));611 const body = stripRequestTag(request.body);
612 return yield* Effect.promise(() => createGitBranch(body));
613 }
675614
676 case WS_METHODS.gitInit:615 case WS_METHODS.gitCheckout: {
677 return initGitRepo(stripRequestTag(request.body));616 const body = stripRequestTag(request.body);
617 return yield* Effect.promise(() => checkoutGitBranch(body));
618 }
678619
679 case WS_METHODS.terminalOpen:620 case WS_METHODS.gitInit: {
680 return terminalManager.open(stripRequestTag(request.body));621 const body = stripRequestTag(request.body);
622 return yield* Effect.promise(() => initGitRepo(body));
623 }
681624
682 case WS_METHODS.terminalWrite:625 case WS_METHODS.terminalOpen: {
683 await terminalManager.write(stripRequestTag(request.body));626 const body = stripRequestTag(request.body);
684 return undefined;627 return yield* Effect.promise(() => terminalManager.open(body));
628 }
685629
686 case WS_METHODS.terminalResize:630 case WS_METHODS.terminalWrite: {
687 await terminalManager.resize(stripRequestTag(request.body));631 const body = stripRequestTag(request.body);
688 return undefined;632 return yield* Effect.promise(() => terminalManager.write(body));
633 }
689634
690 case WS_METHODS.terminalClear:635 case WS_METHODS.terminalResize: {
691 await terminalManager.clear(stripRequestTag(request.body));636 const body = stripRequestTag(request.body);
692 return undefined;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:645 case WS_METHODS.terminalRestart: {
695 return terminalManager.restart(stripRequestTag(request.body));646 const body = stripRequestTag(request.body);
647 return yield* Effect.promise(() => terminalManager.restart(body));
648 }
696649
697 case WS_METHODS.terminalClose:650 case WS_METHODS.terminalClose: {
698 await terminalManager.close(stripRequestTag(request.body));651 const body = stripRequestTag(request.body);
699 return undefined;652 return yield* Effect.promise(() => terminalManager.close(body));
653 }
700654
701 case WS_METHODS.serverGetConfig:655 case WS_METHODS.serverGetConfig:
702 return {656 return { cwd, keybindings: keybindingsConfig };
703 cwd,
704 keybindings: keybindingsConfig,
705 };
706657
707 case WS_METHODS.serverUpsertKeybinding:658 case WS_METHODS.serverUpsertKeybinding: {
708 keybindingsConfig = upsertKeybindingRule(logger, stripRequestTag(request.body));659 const body = stripRequestTag(request.body);
709 return {660 keybindingsConfig = upsertKeybindingRule(logger, body);
710 keybindings: keybindingsConfig,661 return { keybindings: keybindingsConfig };
711 };662 }
712663
713 default: {664 default: {
714 const _exhaustiveCheck: never = request.body;665 const _exhaustiveCheck: never = request.body;
715 throw new Error(`Unknown method: ${String(_exhaustiveCheck)}`);666 return yield* new RouteRequestError({
716 }667 message: `Unknown method: ${String(_exhaustiveCheck)}`,
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"),
742 });668 });
743 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(669 }
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;
800 }670 }
671 });
801672
802 return runtime;673 const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) {
803 }674 const encodeResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse));
804675
805 async function disposeEffectRuntime() {676 const messageText = websocketRawToString(raw);
806 if (subscriptionsScope && effectRuntime) {677 if (messageText === null) {
807 await effectRuntime.runPromise(Scope.close(subscriptionsScope, Exit.void));678 const errorResponse = yield* encodeResponse(
808 subscriptionsScope = null;679 new WebSocketResponse({
680 id: "unknown",
681 error: { message: "Invalid request format: Failed to read message" },
682 }),
683 );
684 ws.send(errorResponse);
685 return;
809 }686 }
810 orchestrationDomainEventLogger?.close();687
811 orchestrationDomainEventLogger = undefined;688 const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText);
812 orchestrationCommandLogger?.close();689 if (request._tag === "Failure") {
813 orchestrationCommandLogger = undefined;690 const errorResponse = yield* encodeResponse(
814691 new WebSocketResponse({
815 const runtime = effectRuntime;692 id: "unknown",
816 const liveProviderService = providerService;693 error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` },
817 effectRuntime = null;694 }),
818 orchestrationEngine = null;695 );
819 projectionSnapshotQuery = null;696 ws.send(errorResponse);
820 checkpointStore = null;
821 providerService = null;
822
823 if (!runtime) {
824 return;697 return;
825 }698 }
826699
827 if (liveProviderService) {700 const result = yield* Effect.exit(routeRequest(request.value));
828 await runtime.runPromise(liveProviderService.stopAll()).catch(() => undefined);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;
829 }710 }
830711
831 await runtime.dispose();712 const response = yield* encodeResponse(
832 }713 new WebSocketResponse({
714 id: request.value.id,
715 result: result.value,
716 }),
717 );
718 ws.send(response);
719 });
833720
834 async function start() {721 httpServer.on("upgrade", (request, socket, head) => {
835 effectRuntime = await createEffectRuntime();722 if (authToken) {
836 await bootstrapProjectForCwd();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) => {732 if (providedToken !== authToken) {
839 const onError = (error: Error) => {733 rejectUpgrade(socket, 401, "Unauthorized WebSocket connection");
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);
850 return;734 return;
851 }735 }
852 httpServer.listen(port, onListening);736 }
737
738 wss.handleUpgrade(request, socket, head, (ws) => {
739 wss.emit("connection", ws, request);
853 });740 });
854 }741 });
855742
856 async function stop(): Promise<void> {743 wss.on("connection", (ws) => {
857 await disposeEffectRuntime();744 clients.add(ws);
858 terminalManager.off("event", onTerminalEvent);
859 terminalManager.dispose();
860745
861 for (const client of clients) {746 const segments = cwd.split(/[/\\]/).filter(Boolean);
862 client.close();747 const projectName = segments[segments.length - 1] ?? "project";
863 }
864 clients.clear();
865748
866 const closeWebSocketServer = new Promise<void>((resolve, reject) => {749 const welcome: WsPush = {
867 wss.close((error) => {750 type: "push",
868 if (error && !isServerNotRunningError(error)) {751 channel: WS_CHANNELS.serverWelcome,
869 reject(error);752 data: { cwd, projectName },
870 return;753 };
871 }754 logOutgoingPush(welcome, 1);
872 resolve();755 ws.send(JSON.stringify(welcome));
873 });756
757 ws.on("message", (raw) => {
758 void Effect.runPromise(handleMessage(ws, raw));
874 });759 });
875760
876 const closeHttpServer = new Promise<void>((resolve, reject) => {761 ws.on("close", () => {
877 httpServer.close((error) => {762 clients.delete(ws);
878 if (error && !isServerNotRunningError(error)) {
879 reject(error);
880 return;
881 }
882 resolve();
883 });
884 });763 });
885764
886 await Promise.all([closeWebSocketServer, closeHttpServer]);765 ws.on("error", () => {
887 }766 clients.delete(ws);
767 });
768 });
888769
889 return { start, stop, httpServer };770 yield* Effect.addFinalizer(() =>
890}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
796export const ServerLive = Layer.succeed(Server, {
797 createServer,
798 stopSignal: Effect.never,
799} satisfies ServerShape);