authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 18:25:26-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log3fbd02c6cfc667dbf239cb325f00caa2e4ac662c
tree3b8cacee840bb3b8e2f8535e41bf7cc0f2e1ebea
parentda3d8e2ecb572e107b4ceec41d8966fe2f8f3db1

alright


11 files changed, 436 insertions(+), 375 deletions(-)

apps/server/src/config.ts created+87
...@@ -0,0 +1,87 @@
1import * as Net from "node:net";
2import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect";
3
4export const DEFAULT_PORT = 3773;
5
6export type RuntimeMode = "web" | "desktop";
7
8export interface ServerConfigShape {
9 readonly mode: RuntimeMode;
10 readonly port: number;
11 readonly host: string | undefined;
12 readonly cwd: string;
13 readonly stateDir: string;
14 readonly staticDir: string | undefined;
15 readonly devUrl: URL | undefined;
16 readonly noBrowser: boolean;
17 readonly authToken: string | undefined;
18}
19
20export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(
21 "server/ServerConfig",
22) {}
23
24// Helpers
25
26export class NetError extends Data.TaggedError("NetError")<{
27 readonly message: string;
28 readonly cause?: unknown;
29}> {}
30
31export interface NetServiceShape {
32 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, NetError>;
33}
34
35export class NetService extends ServiceMap.Service<NetService, NetServiceShape>()(
36 "server/NetService",
37) {
38 static readonly layer = Layer.succeed(NetService, {
39 findAvailablePort: (preferred) =>
40 Effect.callback<number, NetError>((resume) => {
41 const server = Net.createServer();
42 server.listen(preferred, () => {
43 server.close(() => resume(Effect.succeed(preferred)));
44 });
45 server.on("error", () => {
46 const fallback = Net.createServer();
47 fallback.listen(0, () => {
48 const addr = fallback.address();
49 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
50 fallback.close(() => {
51 resume(
52 port > 0
53 ? Effect.succeed(port)
54 : Effect.fail(new NetError({ message: "Could not find an available port." })),
55 );
56 });
57 });
58 fallback.on("error", (cause) => {
59 resume(
60 Effect.fail(new NetError({ message: "Failed to find an available port", cause })),
61 );
62 });
63 });
64 }),
65 });
66}
67
68export const resolveStaticDir = Effect.fn(function* () {
69 const { join, resolve } = yield* Path.Path;
70 const { stat } = yield* FileSystem.FileSystem;
71 const bundledClient = resolve(join(import.meta.dirname, "client"));
72 const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe(
73 Effect.orElseSucceed(() => undefined),
74 );
75 if (bundledStat) {
76 return bundledClient;
77 }
78
79 const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist"));
80 const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe(
81 Effect.orElseSucceed(() => undefined),
82 );
83 if (monorepoStat) {
84 return monorepoClient;
85 }
86 return undefined;
87});
apps/server/src/fixPath.ts deleted-18
...@@ -1,18 +0,0 @@
1import { execFileSync } from "node:child_process";
2
3export function fixPath(): void {
4 if (process.platform !== "darwin") return;
5
6 try {
7 const shell = process.env.SHELL ?? "/bin/zsh";
8 const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], {
9 encoding: "utf8",
10 timeout: 5000,
11 });
12 if (result) {
13 process.env.PATH = result;
14 }
15 } catch {
16 // Silently ignore — keep default PATH
17 }
18}
apps/server/src/index.ts+8-4
...@@ -3,15 +3,19 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -3,15 +3,19 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
55
6import { CliConfigLive, makeCliCommand } from "./main";6import { CliConfig, makeCliCommand } from "./main";
7import { OpenLive } from "./open";7import { OpenLive } from "./open";
8import { Command } from "effect/unstable/cli";8import { Command } from "effect/unstable/cli";
9import { version } from "../package.json" with { type: "json" };9import { version } from "../package.json" with { type: "json" };
10import { ServerLive } from "./wsServer";10import { ServerLive } from "./wsServer";
11import { NetService } from "./config";
1112
12const RuntimeLayer = Layer.provideMerge(13const RuntimeLayer = Layer.empty.pipe(
13 Layer.mergeAll(CliConfigLive, ServerLive, OpenLive),14 Layer.provideMerge(CliConfig.layer),
14 NodeServices.layer,15 Layer.provideMerge(ServerLive),
16 Layer.provideMerge(OpenLive),
17 Layer.provideMerge(NetService.layer),
18 Layer.provideMerge(NodeServices.layer),
15);19);
1620
17Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);21Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
apps/server/src/main.test.ts+20-17
...@@ -8,14 +8,17 @@ import * as Command from "effect/unstable/cli/Command";...@@ -8,14 +8,17 @@ import * as Command from "effect/unstable/cli/Command";
8import { beforeEach } from "vitest";8import { beforeEach } from "vitest";
99
10import { CliConfig, makeCliCommand, type CliConfigShape } from "./main";10import { CliConfig, makeCliCommand, type CliConfigShape } from "./main";
11import { NetService, ServerConfig, type ServerConfigShape } from "./config";
11import { Open, type OpenShape } from "./open";12import { Open, type OpenShape } from "./open";
12import { Server, type ServerShape, type ServerOptions } from "./wsServer";13import { Server, type ServerShape, type ServerOptions } from "./wsServer";
1314
14const start = vi.fn(() => undefined);15const start = vi.fn(() => undefined);
15const stop = vi.fn(() => undefined);16const stop = vi.fn(() => undefined);
16const createServer = vi.fn((_: ServerOptions) =>17let resolvedConfig: ServerConfigShape | null = null;
18const createServer = vi.fn((_: ServerOptions | undefined) =>
17 Effect.acquireRelease(19 Effect.acquireRelease(
18 Effect.sync(() => {20 Effect.gen(function* () {
21 resolvedConfig = yield* ServerConfig;
19 start();22 start();
20 return {} as unknown as Http.Server;23 return {} as unknown as Http.Server;
21 }),24 }),
...@@ -28,9 +31,11 @@ const testLayer = Layer.mergeAll(...@@ -28,9 +31,11 @@ const testLayer = Layer.mergeAll(
28 Layer.succeed(CliConfig, {31 Layer.succeed(CliConfig, {
29 cwd: "/tmp/t3-test-workspace",32 cwd: "/tmp/t3-test-workspace",
30 fixPath: Effect.void,33 fixPath: Effect.void,
31 findAvailablePort,
32 resolveStaticDir: Effect.undefined,34 resolveStaticDir: Effect.undefined,
33 } satisfies CliConfigShape),35 } satisfies CliConfigShape),
36 Layer.succeed(NetService, {
37 findAvailablePort,
38 }),
34 Layer.succeed(Server, {39 Layer.succeed(Server, {
35 createServer,40 createServer,
36 stopSignal: Effect.void,41 stopSignal: Effect.void,
...@@ -52,11 +57,13 @@ const runCli = (...@@ -52,11 +57,13 @@ const runCli = (
5257
53beforeEach(() => {58beforeEach(() => {
54 vi.clearAllMocks();59 vi.clearAllMocks();
60 resolvedConfig = null;
55 start.mockImplementation(() => undefined);61 start.mockImplementation(() => undefined);
56 stop.mockImplementation(() => undefined);62 stop.mockImplementation(() => undefined);
57 createServer.mockImplementation((_: ServerOptions) =>63 createServer.mockImplementation((_: ServerOptions | undefined) =>
58 Effect.acquireRelease(64 Effect.acquireRelease(
59 Effect.sync(() => {65 Effect.gen(function* () {
66 resolvedConfig = yield* ServerConfig;
60 start();67 start();
61 return {} as unknown as Http.Server;68 return {} as unknown as Http.Server;
62 }),69 }),
...@@ -72,9 +79,8 @@ it.layer(testLayer)("server cli", (it) => {...@@ -72,9 +79,8 @@ it.layer(testLayer)("server cli", (it) => {
72 yield* runCli(["--port", "4010", "--token", "secret"]);79 yield* runCli(["--port", "4010", "--token", "secret"]);
7380
74 assert.equal(createServer.mock.calls.length, 1);81 assert.equal(createServer.mock.calls.length, 1);
75 const options = createServer.mock.calls[0]?.[0];82 assert.equal(resolvedConfig?.port, 4010);
76 assert.equal(options?.port, 4010);83 assert.equal(resolvedConfig?.authToken, "secret");
77 assert.equal(options?.authToken, "secret");
78 assert.equal(start.mock.calls.length, 1);84 assert.equal(start.mock.calls.length, 1);
79 assert.equal(stop.mock.calls.length, 1);85 assert.equal(stop.mock.calls.length, 1);
80 }),86 }),
...@@ -89,9 +95,8 @@ it.layer(testLayer)("server cli", (it) => {...@@ -89,9 +95,8 @@ it.layer(testLayer)("server cli", (it) => {
89 });95 });
9096
91 assert.equal(createServer.mock.calls.length, 1);97 assert.equal(createServer.mock.calls.length, 1);
92 const options = createServer.mock.calls[0]?.[0];98 assert.equal(resolvedConfig?.port, 4999);
93 assert.equal(options?.port, 4999);99 assert.equal(resolvedConfig?.authToken, "env-token");
94 assert.equal(options?.authToken, "env-token");
95 assert.equal(findAvailablePort.mock.calls.length, 0);100 assert.equal(findAvailablePort.mock.calls.length, 0);
96 }),101 }),
97 );102 );
...@@ -103,9 +108,8 @@ it.layer(testLayer)("server cli", (it) => {...@@ -103,9 +108,8 @@ it.layer(testLayer)("server cli", (it) => {
103108
104 assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);109 assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
105 assert.equal(createServer.mock.calls.length, 1);110 assert.equal(createServer.mock.calls.length, 1);
106 const options = createServer.mock.calls[0]?.[0];111 assert.equal(resolvedConfig?.port, 5444);
107 assert.equal(options?.port, 5444);112 assert.equal(resolvedConfig?.mode, "web");
108 assert.equal(options?.host, undefined);
109 }),113 }),
110 );114 );
111115
...@@ -118,9 +122,8 @@ it.layer(testLayer)("server cli", (it) => {...@@ -118,9 +122,8 @@ it.layer(testLayer)("server cli", (it) => {
118122
119 assert.equal(findAvailablePort.mock.calls.length, 0);123 assert.equal(findAvailablePort.mock.calls.length, 0);
120 assert.equal(createServer.mock.calls.length, 1);124 assert.equal(createServer.mock.calls.length, 1);
121 const options = createServer.mock.calls[0]?.[0];125 assert.equal(resolvedConfig?.port, 3773);
122 assert.equal(options?.port, 3773);126 assert.equal(resolvedConfig?.mode, "desktop");
123 assert.equal(options?.host, "127.0.0.1");
124 }),127 }),
125 );128 );
126129
apps/server/src/main.ts+122-185
...@@ -1,21 +1,19 @@...@@ -1,21 +1,19 @@
1import net from "node:net";1import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
2import os from "node:os";
3
4import { ServiceMap, Data, Effect, Layer, Option, Config, Path, Schema, FileSystem } from "effect";
5import { Command, Flag } from "effect/unstable/cli";2import { Command, Flag } from "effect/unstable/cli";
63
7import { fixPath } from "./fixPath";
8import { Open } from "./open";
9import {4import {
10 makeServerPersistenceLayer,5 DEFAULT_PORT,
11 makeServerProviderLayer,6 NetService,
12 makeServerRuntimeServicesLayer,7 resolveStaticDir,
13 Server,8 ServerConfig,
14} from "./wsServer";9 type RuntimeMode,
1510 type ServerConfigShape,
16const DEFAULT_PORT = 3773;11} from "./config";
1712import { fixPath, resolveStateDir } from "./os-jank";
18type RuntimeMode = "web" | "desktop";13import { Open } from "./open";
14import * as SqlitePersistence from "./persistence/Layers/Sqlite";
15import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
16import { Server } from "./wsServer";
1917
20export class StartupError extends Data.TaggedError("StartupError")<{18export class StartupError extends Data.TaggedError("StartupError")<{
21 readonly message: string;19 readonly message: string;
...@@ -29,21 +27,34 @@ interface CliInput {...@@ -29,21 +27,34 @@ interface CliInput {
2927
30export interface CliConfigShape {28export interface CliConfigShape {
31 readonly cwd: string;29 readonly cwd: string;
32 readonly fixPath: Effect.Effect<unknown, never>;30 readonly fixPath: Effect.Effect<void>;
33 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>;
34 readonly resolveStaticDir: Effect.Effect<string | undefined>;31 readonly resolveStaticDir: Effect.Effect<string | undefined>;
35}32}
3633
37export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(34export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()("server/CliConfig") {
38 "server/CliConfig",35 static readonly layer = Layer.effect(
39) {}36 CliConfig,
37 Effect.gen(function* () {
38 const fileSystem = yield* FileSystem.FileSystem;
39 const path = yield* Path.Path;
40 return {
41 cwd: process.cwd(),
42 fixPath: Effect.sync(fixPath),
43 resolveStaticDir: resolveStaticDir().pipe(
44 Effect.provideService(FileSystem.FileSystem, fileSystem),
45 Effect.provideService(Path.Path, path),
46 ),
47 } satisfies CliConfigShape;
48 }),
49 );
50}
4051
41const CliEnvConfig = Config.all({52const CliEnvConfig = Config.all({
42 mode: Config.string("T3CODE_MODE").pipe(53 mode: Config.string("T3CODE_MODE").pipe(
43 Config.option,54 Config.option,
44 Config.map(55 Config.map(
45 Option.match({56 Option.match<RuntimeMode, string>({
46 onNone: () => "web" as const,57 onNone: () => "web",
47 onSome: (value) => (value === "desktop" ? "desktop" : "web"),58 onSome: (value) => (value === "desktop" ? "desktop" : "web"),
48 }),59 }),
49 ),60 ),
...@@ -64,159 +75,102 @@ const CliEnvConfig = Config.all({...@@ -64,159 +75,102 @@ const CliEnvConfig = Config.all({
64 ),75 ),
65});76});
6677
67const expandHomePath = Effect.fn(function* (input: string) {78const ServerConfigLive = (input: CliInput) =>
68 const { join, sep } = yield* Path.Path;79 Layer.effect(
69 if (input === "~") return os.homedir();80 ServerConfig,
70 if (input.startsWith(`~${sep}`)) {81 Effect.gen(function* () {
71 return join(os.homedir(), input.slice(sep.length));82 const cliConfig = yield* CliConfig;
72 }83 const { findAvailablePort } = yield* NetService;
73 return input;84 const env = yield* CliEnvConfig.asEffect().pipe(
74});85 Effect.mapError(
7586 (cause) =>
76const resolveStateDir = Effect.fn(function* (raw: string | undefined) {87 new StartupError({ message: "Failed to read environment configuration", cause }),
77 const { join, resolve } = yield* Path.Path;88 ),
78 if (!raw || raw.trim().length === 0) {89 );
79 return join(os.homedir(), ".t3", "userdata");90
80 }91 const port = yield* Option.match(input.port, {
81 return resolve(yield* expandHomePath(raw.trim()));92 onSome: (value) => Effect.succeed(value),
82});93 onNone: () => {
8394 if (env.port) {
84const findAvailablePort = (preferred: number) =>95 return Effect.succeed(env.port);
85 Effect.callback<number, StartupError>((resume) => {96 }
86 const server = net.createServer();97 if (env.mode === "desktop") {
87 server.listen(preferred, () => {98 return Effect.succeed(DEFAULT_PORT);
88 server.close(() => resume(Effect.succeed(preferred)));99 }
89 });100 return findAvailablePort(DEFAULT_PORT);
90 server.on("error", () => {101 },
91 const fallback = net.createServer();
92 fallback.listen(0, () => {
93 const addr = fallback.address();
94 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
95 fallback.close(() => {
96 resume(
97 port > 0
98 ? Effect.succeed(port)
99 : Effect.fail(new StartupError({ message: "Could not find an available port." })),
100 );
101 });
102 });102 });
103 fallback.on("error", (cause) => {103 const stateDir = yield* resolveStateDir(env.stateDir);
104 resume(104 const devUrl = env.devUrl;
105 Effect.fail(new StartupError({ message: "Failed to find an available port", cause })),105 const noBrowser = env.noBrowser ?? env.mode === "desktop";
106 );106 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;
107 });107 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
108 });108 const host = env.mode === "desktop" ? "127.0.0.1" : undefined;
109 });109
110110 return {
111const resolveStaticDir = Effect.fn(function* () {111 mode: env.mode,
112 const { join, resolve } = yield* Path.Path;112 port,
113 const { stat } = yield* FileSystem.FileSystem;113 cwd: cliConfig.cwd,
114 const bundledClient = resolve(join(import.meta.dirname, "client"));114 host,
115 const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe(115 stateDir,
116 Effect.orElseSucceed(() => undefined),116 staticDir,
117 );117 devUrl,
118 if (bundledStat) return bundledClient;118 noBrowser,
119119 authToken,
120 const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist"));120 } satisfies ServerConfigShape;
121 const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe(121 }),
122 Effect.orElseSucceed(() => undefined),
123 );122 );
124 if (monorepoStat) return monorepoClient;
125 return undefined;
126});
127123
128const resolveCliConfig = Effect.fn(function* (input: CliInput) {124const LayerLive = (input: CliInput) =>
129 const cliConfig = yield* CliConfig;125 Layer.empty.pipe(
130 const env = yield* CliEnvConfig.asEffect().pipe(126 Layer.provideMerge(makeServerRuntimeServicesLayer()),
131 Effect.mapError(127 Layer.provideMerge(makeServerProviderLayer()),
132 (cause) =>128 Layer.provideMerge(SqlitePersistence.layerConfig),
133 new StartupError({129 Layer.provideMerge(ServerConfigLive(input)),
134 message: "Failed to read environment configuration",
135 cause,
136 }),
137 ),
138 );130 );
139 const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web";
140
141 const cliPort = Option.getOrUndefined(input.port);
142 const port =
143 (cliPort === undefined ? env.port : cliPort) ??
144 (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT));
145 const stateDir = yield* resolveStateDir(env.stateDir);
146 const devUrl = env.devUrl;
147 const noBrowser = env.noBrowser ?? mode === "desktop";
148 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;
149 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
150131
151 return {132const makeServerProgram = (input: CliInput) =>
152 mode,133 Effect.gen(function* () {
153 port,134 const cliConfig = yield* CliConfig;
154 stateDir,135 const { createServer, stopSignal } = yield* Server;
155 devUrl,136 const openDeps = yield* Open;
156 noBrowser,137 yield* cliConfig.fixPath;
157 authToken,138
158 staticDir,139 const config = yield* ServerConfig;
159 cwd: cliConfig.cwd,140
160 };141 if (!config.devUrl && !config.staticDir) {
161});142 yield* Effect.logWarning(
162143 "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable",
163const makeServerProgram = Effect.fn(function* (input: CliInput) {144 {
164 const cliConfig = yield* CliConfig;145 hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
165 const { createServer, stopSignal } = yield* Server;146 },
166 const openDeps = yield* Open;147 );
167 yield* cliConfig.fixPath;148 }
168 const config = yield* resolveCliConfig(input);149
169150 yield* createServer();
170 if (!config.devUrl && !config.staticDir) {151
171 yield* Effect.logWarning("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", {152 const url = `http://localhost:${config.port}`;
172 hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",153 yield* Effect.logInfo("T3 Code running", {
154 url,
155 cwd: config.cwd,
156 mode: config.mode,
157 stateDir: config.stateDir,
158 authEnabled: Boolean(config.authToken),
173 });159 });
174 }
175160
176 const persistenceLayer = makeServerPersistenceLayer(config.stateDir);161 if (!config.noBrowser) {
177 const providerLayer = makeServerProviderLayer(config.stateDir);162 const target = config.devUrl?.toString() ?? url;
178 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));163 yield* openDeps.openBrowser(target).pipe(
179 const serverRuntimeLayer = Layer.merge(164 Effect.catch(() =>
180 makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),165 Effect.logInfo("browser auto-open unavailable", {
181 infrastructureLayer,166 hint: `Open ${target} in your browser.`,
182 );167 }),
183168 ),
184 yield* createServer({169 );
185 port: config.port,170 }
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 })),
196 );
197171
198 const url = `http://localhost:${config.port}`;172 return yield* stopSignal;
199 yield* Effect.logInfo("T3 Code running", {173 }).pipe(Effect.provide(LayerLive(input)));
200 url,
201 cwd: config.cwd,
202 mode: config.mode,
203 stateDir: config.stateDir,
204 authEnabled: Boolean(config.authToken),
205 });
206
207 if (!config.noBrowser) {
208 const target = config.devUrl ?? url;
209 yield* openDeps.openBrowser(target.toString()).pipe(
210 Effect.catch(() =>
211 Effect.logInfo("browser auto-open unavailable", {
212 hint: `Open ${target} in your browser.`,
213 }),
214 ),
215 );
216 }
217
218 return yield* stopSignal;
219}, Effect.scoped);
220174
221export function makeCliCommand() {175export function makeCliCommand() {
222 const portFlag = Flag.integer("port").pipe(176 const portFlag = Flag.integer("port").pipe(
...@@ -234,23 +188,6 @@ export function makeCliCommand() {...@@ -234,23 +188,6 @@ export function makeCliCommand() {
234 token: tokenFlag,188 token: tokenFlag,
235 }).pipe(189 }).pipe(
236 Command.withDescription("Run the T3 Code server."),190 Command.withDescription("Run the T3 Code server."),
237 Command.withHandler((input) => makeServerProgram(input)),191 Command.withHandler((input) => Effect.scoped(makeServerProgram(input))),
238 );192 );
239}193}
240
241export const CliConfigLive = Layer.effect(
242 CliConfig,
243 Effect.gen(function* () {
244 const fileSystem = yield* FileSystem.FileSystem;
245 const path = yield* Path.Path;
246 return {
247 cwd: process.cwd(),
248 fixPath: Effect.sync(fixPath),
249 findAvailablePort,
250 resolveStaticDir: resolveStaticDir().pipe(
251 Effect.provideService(FileSystem.FileSystem, fileSystem),
252 Effect.provideService(Path.Path, path),
253 ),
254 } satisfies CliConfigShape;
255 }),
256);
apps/server/src/open.ts+23-38
...@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";...@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
22
3import { EDITORS, type EditorId } from "@t3tools/contracts";3import { EDITORS, type EditorId } from "@t3tools/contracts";
4import { ServiceMap, Schema, Effect, Layer } from "effect";4import { ServiceMap, Schema, Effect, Layer } from "effect";
5import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
56
6export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {7export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
7 message: Schema.String,8 message: Schema.String,
...@@ -25,7 +26,7 @@ export interface OpenShape {...@@ -25,7 +26,7 @@ export interface OpenShape {
2526
26export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {}27export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {}
2728
28export function resolveEditorLaunch(29function resolveEditorLaunch(
29 input: OpenInEditorInput,30 input: OpenInEditorInput,
30 platform: NodeJS.Platform = process.platform,31 platform: NodeJS.Platform = process.platform,
31): EditorLaunch {32): EditorLaunch {
...@@ -52,42 +53,26 @@ export function resolveEditorLaunch(...@@ -52,42 +53,26 @@ export function resolveEditorLaunch(
52 }53 }
53}54}
5455
55const OpenDefault: OpenShape = {56const make = Effect.gen(function* () {
56 openBrowser: (target) =>57 const open = yield* Effect.promise(() => import("open"));
57 Effect.tryPromise({58 const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner;
58 try: async () => {
59 const open = await import("open");
60 await open.default(target);
61 },
62 catch: (cause) =>
63 new OpenError({
64 message: "Browser auto-open failed",
65 cause,
66 }),
67 }),
68 openInEditor: (input) =>
69 Effect.try({
70 try: () => {
71 const launch = resolveEditorLaunch(input);
72 const child = spawn(launch.command, [...launch.args], {
73 detached: true,
74 stdio: "ignore",
75 });
76 child.on("error", () => {
77 // Best-effort behavior for detached editor launches.
78 });
79 child.unref();
80 },
81 catch: (cause) =>
82 cause instanceof OpenError
83 ? cause
84 : new OpenError({
85 message: "Failed to open editor",
86 cause,
87 }),
88 }),
89};
9059
91export const OpenLive = Layer.succeed(Open, OpenDefault);60 return {
61 openBrowser: (target) =>
62 Effect.tryPromise({
63 try: () => open.default(target),
64 catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
65 }),
66 openInEditor: (input) =>
67 Effect.gen(function* () {
68 const launch = resolveEditorLaunch(input);
69 const child = yield* spawn(ChildProcess.make(launch.command, launch.args));
70 yield* Effect.forkDetach(child.exitCode);
71 }).pipe(
72 Effect.catch((error) => Effect.logError("Failed to open editor", { cause: error })),
73 Effect.scoped,
74 ),
75 } satisfies OpenShape;
76});
9277
93export const OpenDefaultService = OpenDefault;78export const OpenLive = Layer.effect(Open, make);
apps/server/src/os-jank.ts created+39
...@@ -0,0 +1,39 @@
1import * as OS from "node:os";
2import { Effect, Path } from "effect";
3import { execFileSync } from "node:child_process";
4
5export function fixPath(): void {
6 if (process.platform !== "darwin") return;
7
8 try {
9 const shell = process.env.SHELL ?? "/bin/zsh";
10 const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], {
11 encoding: "utf8",
12 timeout: 5000,
13 });
14 if (result) {
15 process.env.PATH = result;
16 }
17 } catch {
18 // Silently ignore — keep default PATH
19 }
20}
21
22export const expandHomePath = Effect.fn(function* (input: string) {
23 const { join, sep } = yield* Path.Path;
24 if (input === "~") {
25 return OS.homedir();
26 }
27 if (input.startsWith(`~${sep}`)) {
28 return join(OS.homedir(), input.slice(sep.length));
29 }
30 return input;
31});
32
33export const resolveStateDir = Effect.fn(function* (raw: string | undefined) {
34 const { join, resolve } = yield* Path.Path;
35 if (!raw || raw.trim().length === 0) {
36 return join(OS.homedir(), ".t3", "userdata");
37 }
38 return resolve(yield* expandHomePath(raw.trim()));
39});
apps/server/src/persistence/Layers/Sqlite.ts+8
...@@ -2,6 +2,7 @@ import { Effect, Layer, FileSystem, Path } from "effect";...@@ -2,6 +2,7 @@ import { Effect, Layer, FileSystem, Path } from "effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";2import * as SqlClient from "effect/unstable/sql/SqlClient";
33
4import { runMigrations } from "../Migrations.ts";4import { runMigrations } from "../Migrations.ts";
5import { ServerConfig } from "../../config.ts";
56
6type RuntimeSqliteLayerConfig = {7type RuntimeSqliteLayerConfig = {
7 readonly filename: string;8 readonly filename: string;
...@@ -47,3 +48,10 @@ export const SqlitePersistenceMemory = Layer.provideMerge(...@@ -47,3 +48,10 @@ export const SqlitePersistenceMemory = Layer.provideMerge(
47 setup,48 setup,
48 makeRuntimeSqliteLayer({ filename: ":memory:" }),49 makeRuntimeSqliteLayer({ filename: ":memory:" }),
49);50);
51
52export const layerConfig = Effect.gen(function* () {
53 const { stateDir } = yield* ServerConfig;
54 const { join } = yield* Path.Path;
55 const dbPath = join(stateDir, "orchestration.sqlite");
56 return makeSqlitePersistenceLive(dbPath);
57}).pipe(Layer.unwrap);
apps/server/src/serverLayers.ts created+83
...@@ -0,0 +1,83 @@
1import path from "node:path";
2
3import { NodeServices } from "@effect/platform-node";
4import { Effect, Layer } from "effect";
5import * as SqlClient from "effect/unstable/sql/SqlClient";
6
7import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
8import { ServerConfig } from "./config";
9import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
10import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
11import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
12import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
13import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
14import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
15import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
16import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
17import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
18import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
19import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
20import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
21import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
22import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
23import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
24import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
25import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
26import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
27import { ProviderService } from "./provider/Services/ProviderService";
28
29export function makeServerProviderLayer(): Layer.Layer<
30 ProviderService,
31 unknown,
32 SqlClient.SqlClient | ServerConfig
33> {
34 return Effect.gen(function* () {
35 const { stateDir } = yield* ServerConfig;
36 const providerLogsDir = path.join(stateDir, "logs", "providers");
37 const codexAdapterLayer = makeCodexAdapterLive({
38 nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"),
39 });
40 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer));
41 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
42 Layer.provide(ProviderSessionRuntimeRepositoryLive),
43 );
44 return makeProviderServiceLive({
45 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
46 }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
47 }).pipe(Layer.unwrap);
48}
49
50export function makeServerRuntimeServicesLayer(): Layer.Layer<
51 OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor,
52 unknown,
53 SqlClient.SqlClient | ProviderService
54> {
55 const orchestrationLayer = OrchestrationEngineLive.pipe(
56 Layer.provide(OrchestrationProjectionPipelineLive),
57 Layer.provide(OrchestrationEventStoreLive),
58 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
59 );
60 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
61
62 const runtimeServicesLayer = Layer.mergeAll(
63 orchestrationLayer,
64 OrchestrationProjectionSnapshotQueryLive,
65 checkpointStoreLayer,
66 );
67 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
68 Layer.provideMerge(runtimeServicesLayer),
69 );
70 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
71 Layer.provideMerge(runtimeServicesLayer),
72 );
73 const checkpointReactorLayer = CheckpointReactorLive.pipe(
74 Layer.provideMerge(runtimeServicesLayer),
75 );
76 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
77 Layer.provideMerge(runtimeIngestionLayer),
78 Layer.provideMerge(providerCommandReactorLayer),
79 Layer.provideMerge(checkpointReactorLayer),
80 );
81
82 return orchestrationReactorLayer;
83}
apps/server/src/wsServer.test.ts+29-13
...@@ -6,8 +6,10 @@ import { EventEmitter } from "node:events";...@@ -6,8 +6,10 @@ import { EventEmitter } from "node:events";
66
7import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";7import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect";
8import { describe, expect, it, afterEach, vi } from "vitest";8import { describe, expect, it, afterEach, vi } from "vitest";
9import { createServer, makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./wsServer";9import { createServer } from "./wsServer";
10import WebSocket from "ws";10import WebSocket from "ws";
11import { ServerConfig, type ServerConfigShape } from "./config";
12import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
1113
12import {14import {
13 DEFAULT_TERMINAL_ID,15 DEFAULT_TERMINAL_ID,
...@@ -40,7 +42,7 @@ import type { TerminalManager } from "./terminalManager";...@@ -40,7 +42,7 @@ import type { TerminalManager } from "./terminalManager";
40import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";42import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
41import { SqlClient } from "effect/unstable/sql";43import { SqlClient } from "effect/unstable/sql";
42import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";44import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
43import type { OpenShape } from "./open";45import { Open, type OpenShape } from "./open";
4446
45interface PendingMessages {47interface PendingMessages {
46 queue: unknown[];48 queue: unknown[];
...@@ -56,6 +58,11 @@ const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId...@@ -56,6 +58,11 @@ const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId
56const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);58const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
57const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);59const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
5860
61const defaultOpenService: OpenShape = {
62 openBrowser: () => Effect.void,
63 openInEditor: () => Effect.void,
64};
65
59class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {66class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
60 private readonly sessions = new Map<string, TerminalSessionSnapshot>();67 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
6168
...@@ -323,28 +330,37 @@ describe("WebSocket Server", () => {...@@ -323,28 +330,37 @@ describe("WebSocket Server", () => {
323 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");330 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");
324 const scope = await Effect.runPromise(Scope.make("sequential"));331 const scope = await Effect.runPromise(Scope.make("sequential"));
325 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;332 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
326 const providerLayer = options.providerLayer ?? makeServerProviderLayer(stateDir);333 const providerLayer = options.providerLayer ?? makeServerProviderLayer();
334 const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService);
335 const serverConfigLayer = Layer.succeed(ServerConfig, {
336 mode: "web",
337 port: 0,
338 host: undefined,
339 cwd: options.cwd ?? "/test/project",
340 stateDir,
341 staticDir: undefined,
342 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,
343 noBrowser: true,
344 authToken: options.authToken,
345 } satisfies ServerConfigShape);
327 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));346 const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer));
328 const runtimeLayer = Layer.merge(347 const runtimeLayer = Layer.merge(
329 makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),348 makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)),
330 infrastructureLayer,349 infrastructureLayer,
331 );350 );
351 const dependenciesLayer = Layer.empty.pipe(
352 Layer.provideMerge(runtimeLayer),
353 Layer.provideMerge(openLayer),
354 Layer.provideMerge(serverConfigLayer),
355 );
332 const runtimeServices = await Effect.runPromise(356 const runtimeServices = await Effect.runPromise(
333 Layer.build(runtimeLayer).pipe(Scope.provide(scope)),357 Layer.build(dependenciesLayer).pipe(Scope.provide(scope)),
334 );358 );
335359
336 try {360 try {
337 const runtime = await Effect.runPromise(361 const runtime = await Effect.runPromise(
338 createServer({362 createServer({
339 port: 0,363 autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
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 } : {}),364 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
349 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),365 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),
350 }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)),366 }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)),
apps/server/src/wsServer.ts+17-100
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import fs from "node:fs";1import fs from "node:fs";
2import http from "node:http";2import http from "node:http";
3import os from "node:os";
4import path from "node:path";3import path from "node:path";
5import type { Duplex } from "node:stream";4import type { Duplex } from "node:stream";
65
...@@ -16,7 +15,7 @@ import {...@@ -16,7 +15,7 @@ import {
16 WsPush,15 WsPush,
17 WsResponse,16 WsResponse,
18} from "@t3tools/contracts";17} from "@t3tools/contracts";
19import { NodeHttpServer, NodeServices } from "@effect/platform-node";18import { NodeHttpServer } from "@effect/platform-node";
20import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect";19import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect";
21import { WebSocketServer, type WebSocket } from "ws";20import { WebSocketServer, type WebSocket } from "ws";
2221
...@@ -34,30 +33,14 @@ import {...@@ -34,30 +33,14 @@ import {
34import { TerminalManager } from "./terminalManager";33import { TerminalManager } from "./terminalManager";
35import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";34import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
36import { searchWorkspaceEntries } from "./workspaceEntries";35import { searchWorkspaceEntries } from "./workspaceEntries";
37import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine";
38import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor";
39import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor";
40import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor";
41import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline";
42import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
43import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
44import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";36import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine";
45import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";37import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
46import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";38import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
47import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
48import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
49import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
50import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
51import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
52import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
53import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory";
54import { ProviderService } from "./provider/Services/ProviderService";39import { ProviderService } from "./provider/Services/ProviderService";
55import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
56import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
57import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";40import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
58import * as SqlClient from "effect/unstable/sql/SqlClient";
59import { clamp } from "effect/Number";41import { clamp } from "effect/Number";
60import { OpenDefaultService, type OpenShape } from "./open";42import { Open } from "./open";
43import { ServerConfig } from "./config";
6144
62const MIME_TYPES: Record<string, string> = {45const MIME_TYPES: Record<string, string> = {
63 ".html": "text/html; charset=utf-8",46 ".html": "text/html; charset=utf-8",
...@@ -75,24 +58,16 @@ const MIME_TYPES: Record<string, string> = {...@@ -75,24 +58,16 @@ const MIME_TYPES: Record<string, string> = {
75};58};
7659
77export interface ServerOptions {60export interface ServerOptions {
78 port: number;
79 host?: string | undefined;
80 cwd: string;
81 autoBootstrapProjectFromCwd?: boolean | undefined;61 autoBootstrapProjectFromCwd?: boolean | undefined;
82 stateDir?: string | undefined;
83 staticDir?: string | undefined;
84 devUrl?: string | undefined;
85 logWebSocketEvents?: boolean | undefined;62 logWebSocketEvents?: boolean | undefined;
86 gitManager?: GitManager | undefined;63 gitManager?: GitManager | undefined;
87 terminalManager?: TerminalManager | undefined;64 terminalManager?: TerminalManager | undefined;
88 authToken?: string | undefined;
89 open?: OpenShape | undefined;
90}65}
9166
92export interface ServerShape {67export interface ServerShape {
93 readonly createServer: (68 readonly createServer: (
94 options: ServerOptions,69 options?: ServerOptions,
95 ) => Effect.Effect<http.Server, unknown, Scope.Scope | ServerRuntimeServices>;70 ) => Effect.Effect<http.Server, unknown, Scope.Scope | ServerRuntimeServices | ServerConfig>;
96 readonly stopSignal: Effect.Effect<unknown, never>;71 readonly stopSignal: Effect.Effect<unknown, never>;
97}72}
9873
...@@ -174,66 +149,11 @@ export type ServerCoreRuntimeServices =...@@ -174,66 +149,11 @@ export type ServerCoreRuntimeServices =
174 | OrchestrationEngineService149 | OrchestrationEngineService
175 | ProjectionSnapshotQuery150 | ProjectionSnapshotQuery
176 | CheckpointStore151 | CheckpointStore
177 | OrchestrationReactor;152 | OrchestrationReactor
153 | Open;
178154
179export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService;155export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService;
180156
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>()(157export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycleError>()(
238 "ServerLifecycleError",158 "ServerLifecycleError",
239 {159 {
...@@ -246,22 +166,18 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro...@@ -246,22 +166,18 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro
246 message: Schema.String,166 message: Schema.String,
247}) {}167}) {}
248168
249export const createServer = Effect.fn(function* (169export const createServer = Effect.fn(function* (options: ServerOptions = {}): Effect.fn.Return<
250 options: ServerOptions,170 http.Server,
251): Effect.fn.Return<http.Server, unknown, Scope.Scope | ServerRuntimeServices> {171 unknown, // TODO: This should not be unknown
172 Scope.Scope | ServerRuntimeServices | ServerConfig
173> {
174 const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig;
252 const {175 const {
253 port,
254 host,
255 cwd,
256 autoBootstrapProjectFromCwd = false,
257 staticDir,
258 devUrl,
259 logWebSocketEvents: explicitLogWsEvents,176 logWebSocketEvents: explicitLogWsEvents,
260 gitManager = new GitManager(),177 gitManager = new GitManager(),
261 terminalManager = new TerminalManager(),178 terminalManager = new TerminalManager(),
262 authToken,
263 open: openService = OpenDefaultService,
264 } = options;179 } = options;
180 const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web";
265181
266 const clients = new Set<WebSocket>();182 const clients = new Set<WebSocket>();
267 const logger = createLogger("ws");183 const logger = createLogger("ws");
...@@ -299,7 +215,7 @@ export const createServer = Effect.fn(function* (...@@ -299,7 +215,7 @@ export const createServer = Effect.fn(function* (
299 const httpServer = http.createServer((req, res) => {215 const httpServer = http.createServer((req, res) => {
300 // In dev mode, redirect to Vite dev server216 // In dev mode, redirect to Vite dev server
301 if (devUrl) {217 if (devUrl) {
302 res.writeHead(302, { Location: devUrl });218 res.writeHead(302, { Location: devUrl.href });
303 res.end();219 res.end();
304 return;220 return;
305 }221 }
...@@ -382,6 +298,7 @@ export const createServer = Effect.fn(function* (...@@ -382,6 +298,7 @@ export const createServer = Effect.fn(function* (
382 const liveCheckpointStore = yield* CheckpointStore;298 const liveCheckpointStore = yield* CheckpointStore;
383 const liveProviderService = yield* ProviderService;299 const liveProviderService = yield* ProviderService;
384 const orchestrationReactor = yield* OrchestrationReactor;300 const orchestrationReactor = yield* OrchestrationReactor;
301 const { openInEditor } = yield* Open;
385302
386 const subscriptionsScope = yield* Scope.make("sequential");303 const subscriptionsScope = yield* Scope.make("sequential");
387 yield* Effect.addFinalizer(() =>304 yield* Effect.addFinalizer(() =>
...@@ -574,7 +491,7 @@ export const createServer = Effect.fn(function* (...@@ -574,7 +491,7 @@ export const createServer = Effect.fn(function* (
574491
575 case WS_METHODS.shellOpenInEditor: {492 case WS_METHODS.shellOpenInEditor: {
576 const body = stripRequestTag(request.body);493 const body = stripRequestTag(request.body);
577 return yield* openService.openInEditor(body);494 return yield* openInEditor(body);
578 }495 }
579496
580 case WS_METHODS.gitStatus: {497 case WS_METHODS.gitStatus: {