authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 13:46:30-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
loge63d5b240f908ec3cf69b49ac7488b33dc5231c0
tree9a94782305a744718f9ddb192be5d5f0b97aaaaa
parent877ab769345a13e90509c564b62ef3f50f480607

effect cli


23 files changed, 952 insertions(+), 486 deletions(-)

apps/server/src/codexAppServerManager.test.ts+9-6
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { describe, expect, it, vi } from "vitest";1import { describe, expect, it, vi } from "vitest";
2import { ProviderSessionId } from "@t3tools/contracts";
23
3import {4import {
4 CodexAppServerManager,5 CodexAppServerManager,
...@@ -7,6 +8,8 @@ import {...@@ -7,6 +8,8 @@ import {
7 normalizeCodexModelSlug,8 normalizeCodexModelSlug,
8} from "./codexAppServerManager";9} from "./codexAppServerManager";
910
11const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
12
10function createSendTurnHarness() {13function createSendTurnHarness() {
11 const manager = new CodexAppServerManager();14 const manager = new CodexAppServerManager();
12 const context = {15 const context = {
...@@ -183,7 +186,7 @@ describe("sendTurn", () => {...@@ -183,7 +186,7 @@ describe("sendTurn", () => {
183 createSendTurnHarness();186 createSendTurnHarness();
184187
185 const result = await manager.sendTurn({188 const result = await manager.sendTurn({
186 sessionId: "sess_1",189 sessionId: asSessionId("sess_1"),
187 input: "Inspect this image",190 input: "Inspect this image",
188 attachments: [191 attachments: [
189 {192 {
...@@ -229,7 +232,7 @@ describe("sendTurn", () => {...@@ -229,7 +232,7 @@ describe("sendTurn", () => {
229 const { manager, context, sendRequest } = createSendTurnHarness();232 const { manager, context, sendRequest } = createSendTurnHarness();
230233
231 await manager.sendTurn({234 await manager.sendTurn({
232 sessionId: "sess_1",235 sessionId: asSessionId("sess_1"),
233 attachments: [236 attachments: [
234 {237 {
235 type: "image",238 type: "image",
...@@ -257,7 +260,7 @@ describe("sendTurn", () => {...@@ -257,7 +260,7 @@ describe("sendTurn", () => {
257260
258 await expect(261 await expect(
259 manager.sendTurn({262 manager.sendTurn({
260 sessionId: "sess_1",263 sessionId: asSessionId("sess_1"),
261 }),264 }),
262 ).rejects.toThrow("Turn input must include text or attachments.");265 ).rejects.toThrow("Turn input must include text or attachments.");
263 });266 });
...@@ -278,7 +281,7 @@ describe("thread checkpoint control", () => {...@@ -278,7 +281,7 @@ describe("thread checkpoint control", () => {
278 },281 },
279 });282 });
280283
281 const result = await manager.readThread("sess_1");284 const result = await manager.readThread(asSessionId("sess_1"));
282285
283 expect(requireSession).toHaveBeenCalledWith("sess_1");286 expect(requireSession).toHaveBeenCalledWith("sess_1");
284 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {287 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
...@@ -308,7 +311,7 @@ describe("thread checkpoint control", () => {...@@ -308,7 +311,7 @@ describe("thread checkpoint control", () => {
308 ],311 ],
309 });312 });
310313
311 const result = await manager.readThread("sess_1");314 const result = await manager.readThread(asSessionId("sess_1"));
312315
313 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {316 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
314 threadId: "thread_1",317 threadId: "thread_1",
...@@ -334,7 +337,7 @@ describe("thread checkpoint control", () => {...@@ -334,7 +337,7 @@ describe("thread checkpoint control", () => {
334 },337 },
335 });338 });
336339
337 const result = await manager.rollbackThread("sess_1", 2);340 const result = await manager.rollbackThread(asSessionId("sess_1"), 2);
338341
339 expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {342 expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {
340 threadId: "thread_1",343 threadId: "thread_1",
apps/server/src/index.ts+17-177
...@@ -1,177 +1,17 @@...@@ -1,177 +1,17 @@
1import fs from "node:fs";1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import net from "node:net";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import os from "node:os";3import * as Effect from "effect/Effect";
4import path from "node:path";4import * as Layer from "effect/Layer";
5import { fileURLToPath } from "node:url";5
66import { CliConfigLive, makeCliCommand } from "./main";
7import { fixPath } from "./fixPath";7import { OpenLive } from "./open";
8import { createLogger } from "./logger";8import { Command } from "effect/unstable/cli";
9import { createServer } from "./wsServer";9import { version } from "../package.json" with { type: "json" };
1010import { ServerLive } from "./wsServer";
11fixPath();11
1212const RuntimeLayer = Layer.mergeAll(CliConfigLive, ServerLive, OpenLive, NodeServices.layer);
13const DEFAULT_PORT = 3773;13
14// Dummy comment for a no-op change request.14Command.run(makeCliCommand(), { version }).pipe(
15// Additional dummy comment requested in chat.15 Effect.provide(RuntimeLayer),
16const cwd = process.cwd();16 NodeRuntime.runMain,
17const logger = createLogger("server");17);
18
19type RuntimeMode = "web" | "desktop";
20
21function parseBooleanEnv(value: string | undefined): boolean | undefined {
22 if (value === undefined) return undefined;
23 const normalized = value.trim().toLowerCase();
24 if (["1", "true", "yes", "on"].includes(normalized)) return true;
25 if (["0", "false", "no", "off"].includes(normalized)) return false;
26 return undefined;
27}
28
29function parsePort(value: string | undefined): number | undefined {
30 if (!value) return undefined;
31 const parsed = Number(value);
32 if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
33 throw new Error(`Invalid T3CODE_PORT: ${value}`);
34 }
35 return parsed;
36}
37
38function expandHomePath(input: string): string {
39 if (input === "~") return os.homedir();
40 if (input.startsWith("~/")) {
41 return path.join(os.homedir(), input.slice(2));
42 }
43 return input;
44}
45
46function resolveStateDir(raw: string | undefined): string {
47 if (!raw || raw.trim().length === 0) {
48 return path.join(os.homedir(), ".t3", "userdata");
49 }
50 return path.resolve(expandHomePath(raw.trim()));
51}
52
53async function findAvailablePort(preferred: number): Promise<number> {
54 return new Promise((resolve, reject) => {
55 const server = net.createServer();
56 server.listen(preferred, () => {
57 server.close(() => resolve(preferred));
58 });
59 server.on("error", () => {
60 // Preferred port busy — let the OS pick one
61 const fallback = net.createServer();
62 fallback.listen(0, () => {
63 const addr = fallback.address();
64 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
65 fallback.close(() => {
66 if (port > 0) resolve(port);
67 else reject(new Error("Could not find an available port."));
68 });
69 });
70 fallback.on("error", reject);
71 });
72 });
73}
74
75function resolveStaticDir(): string | undefined {
76 const __dirname = path.dirname(fileURLToPath(import.meta.url));
77
78 // Check for bundled client (npm publish / npx scenario):
79 // dist/client/ lives alongside dist/index.js
80 const bundledClient = path.resolve(__dirname, "client");
81 try {
82 const stat = fs.statSync(path.join(bundledClient, "index.html"));
83 if (stat.isFile()) return bundledClient;
84 } catch {
85 // Not bundled — check monorepo layout
86 }
87
88 // Monorepo layout: apps/server/dist/index.js → apps/web/dist/
89 const monorepoClient = path.resolve(__dirname, "../../web/dist");
90 try {
91 const stat = fs.statSync(path.join(monorepoClient, "index.html"));
92 if (stat.isFile()) return monorepoClient;
93 } catch {
94 // Not found — probably dev mode, Vite will serve
95 }
96
97 return undefined;
98}
99
100async function main() {
101 const mode: RuntimeMode = process.env.T3CODE_MODE === "desktop" ? "desktop" : "web";
102 const requestedPort = parsePort(process.env.T3CODE_PORT);
103 const port =
104 requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT));
105 const stateDir = resolveStateDir(process.env.T3CODE_STATE_DIR);
106 const devUrl = process.env.VITE_DEV_SERVER_URL;
107 const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop";
108 const authToken = process.env.T3CODE_AUTH_TOKEN;
109 const staticDir = devUrl ? undefined : resolveStaticDir();
110
111 if (!devUrl && !staticDir) {
112 logger.warn("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", {
113 hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
114 });
115 }
116
117 const server = createServer({
118 port,
119 host: mode === "desktop" ? "127.0.0.1" : undefined,
120 cwd,
121 autoBootstrapProjectFromCwd: mode === "web",
122 stateDir,
123 staticDir,
124 devUrl,
125 authToken,
126 });
127 await server.start();
128
129 const url = `http://localhost:${port}`;
130 logger.info("T3 Code running", {
131 url,
132 cwd,
133 mode,
134 stateDir,
135 authEnabled: Boolean(authToken),
136 });
137
138 // Open browser (dynamic import because `open` is ESM-only)
139 if (!noBrowser) {
140 try {
141 const open = await import("open");
142 const target = devUrl ?? url;
143 await open.default(target);
144 } catch {
145 logger.info("browser auto-open unavailable", {
146 hint: `Open ${devUrl ?? url} in your browser.`,
147 });
148 }
149 }
150
151 // Graceful shutdown
152 let shuttingDown = false;
153 async function shutdown() {
154 if (shuttingDown) return;
155 shuttingDown = true;
156 logger.info("shutting down");
157 try {
158 await server.stop();
159 process.exit(0);
160 } catch (error) {
161 logger.error("failed to stop server cleanly", { error });
162 process.exit(1);
163 }
164 }
165
166 process.on("SIGINT", () => {
167 void shutdown();
168 });
169 process.on("SIGTERM", () => {
170 void shutdown();
171 });
172}
173
174main().catch((err) => {
175 logger.error("failed to start T3 Code", { error: err });
176 process.exit(1);
177});
apps/server/src/keybindings.ts+1-1
...@@ -326,7 +326,7 @@ function loadCustomKeybindingsConfig(...@@ -326,7 +326,7 @@ function loadCustomKeybindingsConfig(
326 const sanitized: Mutable<KeybindingsConfig> = [];326 const sanitized: Mutable<KeybindingsConfig> = [];
327 let invalidEntries = 0;327 let invalidEntries = 0;
328 for (const entry of parsed) {328 for (const entry of parsed) {
329 const result = Schema.decodeUnknownExit(KeybindingRule)(entry);329 const result = Schema.decodeExit(KeybindingRule)(entry);
330 if (result._tag === "Success") {330 if (result._tag === "Success") {
331 if (!compileResolvedKeybindingRule(result.value)) {331 if (!compileResolvedKeybindingRule(result.value)) {
332 invalidEntries += 1;332 invalidEntries += 1;
apps/server/src/main.test.ts created+131
...@@ -0,0 +1,131 @@
1import { NodeServices } from "@effect/platform-node";
2import { assert, it, vi } from "@effect/vitest";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Command from "effect/unstable/cli/Command";
7import { beforeEach } from "vitest";
8
9import { CliConfig, makeCliCommand, type CliConfigShape } from "./main";
10import { Open, type OpenShape } from "./open";
11import { Server, type ServerShape, type ServerOptions } from "./wsServer";
12
13const start = vi.fn(async () => undefined);
14const stop = vi.fn(async () => undefined);
15const createServer = vi.fn((_: ServerOptions) => ({
16 start,
17 stop,
18 httpServer: undefined as never,
19}));
20const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred));
21
22const testLayer = Layer.mergeAll(
23 Layer.succeed(CliConfig, {
24 cwd: "/tmp/t3-test-workspace",
25 fixPath: Effect.void,
26 findAvailablePort,
27 resolveStaticDir: () => undefined,
28 } satisfies CliConfigShape),
29 Layer.succeed(Server, {
30 createServer,
31 } satisfies ServerShape),
32 Layer.succeed(Open, {
33 openBrowser: (_target: string) => Effect.void,
34 openInEditor: () => Effect.void,
35 } satisfies OpenShape),
36 NodeServices.layer,
37);
38
39const runCli = (
40 args: ReadonlyArray<string>,
41 env: Record<string, string> = { T3CODE_NO_BROWSER: "true" },
42) =>
43 Command.runWith(makeCliCommand(), { version: "0.0.0-test" })(args).pipe(
44 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))),
45 );
46
47beforeEach(() => {
48 vi.clearAllMocks();
49 start.mockImplementation(async () => undefined);
50 stop.mockImplementation(async () => undefined);
51 createServer.mockImplementation((_: ServerOptions) => ({
52 start,
53 stop,
54 httpServer: undefined as never,
55 }));
56 findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
57});
58
59it.layer(testLayer)("server cli", (it) => {
60 it.effect("parses --port and --token and wires scoped start/stop", () =>
61 Effect.gen(function* () {
62 yield* runCli(["--port", "4010", "--token", "secret"]);
63
64 assert.equal(createServer.mock.calls.length, 1);
65 const options = createServer.mock.calls[0]?.[0];
66 assert.equal(options?.port, 4010);
67 assert.equal(options?.authToken, "secret");
68 assert.equal(start.mock.calls.length, 1);
69 assert.equal(stop.mock.calls.length, 1);
70 }),
71 );
72
73 it.effect("uses env fallbacks when flags are not provided", () =>
74 Effect.gen(function* () {
75 yield* runCli([], {
76 T3CODE_NO_BROWSER: "true",
77 T3CODE_PORT: "4999",
78 T3CODE_AUTH_TOKEN: "env-token",
79 });
80
81 assert.equal(createServer.mock.calls.length, 1);
82 const options = createServer.mock.calls[0]?.[0];
83 assert.equal(options?.port, 4999);
84 assert.equal(options?.authToken, "env-token");
85 assert.equal(findAvailablePort.mock.calls.length, 0);
86 }),
87 );
88
89 it.effect("uses dynamic port discovery in web mode when port is omitted", () =>
90 Effect.gen(function* () {
91 findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444));
92 yield* runCli([]);
93
94 assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]);
95 assert.equal(createServer.mock.calls.length, 1);
96 const options = createServer.mock.calls[0]?.[0];
97 assert.equal(options?.port, 5444);
98 assert.equal(options?.host, undefined);
99 }),
100 );
101
102 it.effect("uses fixed localhost defaults in desktop mode", () =>
103 Effect.gen(function* () {
104 yield* runCli([], {
105 T3CODE_MODE: "desktop",
106 T3CODE_NO_BROWSER: "true",
107 });
108
109 assert.equal(findAvailablePort.mock.calls.length, 0);
110 assert.equal(createServer.mock.calls.length, 1);
111 const options = createServer.mock.calls[0]?.[0];
112 assert.equal(options?.port, 3773);
113 assert.equal(options?.host, "127.0.0.1");
114 }),
115 );
116
117 it.effect("fails for out-of-range --port values", () =>
118 Effect.gen(function* () {
119 const result = yield* Effect.result(runCli(["--port", "70000"]));
120
121 assert.equal(result._tag, "Failure");
122 if (result._tag === "Failure") {
123 assert.equal(
124 result.failure.message,
125 "Invalid --port: 70000. Expected an integer between 1 and 65535.",
126 );
127 }
128 assert.equal(createServer.mock.calls.length, 0);
129 }),
130 );
131});
apps/server/src/main.ts created+284
...@@ -0,0 +1,284 @@
1import fs from "node:fs";
2import net from "node:net";
3import os from "node:os";
4import path from "node:path";
5import { fileURLToPath } from "node:url";
6
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Config from "effect/Config";
12import { ServiceMap } from "effect";
13import * as Command from "effect/unstable/cli/Command";
14import * as Flag from "effect/unstable/cli/Flag";
15
16import { fixPath } from "./fixPath";
17import { Open } from "./open";
18import { Server } from "./wsServer";
19
20const DEFAULT_PORT = 3773;
21
22type RuntimeMode = "web" | "desktop";
23
24export class StartupError extends Data.TaggedError("StartupError")<{
25 readonly message: string;
26 readonly cause?: unknown;
27}> {}
28
29interface CliInput {
30 readonly port: Option.Option<number>;
31 readonly token: Option.Option<string>;
32}
33
34interface ResolvedCliConfig {
35 readonly port: number;
36 readonly mode: RuntimeMode;
37 readonly cwd: string;
38 readonly stateDir: string;
39 readonly devUrl: URL | undefined;
40 readonly noBrowser: boolean;
41 readonly authToken: string | undefined;
42 readonly staticDir: string | undefined;
43}
44
45export interface CliConfigShape {
46 readonly cwd: string;
47 readonly fixPath: Effect.Effect<unknown, never>;
48 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>;
49 readonly resolveStaticDir: () => string | undefined;
50}
51
52export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(
53 "server/CliConfig",
54) {}
55
56const CliEnvConfig = Config.all({
57 mode: Config.string("T3CODE_MODE").pipe(
58 Config.option,
59 Config.map(
60 Option.match({
61 onNone: () => "web" as const,
62 onSome: (value) => (value === "desktop" ? "desktop" : "web"),
63 }),
64 ),
65 ),
66 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
67 stateDir: Config.string("T3CODE_STATE_DIR").pipe(
68 Config.option,
69 Config.map(Option.getOrUndefined),
70 ),
71 devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
72 noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
73 Config.option,
74 Config.map(Option.getOrUndefined),
75 ),
76 authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
77 Config.option,
78 Config.map(Option.getOrUndefined),
79 ),
80});
81
82function assertValidPort(value: number, source: string): Effect.Effect<number, StartupError> {
83 if (Number.isInteger(value) && value >= 1 && value <= 65535) {
84 return Effect.succeed(value);
85 }
86 return Effect.fail(
87 new StartupError({
88 message: `Invalid ${source}: ${value}. Expected an integer between 1 and 65535.`,
89 }),
90 );
91}
92
93function expandHomePath(input: string): string {
94 if (input === "~") return os.homedir();
95 if (input.startsWith("~/")) {
96 return path.join(os.homedir(), input.slice(2));
97 }
98 return input;
99}
100
101function resolveStateDir(raw: string | undefined): string {
102 if (!raw || raw.trim().length === 0) {
103 return path.join(os.homedir(), ".t3", "userdata");
104 }
105 return path.resolve(expandHomePath(raw.trim()));
106}
107
108async function findAvailablePort(preferred: number): Promise<number> {
109 return new Promise((resolve, reject) => {
110 const server = net.createServer();
111 server.listen(preferred, () => {
112 server.close(() => resolve(preferred));
113 });
114 server.on("error", () => {
115 const fallback = net.createServer();
116 fallback.listen(0, () => {
117 const addr = fallback.address();
118 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
119 fallback.close(() => {
120 if (port > 0) resolve(port);
121 else reject(new Error("Could not find an available port."));
122 });
123 });
124 fallback.on("error", reject);
125 });
126 });
127}
128
129function resolveStaticDir(): string | undefined {
130 const __dirname = path.dirname(fileURLToPath(import.meta.url));
131
132 const bundledClient = path.resolve(__dirname, "client");
133 try {
134 const stat = fs.statSync(path.join(bundledClient, "index.html"));
135 if (stat.isFile()) return bundledClient;
136 } catch {
137 // Not bundled — check monorepo layout.
138 }
139
140 const monorepoClient = path.resolve(__dirname, "../../web/dist");
141 try {
142 const stat = fs.statSync(path.join(monorepoClient, "index.html"));
143 if (stat.isFile()) return monorepoClient;
144 } catch {
145 // Not found — probably dev mode.
146 }
147
148 return undefined;
149}
150
151function resolveCliConfig(
152 input: CliInput,
153): Effect.Effect<ResolvedCliConfig, StartupError, CliConfig> {
154 return Effect.gen(function* () {
155 const cliConfig = yield* CliConfig;
156 const env = yield* CliEnvConfig.asEffect().pipe(
157 Effect.mapError(
158 (cause) =>
159 new StartupError({
160 message: "Failed to read environment configuration",
161 cause,
162 }),
163 ),
164 );
165 const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web";
166
167 const cliPort = Option.getOrUndefined(input.port);
168 const requestedPort =
169 cliPort === undefined ? env.port : yield* assertValidPort(cliPort, "--port");
170
171 const port =
172 requestedPort ??
173 (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT));
174 const stateDir = resolveStateDir(env.stateDir);
175 const devUrl = env.devUrl;
176 const noBrowser = env.noBrowser ?? mode === "desktop";
177 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;
178 const staticDir = devUrl ? undefined : cliConfig.resolveStaticDir();
179
180 return {
181 mode,
182 port,
183 stateDir,
184 devUrl,
185 noBrowser,
186 authToken,
187 staticDir,
188 cwd: cliConfig.cwd,
189 };
190 });
191}
192
193const makeServerProgram = Effect.fn(function* (input: CliInput) {
194 const cliConfig = yield* CliConfig;
195 const serverDeps = yield* Server;
196 const openDeps = yield* Open;
197 yield* cliConfig.fixPath;
198 const config = yield* resolveCliConfig(input);
199
200 if (!config.devUrl && !config.staticDir) {
201 yield* Effect.logWarning("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", {
202 hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
203 });
204 }
205
206 yield* Effect.acquireRelease(
207 Effect.tryPromise({
208 try: async () => {
209 const server = serverDeps.createServer({
210 port: config.port,
211 host: config.mode === "desktop" ? "127.0.0.1" : undefined,
212 cwd: config.cwd,
213 autoBootstrapProjectFromCwd: config.mode === "web",
214 stateDir: config.stateDir,
215 staticDir: config.staticDir,
216 devUrl: config.devUrl?.href,
217 authToken: config.authToken,
218 });
219 await server.start();
220 return server;
221 },
222 catch: (cause) => new StartupError({ message: "Failed to start server", cause }),
223 }),
224 (server) =>
225 Effect.tryPromise({
226 try: () => server.stop(),
227 catch: (cause) => new StartupError({ message: "Failed to stop server", cause }),
228 }).pipe(Effect.catch((error) => Effect.logError("failed to stop server cleanly", { error }))),
229 );
230
231 const url = `http://localhost:${config.port}`;
232 yield* Effect.logInfo("T3 Code running", {
233 url,
234 cwd: config.cwd,
235 mode: config.mode,
236 stateDir: config.stateDir,
237 authEnabled: Boolean(config.authToken),
238 });
239
240 if (!config.noBrowser) {
241 const target = config.devUrl ?? url;
242 yield* openDeps.openBrowser(target.toString()).pipe(
243 Effect.catch(() =>
244 Effect.logInfo("browser auto-open unavailable", {
245 hint: `Open ${target} in your browser.`,
246 }),
247 ),
248 );
249 }
250}, Effect.scoped);
251
252export function makeCliCommand() {
253 const portFlag = Flag.integer("port").pipe(
254 Flag.withDescription("Port for the HTTP/WebSocket server."),
255 Flag.optional,
256 );
257 const tokenFlag = Flag.string("token").pipe(
258 Flag.withDescription("Auth token required for WebSocket connections."),
259 Flag.optional,
260 );
261
262 return Command.make("t3", {
263 port: portFlag,
264 token: tokenFlag,
265 }).pipe(
266 Command.withDescription("Run the T3 Code server."),
267 Command.withHandler((input) => makeServerProgram(input)),
268 );
269}
270
271export const CliConfigLive = Layer.succeed(CliConfig, {
272 cwd: process.cwd(),
273 fixPath: Effect.sync(fixPath),
274 findAvailablePort: (preferred) =>
275 Effect.tryPromise({
276 try: () => findAvailablePort(preferred),
277 catch: (cause) =>
278 new StartupError({
279 message: "Failed to discover an available port",
280 cause,
281 }),
282 }),
283 resolveStaticDir,
284} satisfies CliConfigShape);
apps/server/src/open.ts created+93
...@@ -0,0 +1,93 @@
1import { spawn } from "node:child_process";
2
3import { EDITORS, type EditorId } from "@t3tools/contracts";
4import { ServiceMap, Schema, Effect, Layer } from "effect";
5
6export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", {
7 message: Schema.String,
8 cause: Schema.optional(Schema.Defect),
9}) {}
10
11export interface OpenInEditorInput {
12 readonly cwd: string;
13 readonly editor: EditorId;
14}
15
16interface EditorLaunch {
17 readonly command: string;
18 readonly args: ReadonlyArray<string>;
19}
20
21export interface OpenShape {
22 readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>;
23 readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>;
24}
25
26export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {}
27
28export function resolveEditorLaunch(
29 input: OpenInEditorInput,
30 platform: NodeJS.Platform = process.platform,
31): EditorLaunch {
32 const editorDef = EDITORS.find((editor) => editor.id === input.editor);
33 if (!editorDef) {
34 throw new OpenError({ message: `Unknown editor: ${input.editor}` });
35 }
36
37 if (editorDef.command) {
38 return { command: editorDef.command, args: [input.cwd] };
39 }
40
41 if (editorDef.id !== "file-manager") {
42 throw new OpenError({ message: `Unsupported editor: ${input.editor}` });
43 }
44
45 switch (platform) {
46 case "darwin":
47 return { command: "open", args: [input.cwd] };
48 case "win32":
49 return { command: "explorer", args: [input.cwd] };
50 default:
51 return { command: "xdg-open", args: [input.cwd] };
52 }
53}
54
55const OpenDefault: OpenShape = {
56 openBrowser: (target) =>
57 Effect.tryPromise({
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};
90
91export const OpenLive = Layer.succeed(Open, OpenDefault);
92
93export const OpenDefaultService = OpenDefault;
apps/server/src/orchestration/Layer.test.ts+75-56
...@@ -1,4 +1,12 @@...@@ -1,4 +1,12 @@
1import type { OrchestrationEvent } from "@t3tools/contracts";1import {
2 CheckpointRef,
3 CommandId,
4 MessageId,
5 ProjectId,
6 ThreadId,
7 TurnId,
8 type OrchestrationEvent,
9} from "@t3tools/contracts";
2import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect";10import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect";
3import { describe, expect, it } from "vitest";11import { describe, expect, it } from "vitest";
412
...@@ -14,6 +22,13 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";...@@ -14,6 +22,13 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
14import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";22import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
15import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";23import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
1624
25const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
26const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
27const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
28const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
29const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
30const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
31
17async function createOrchestrationSystem() {32async function createOrchestrationSystem() {
18 const orchestrationLayer = OrchestrationEngineLive.pipe(33 const orchestrationLayer = OrchestrationEngineLive.pipe(
19 Layer.provide(OrchestrationProjectionPipelineLive),34 Layer.provide(OrchestrationProjectionPipelineLive),
...@@ -43,8 +58,8 @@ describe("OrchestrationEngine", () => {...@@ -43,8 +58,8 @@ describe("OrchestrationEngine", () => {
43 await system.run(58 await system.run(
44 engine.dispatch({59 engine.dispatch({
45 type: "project.create",60 type: "project.create",
46 commandId: "cmd-project-1-create",61 commandId: asCommandId("cmd-project-1-create"),
47 projectId: "project-1",62 projectId: asProjectId("project-1"),
48 title: "Project 1",63 title: "Project 1",
49 workspaceRoot: "/tmp/project-1",64 workspaceRoot: "/tmp/project-1",
50 defaultModel: "gpt-5-codex",65 defaultModel: "gpt-5-codex",
...@@ -54,9 +69,9 @@ describe("OrchestrationEngine", () => {...@@ -54,9 +69,9 @@ describe("OrchestrationEngine", () => {
54 await system.run(69 await system.run(
55 engine.dispatch({70 engine.dispatch({
56 type: "thread.create",71 type: "thread.create",
57 commandId: "cmd-thread-1-create",72 commandId: asCommandId("cmd-thread-1-create"),
58 threadId: "thread-1",73 threadId: asThreadId("thread-1"),
59 projectId: "project-1",74 projectId: asProjectId("project-1"),
60 title: "Thread",75 title: "Thread",
61 model: "gpt-5-codex",76 model: "gpt-5-codex",
62 branch: null,77 branch: null,
...@@ -67,10 +82,10 @@ describe("OrchestrationEngine", () => {...@@ -67,10 +82,10 @@ describe("OrchestrationEngine", () => {
67 await system.run(82 await system.run(
68 engine.dispatch({83 engine.dispatch({
69 type: "thread.turn.start",84 type: "thread.turn.start",
70 commandId: "cmd-turn-start-1",85 commandId: asCommandId("cmd-turn-start-1"),
71 threadId: "thread-1",86 threadId: asThreadId("thread-1"),
72 message: {87 message: {
73 messageId: "msg-1",88 messageId: asMessageId("msg-1"),
74 role: "user",89 role: "user",
75 text: "hello",90 text: "hello",
76 attachments: [],91 attachments: [],
...@@ -93,8 +108,8 @@ describe("OrchestrationEngine", () => {...@@ -93,8 +108,8 @@ describe("OrchestrationEngine", () => {
93 await system.run(108 await system.run(
94 engine.dispatch({109 engine.dispatch({
95 type: "project.create",110 type: "project.create",
96 commandId: "cmd-project-replay-create",111 commandId: asCommandId("cmd-project-replay-create"),
97 projectId: "project-replay",112 projectId: asProjectId("project-replay"),
98 title: "Replay Project",113 title: "Replay Project",
99 workspaceRoot: "/tmp/project-replay",114 workspaceRoot: "/tmp/project-replay",
100 defaultModel: "gpt-5-codex",115 defaultModel: "gpt-5-codex",
...@@ -104,9 +119,9 @@ describe("OrchestrationEngine", () => {...@@ -104,9 +119,9 @@ describe("OrchestrationEngine", () => {
104 await system.run(119 await system.run(
105 engine.dispatch({120 engine.dispatch({
106 type: "thread.create",121 type: "thread.create",
107 commandId: "cmd-thread-replay-create",122 commandId: asCommandId("cmd-thread-replay-create"),
108 threadId: "thread-replay",123 threadId: asThreadId("thread-replay"),
109 projectId: "project-replay",124 projectId: asProjectId("project-replay"),
110 title: "replay",125 title: "replay",
111 model: "gpt-5-codex",126 model: "gpt-5-codex",
112 branch: null,127 branch: null,
...@@ -117,8 +132,8 @@ describe("OrchestrationEngine", () => {...@@ -117,8 +132,8 @@ describe("OrchestrationEngine", () => {
117 await system.run(132 await system.run(
118 engine.dispatch({133 engine.dispatch({
119 type: "thread.delete",134 type: "thread.delete",
120 commandId: "cmd-thread-replay-delete",135 commandId: asCommandId("cmd-thread-replay-delete"),
121 threadId: "thread-replay",136 threadId: asThreadId("thread-replay"),
122 }),137 }),
123 );138 );
124139
...@@ -143,8 +158,8 @@ describe("OrchestrationEngine", () => {...@@ -143,8 +158,8 @@ describe("OrchestrationEngine", () => {
143 await system.run(158 await system.run(
144 engine.dispatch({159 engine.dispatch({
145 type: "project.create",160 type: "project.create",
146 commandId: "cmd-project-stream-create",161 commandId: asCommandId("cmd-project-stream-create"),
147 projectId: "project-stream",162 projectId: asProjectId("project-stream"),
148 title: "Stream Project",163 title: "Stream Project",
149 workspaceRoot: "/tmp/project-stream",164 workspaceRoot: "/tmp/project-stream",
150 defaultModel: "gpt-5-codex",165 defaultModel: "gpt-5-codex",
...@@ -164,9 +179,9 @@ describe("OrchestrationEngine", () => {...@@ -164,9 +179,9 @@ describe("OrchestrationEngine", () => {
164 yield* Effect.sleep("10 millis");179 yield* Effect.sleep("10 millis");
165 yield* engine.dispatch({180 yield* engine.dispatch({
166 type: "thread.create",181 type: "thread.create",
167 commandId: "cmd-stream-thread-create",182 commandId: asCommandId("cmd-stream-thread-create"),
168 threadId: "thread-stream",183 threadId: asThreadId("thread-stream"),
169 projectId: "project-stream",184 projectId: asProjectId("project-stream"),
170 title: "domain-stream",185 title: "domain-stream",
171 model: "gpt-5-codex",186 model: "gpt-5-codex",
172 branch: null,187 branch: null,
...@@ -175,8 +190,8 @@ describe("OrchestrationEngine", () => {...@@ -175,8 +190,8 @@ describe("OrchestrationEngine", () => {
175 });190 });
176 yield* engine.dispatch({191 yield* engine.dispatch({
177 type: "thread.meta.update",192 type: "thread.meta.update",
178 commandId: "cmd-stream-thread-update",193 commandId: asCommandId("cmd-stream-thread-update"),
179 threadId: "thread-stream",194 threadId: asThreadId("thread-stream"),
180 title: "domain-stream-updated",195 title: "domain-stream-updated",
181 });196 });
182 eventTypes.push((yield* Queue.take(eventQueue)).type);197 eventTypes.push((yield* Queue.take(eventQueue)).type);
...@@ -196,8 +211,8 @@ describe("OrchestrationEngine", () => {...@@ -196,8 +211,8 @@ describe("OrchestrationEngine", () => {
196 await system.run(211 await system.run(
197 engine.dispatch({212 engine.dispatch({
198 type: "project.create",213 type: "project.create",
199 commandId: "cmd-project-turn-diff-create",214 commandId: asCommandId("cmd-project-turn-diff-create"),
200 projectId: "project-turn-diff",215 projectId: asProjectId("project-turn-diff"),
201 title: "Turn Diff Project",216 title: "Turn Diff Project",
202 workspaceRoot: "/tmp/project-turn-diff",217 workspaceRoot: "/tmp/project-turn-diff",
203 defaultModel: "gpt-5-codex",218 defaultModel: "gpt-5-codex",
...@@ -207,9 +222,9 @@ describe("OrchestrationEngine", () => {...@@ -207,9 +222,9 @@ describe("OrchestrationEngine", () => {
207 await system.run(222 await system.run(
208 engine.dispatch({223 engine.dispatch({
209 type: "thread.create",224 type: "thread.create",
210 commandId: "cmd-thread-turn-diff-create",225 commandId: asCommandId("cmd-thread-turn-diff-create"),
211 threadId: "thread-turn-diff",226 threadId: asThreadId("thread-turn-diff"),
212 projectId: "project-turn-diff",227 projectId: asProjectId("project-turn-diff"),
213 title: "Turn diff thread",228 title: "Turn diff thread",
214 model: "gpt-5-codex",229 model: "gpt-5-codex",
215 branch: null,230 branch: null,
...@@ -220,11 +235,11 @@ describe("OrchestrationEngine", () => {...@@ -220,11 +235,11 @@ describe("OrchestrationEngine", () => {
220 await system.run(235 await system.run(
221 engine.dispatch({236 engine.dispatch({
222 type: "thread.turn.diff.complete",237 type: "thread.turn.diff.complete",
223 commandId: "cmd-turn-diff-complete",238 commandId: asCommandId("cmd-turn-diff-complete"),
224 threadId: "thread-turn-diff",239 threadId: asThreadId("thread-turn-diff"),
225 turnId: "turn-1",240 turnId: asTurnId("turn-1"),
226 completedAt: createdAt,241 completedAt: createdAt,
227 checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1",242 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),
228 status: "ready",243 status: "ready",
229 files: [],244 files: [],
230 checkpointTurnCount: 1,245 checkpointTurnCount: 1,
...@@ -237,9 +252,9 @@ describe("OrchestrationEngine", () => {...@@ -237,9 +252,9 @@ describe("OrchestrationEngine", () => {
237 );252 );
238 expect(thread?.checkpoints).toEqual([253 expect(thread?.checkpoints).toEqual([
239 {254 {
240 turnId: "turn-1",255 turnId: asTurnId("turn-1"),
241 checkpointTurnCount: 1,256 checkpointTurnCount: 1,
242 checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1",257 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),
243 status: "ready",258 status: "ready",
244 files: [],259 files: [],
245 assistantMessageId: null,260 assistantMessageId: null,
...@@ -250,13 +265,17 @@ describe("OrchestrationEngine", () => {...@@ -250,13 +265,17 @@ describe("OrchestrationEngine", () => {
250 });265 });
251266
252 it("keeps processing queued commands after a storage failure", async () => {267 it("keeps processing queued commands after a storage failure", async () => {
253 const events: OrchestrationEvent[] = [];268 type StoredEvent =
269 ReturnType<OrchestrationEventStoreShape["append"]> extends Effect.Effect<infer A, any, any>
270 ? A
271 : never;
272 const events: StoredEvent[] = [];
254 let nextSequence = 1;273 let nextSequence = 1;
255 let shouldFailFirstAppend = true;274 let shouldFailFirstAppend = true;
256275
257 const flakyStore: OrchestrationEventStoreShape = {276 const flakyStore: OrchestrationEventStoreShape = {
258 append(event) {277 append(event) {
259 if (shouldFailFirstAppend && event.commandId === "cmd-flaky-1") {278 if (shouldFailFirstAppend && event.commandId === asCommandId("cmd-flaky-1")) {
260 shouldFailFirstAppend = false;279 shouldFailFirstAppend = false;
261 return Effect.fail(280 return Effect.fail(
262 new PersistenceSqlError({281 new PersistenceSqlError({
...@@ -268,7 +287,7 @@ describe("OrchestrationEngine", () => {...@@ -268,7 +287,7 @@ describe("OrchestrationEngine", () => {
268 const savedEvent = {287 const savedEvent = {
269 ...event,288 ...event,
270 sequence: nextSequence,289 sequence: nextSequence,
271 } satisfies OrchestrationEvent;290 } as StoredEvent;
272 nextSequence += 1;291 nextSequence += 1;
273 events.push(savedEvent);292 events.push(savedEvent);
274 return Effect.succeed(savedEvent);293 return Effect.succeed(savedEvent);
...@@ -295,8 +314,8 @@ describe("OrchestrationEngine", () => {...@@ -295,8 +314,8 @@ describe("OrchestrationEngine", () => {
295 await runtime.runPromise(314 await runtime.runPromise(
296 engine.dispatch({315 engine.dispatch({
297 type: "project.create",316 type: "project.create",
298 commandId: "cmd-project-flaky-create",317 commandId: asCommandId("cmd-project-flaky-create"),
299 projectId: "project-flaky",318 projectId: asProjectId("project-flaky"),
300 title: "Flaky Project",319 title: "Flaky Project",
301 workspaceRoot: "/tmp/project-flaky",320 workspaceRoot: "/tmp/project-flaky",
302 defaultModel: "gpt-5-codex",321 defaultModel: "gpt-5-codex",
...@@ -308,9 +327,9 @@ describe("OrchestrationEngine", () => {...@@ -308,9 +327,9 @@ describe("OrchestrationEngine", () => {
308 runtime.runPromise(327 runtime.runPromise(
309 engine.dispatch({328 engine.dispatch({
310 type: "thread.create",329 type: "thread.create",
311 commandId: "cmd-flaky-1",330 commandId: asCommandId("cmd-flaky-1"),
312 threadId: "thread-flaky-fail",331 threadId: asThreadId("thread-flaky-fail"),
313 projectId: "project-flaky",332 projectId: asProjectId("project-flaky"),
314 title: "flaky-fail",333 title: "flaky-fail",
315 model: "gpt-5-codex",334 model: "gpt-5-codex",
316 branch: null,335 branch: null,
...@@ -323,9 +342,9 @@ describe("OrchestrationEngine", () => {...@@ -323,9 +342,9 @@ describe("OrchestrationEngine", () => {
323 const result = await runtime.runPromise(342 const result = await runtime.runPromise(
324 engine.dispatch({343 engine.dispatch({
325 type: "thread.create",344 type: "thread.create",
326 commandId: "cmd-flaky-2",345 commandId: asCommandId("cmd-flaky-2"),
327 threadId: "thread-flaky-ok",346 threadId: asThreadId("thread-flaky-ok"),
328 projectId: "project-flaky",347 projectId: asProjectId("project-flaky"),
329 title: "flaky-ok",348 title: "flaky-ok",
330 model: "gpt-5-codex",349 model: "gpt-5-codex",
331 branch: null,350 branch: null,
...@@ -347,10 +366,10 @@ describe("OrchestrationEngine", () => {...@@ -347,10 +366,10 @@ describe("OrchestrationEngine", () => {
347 system.run(366 system.run(
348 engine.dispatch({367 engine.dispatch({
349 type: "thread.turn.start",368 type: "thread.turn.start",
350 commandId: "cmd-invariant-missing-thread",369 commandId: asCommandId("cmd-invariant-missing-thread"),
351 threadId: "thread-missing",370 threadId: asThreadId("thread-missing"),
352 message: {371 message: {
353 messageId: "msg-missing",372 messageId: asMessageId("msg-missing"),
354 role: "user",373 role: "user",
355 text: "hello",374 text: "hello",
356 attachments: [],375 attachments: [],
...@@ -371,8 +390,8 @@ describe("OrchestrationEngine", () => {...@@ -371,8 +390,8 @@ describe("OrchestrationEngine", () => {
371 await system.run(390 await system.run(
372 engine.dispatch({391 engine.dispatch({
373 type: "project.create",392 type: "project.create",
374 commandId: "cmd-project-duplicate-create",393 commandId: asCommandId("cmd-project-duplicate-create"),
375 projectId: "project-duplicate",394 projectId: asProjectId("project-duplicate"),
376 title: "Duplicate Project",395 title: "Duplicate Project",
377 workspaceRoot: "/tmp/project-duplicate",396 workspaceRoot: "/tmp/project-duplicate",
378 defaultModel: "gpt-5-codex",397 defaultModel: "gpt-5-codex",
...@@ -383,9 +402,9 @@ describe("OrchestrationEngine", () => {...@@ -383,9 +402,9 @@ describe("OrchestrationEngine", () => {
383 await system.run(402 await system.run(
384 engine.dispatch({403 engine.dispatch({
385 type: "thread.create",404 type: "thread.create",
386 commandId: "cmd-thread-duplicate-1",405 commandId: asCommandId("cmd-thread-duplicate-1"),
387 threadId: "thread-duplicate",406 threadId: asThreadId("thread-duplicate"),
388 projectId: "project-duplicate",407 projectId: asProjectId("project-duplicate"),
389 title: "duplicate",408 title: "duplicate",
390 model: "gpt-5-codex",409 model: "gpt-5-codex",
391 branch: null,410 branch: null,
...@@ -398,9 +417,9 @@ describe("OrchestrationEngine", () => {...@@ -398,9 +417,9 @@ describe("OrchestrationEngine", () => {
398 system.run(417 system.run(
399 engine.dispatch({418 engine.dispatch({
400 type: "thread.create",419 type: "thread.create",
401 commandId: "cmd-thread-duplicate-2",420 commandId: asCommandId("cmd-thread-duplicate-2"),
402 threadId: "thread-duplicate",421 threadId: asThreadId("thread-duplicate"),
403 projectId: "project-duplicate",422 projectId: asProjectId("project-duplicate"),
404 title: "duplicate",423 title: "duplicate",
405 model: "gpt-5-codex",424 model: "gpt-5-codex",
406 branch: null,425 branch: null,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+3-1
...@@ -41,6 +41,8 @@ const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);...@@ -41,6 +41,8 @@ const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
41const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);41const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
42const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);42const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
43const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);43const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
44const asProviderThreadId = (value: string): ProviderThreadId =>
45 ProviderThreadId.makeUnsafe(value);
44const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);46const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4547
46function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef {48function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
...@@ -62,7 +64,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true) {...@@ -62,7 +64,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true) {
62 sessionId: asSessionId("sess-1"),64 sessionId: asSessionId("sess-1"),
63 provider: "codex",65 provider: "codex",
64 status: "ready",66 status: "ready",
65 threadId: asThreadId("thread-1"),67 threadId: asProviderThreadId("provider-thread-1"),
66 cwd,68 cwd,
67 createdAt: now,69 createdAt: now,
68 updatedAt: now,70 updatedAt: now,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+34-15
...@@ -1,3 +1,13 @@...@@ -1,3 +1,13 @@
1import {
2 CheckpointRef,
3 EventId,
4 MessageId,
5 ProjectId,
6 ProviderSessionId,
7 ProviderThreadId,
8 ThreadId,
9 TurnId,
10} from "@t3tools/contracts";
1import { assert, it } from "@effect/vitest";11import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";12import { Effect, Layer } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";13import * as SqlClient from "effect/unstable/sql/SqlClient";
...@@ -7,6 +17,15 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";...@@ -7,6 +17,15 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
7import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";17import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
8import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";18import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
919
20const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
21const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
22const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
23const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
24const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
25const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
26const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
27const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
28
10const projectionSnapshotLayer = it.layer(29const projectionSnapshotLayer = it.layer(
11 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),30 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
12);31);
...@@ -185,7 +204,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -185,7 +204,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
185 assert.equal(snapshot.updatedAt, "2026-02-24T00:00:09.000Z");204 assert.equal(snapshot.updatedAt, "2026-02-24T00:00:09.000Z");
186 assert.deepEqual(snapshot.projects, [205 assert.deepEqual(snapshot.projects, [
187 {206 {
188 id: "project-1",207 id: asProjectId("project-1"),
189 title: "Project 1",208 title: "Project 1",
190 workspaceRoot: "/tmp/project-1",209 workspaceRoot: "/tmp/project-1",
191 defaultModel: "gpt-5-codex",210 defaultModel: "gpt-5-codex",
...@@ -205,22 +224,22 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -205,22 +224,22 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
205 ]);224 ]);
206 assert.deepEqual(snapshot.threads, [225 assert.deepEqual(snapshot.threads, [
207 {226 {
208 id: "thread-1",227 id: asThreadId("thread-1"),
209 projectId: "project-1",228 projectId: asProjectId("project-1"),
210 title: "Thread 1",229 title: "Thread 1",
211 model: "gpt-5-codex",230 model: "gpt-5-codex",
212 branch: null,231 branch: null,
213 worktreePath: null,232 worktreePath: null,
214 latestTurnId: "turn-1",233 latestTurnId: asTurnId("turn-1"),
215 createdAt: "2026-02-24T00:00:02.000Z",234 createdAt: "2026-02-24T00:00:02.000Z",
216 updatedAt: "2026-02-24T00:00:03.000Z",235 updatedAt: "2026-02-24T00:00:03.000Z",
217 deletedAt: null,236 deletedAt: null,
218 messages: [237 messages: [
219 {238 {
220 id: "message-1",239 id: asMessageId("message-1"),
221 role: "assistant",240 role: "assistant",
222 text: "hello from projection",241 text: "hello from projection",
223 turnId: "turn-1",242 turnId: asTurnId("turn-1"),
224 streaming: false,243 streaming: false,
225 createdAt: "2026-02-24T00:00:04.000Z",244 createdAt: "2026-02-24T00:00:04.000Z",
226 updatedAt: "2026-02-24T00:00:05.000Z",245 updatedAt: "2026-02-24T00:00:05.000Z",
...@@ -228,33 +247,33 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -228,33 +247,33 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
228 ],247 ],
229 activities: [248 activities: [
230 {249 {
231 id: "activity-1",250 id: asEventId("activity-1"),
232 tone: "info",251 tone: "info",
233 kind: "runtime.note",252 kind: "runtime.note",
234 summary: "provider started",253 summary: "provider started",
235 payload: { stage: "start" },254 payload: { stage: "start" },
236 turnId: "turn-1",255 turnId: asTurnId("turn-1"),
237 createdAt: "2026-02-24T00:00:06.000Z",256 createdAt: "2026-02-24T00:00:06.000Z",
238 },257 },
239 ],258 ],
240 checkpoints: [259 checkpoints: [
241 {260 {
242 turnId: "turn-1",261 turnId: asTurnId("turn-1"),
243 checkpointTurnCount: 1,262 checkpointTurnCount: 1,
244 checkpointRef: "checkpoint-1",263 checkpointRef: asCheckpointRef("checkpoint-1"),
245 status: "ready",264 status: "ready",
246 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],265 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
247 assistantMessageId: "message-1",266 assistantMessageId: asMessageId("message-1"),
248 completedAt: "2026-02-24T00:00:08.000Z",267 completedAt: "2026-02-24T00:00:08.000Z",
249 },268 },
250 ],269 ],
251 session: {270 session: {
252 threadId: "thread-1",271 threadId: asThreadId("thread-1"),
253 status: "running",272 status: "running",
254 providerName: "codex",273 providerName: "codex",
255 providerSessionId: "provider-session-1",274 providerSessionId: asProviderSessionId("provider-session-1"),
256 providerThreadId: "provider-thread-1",275 providerThreadId: asProviderThreadId("provider-thread-1"),
257 activeTurnId: "turn-1",276 activeTurnId: asTurnId("turn-1"),
258 lastError: null,277 lastError: null,
259 updatedAt: "2026-02-24T00:00:07.000Z",278 updatedAt: "2026-02-24T00:00:07.000Z",
260 },279 },
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+7-3
...@@ -2,11 +2,13 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts";...@@ -2,11 +2,13 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts";
2import {2import {
3 ApprovalRequestId,3 ApprovalRequestId,
4 CommandId,4 CommandId,
5 MessageId,
5 ProjectId,6 ProjectId,
6 ProviderSessionId,7 ProviderSessionId,
7 ProviderThreadId,8 ProviderThreadId,
8 ProviderTurnId,9 ProviderTurnId,
9 ThreadId,10 ThreadId,
11 TurnId,
10} from "@t3tools/contracts";12} from "@t3tools/contracts";
11import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";13import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
12import { afterEach, describe, expect, it, vi } from "vitest";14import { afterEach, describe, expect, it, vi } from "vitest";
...@@ -30,6 +32,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);...@@ -30,6 +32,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
30const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);32const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
31const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);33const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
32const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);34const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
35const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
36const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
3337
34async function waitFor(38async function waitFor(
35 predicate: () => boolean,39 predicate: () => boolean,
...@@ -166,7 +170,7 @@ describe("ProviderCommandReactor", () => {...@@ -166,7 +170,7 @@ describe("ProviderCommandReactor", () => {
166 commandId: asCommandId("cmd-turn-start-1"),170 commandId: asCommandId("cmd-turn-start-1"),
167 threadId: asThreadId("thread-1"),171 threadId: asThreadId("thread-1"),
168 message: {172 message: {
169 messageId: "user-message-1",173 messageId: asMessageId("user-message-1"),
170 role: "user",174 role: "user",
171 text: "hello reactor",175 text: "hello reactor",
172 attachments: [],176 attachments: [],
...@@ -198,7 +202,7 @@ describe("ProviderCommandReactor", () => {...@@ -198,7 +202,7 @@ describe("ProviderCommandReactor", () => {
198 providerName: "codex",202 providerName: "codex",
199 providerSessionId: asSessionId("sess-1"),203 providerSessionId: asSessionId("sess-1"),
200 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),204 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
201 activeTurnId: "turn-1",205 activeTurnId: asTurnId("turn-1"),
202 lastError: null,206 lastError: null,
203 updatedAt: now,207 updatedAt: now,
204 },208 },
...@@ -211,7 +215,7 @@ describe("ProviderCommandReactor", () => {...@@ -211,7 +215,7 @@ describe("ProviderCommandReactor", () => {
211 type: "thread.turn.interrupt",215 type: "thread.turn.interrupt",
212 commandId: asCommandId("cmd-turn-interrupt"),216 commandId: asCommandId("cmd-turn-interrupt"),
213 threadId: asThreadId("thread-1"),217 threadId: asThreadId("thread-1"),
214 turnId: "turn-1",218 turnId: asTurnId("turn-1"),
215 createdAt: now,219 createdAt: now,
216 }),220 }),
217 );221 );
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1-1
...@@ -313,7 +313,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -313,7 +313,7 @@ describe("ProviderRuntimeIngestion", () => {
313 sessionId: asSessionId("sess-1"),313 sessionId: asSessionId("sess-1"),
314 createdAt: now,314 createdAt: now,
315 turnId: asProviderTurnId("turn-9"),315 turnId: asProviderTurnId("turn-9"),
316 toolKind: "read",316 toolKind: "other",
317 title: "Read file",317 title: "Read file",
318 detail: "/tmp/file.ts",318 detail: "/tmp/file.ts",
319 });319 });
apps/server/src/orchestration/commandInvariants.test.ts+39-28
...@@ -1,5 +1,12 @@...@@ -1,5 +1,12 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import type { OrchestrationCommand, OrchestrationReadModel } from "@t3tools/contracts";2import {
3 MessageId,
4 CommandId,
5 ProjectId,
6 ThreadId,
7 type OrchestrationCommand,
8 type OrchestrationReadModel,
9} from "@t3tools/contracts";
3import { Effect } from "effect";10import { Effect } from "effect";
411
5import {12import {
...@@ -17,7 +24,7 @@ const readModel: OrchestrationReadModel = {...@@ -17,7 +24,7 @@ const readModel: OrchestrationReadModel = {
17 updatedAt: now,24 updatedAt: now,
18 projects: [25 projects: [
19 {26 {
20 id: "project-a",27 id: ProjectId.makeUnsafe("project-a"),
21 title: "Project A",28 title: "Project A",
22 workspaceRoot: "/tmp/project-a",29 workspaceRoot: "/tmp/project-a",
23 defaultModel: "gpt-5-codex",30 defaultModel: "gpt-5-codex",
...@@ -27,7 +34,7 @@ const readModel: OrchestrationReadModel = {...@@ -27,7 +34,7 @@ const readModel: OrchestrationReadModel = {
27 deletedAt: null,34 deletedAt: null,
28 },35 },
29 {36 {
30 id: "project-b",37 id: ProjectId.makeUnsafe("project-b"),
31 title: "Project B",38 title: "Project B",
32 workspaceRoot: "/tmp/project-b",39 workspaceRoot: "/tmp/project-b",
33 defaultModel: "gpt-5-codex",40 defaultModel: "gpt-5-codex",
...@@ -39,8 +46,8 @@ const readModel: OrchestrationReadModel = {...@@ -39,8 +46,8 @@ const readModel: OrchestrationReadModel = {
39 ],46 ],
40 threads: [47 threads: [
41 {48 {
42 id: "thread-1",49 id: ThreadId.makeUnsafe("thread-1"),
43 projectId: "project-a",50 projectId: ProjectId.makeUnsafe("project-a"),
44 title: "Thread A",51 title: "Thread A",
45 model: "gpt-5-codex",52 model: "gpt-5-codex",
46 branch: null,53 branch: null,
...@@ -52,10 +59,11 @@ const readModel: OrchestrationReadModel = {...@@ -52,10 +59,11 @@ const readModel: OrchestrationReadModel = {
52 session: null,59 session: null,
53 activities: [],60 activities: [],
54 checkpoints: [],61 checkpoints: [],
62 deletedAt: null,
55 },63 },
56 {64 {
57 id: "thread-2",65 id: ThreadId.makeUnsafe("thread-2"),
58 projectId: "project-b",66 projectId: ProjectId.makeUnsafe("project-b"),
59 title: "Thread B",67 title: "Thread B",
60 model: "gpt-5-codex",68 model: "gpt-5-codex",
61 branch: null,69 branch: null,
...@@ -67,16 +75,17 @@ const readModel: OrchestrationReadModel = {...@@ -67,16 +75,17 @@ const readModel: OrchestrationReadModel = {
67 session: null,75 session: null,
68 activities: [],76 activities: [],
69 checkpoints: [],77 checkpoints: [],
78 deletedAt: null,
70 },79 },
71 ],80 ],
72};81};
7382
74const messageSendCommand: OrchestrationCommand = {83const messageSendCommand: OrchestrationCommand = {
75 type: "thread.turn.start",84 type: "thread.turn.start",
76 commandId: "cmd-1",85 commandId: CommandId.makeUnsafe("cmd-1"),
77 threadId: "thread-1",86 threadId: ThreadId.makeUnsafe("thread-1"),
78 message: {87 message: {
79 messageId: "msg-1",88 messageId: MessageId.makeUnsafe("msg-1"),
80 role: "user",89 role: "user",
81 text: "hello",90 text: "hello",
82 attachments: [],91 attachments: [],
...@@ -86,11 +95,13 @@ const messageSendCommand: OrchestrationCommand = {...@@ -86,11 +95,13 @@ const messageSendCommand: OrchestrationCommand = {
8695
87describe("commandInvariants", () => {96describe("commandInvariants", () => {
88 it("finds threads by id and project", () => {97 it("finds threads by id and project", () => {
89 expect(findThreadById(readModel, "thread-1")?.projectId).toBe("project-a");98 expect(findThreadById(readModel, ThreadId.makeUnsafe("thread-1"))?.projectId).toBe("project-a");
90 expect(findThreadById(readModel, "missing")).toBeUndefined();99 expect(findThreadById(readModel, ThreadId.makeUnsafe("missing"))).toBeUndefined();
91 expect(listThreadsByProjectId(readModel, "project-b").map((thread) => thread.id)).toEqual([100 expect(
92 "thread-2",101 listThreadsByProjectId(readModel, ProjectId.makeUnsafe("project-b")).map(
93 ]);102 (thread) => thread.id,
103 ),
104 ).toEqual([ThreadId.makeUnsafe("thread-2")]);
94 });105 });
95106
96 it("requires existing thread", async () => {107 it("requires existing thread", async () => {
...@@ -98,17 +109,17 @@ describe("commandInvariants", () => {...@@ -98,17 +109,17 @@ describe("commandInvariants", () => {
98 requireThread({109 requireThread({
99 readModel,110 readModel,
100 command: messageSendCommand,111 command: messageSendCommand,
101 threadId: "thread-1",112 threadId: ThreadId.makeUnsafe("thread-1"),
102 }),113 }),
103 );114 );
104 expect(thread.id).toBe("thread-1");115 expect(thread.id).toBe(ThreadId.makeUnsafe("thread-1"));
105116
106 await expect(117 await expect(
107 Effect.runPromise(118 Effect.runPromise(
108 requireThread({119 requireThread({
109 readModel,120 readModel,
110 command: messageSendCommand,121 command: messageSendCommand,
111 threadId: "missing",122 threadId: ThreadId.makeUnsafe("missing"),
112 }),123 }),
113 ),124 ),
114 ).rejects.toThrow("does not exist");125 ).rejects.toThrow("does not exist");
...@@ -120,16 +131,16 @@ describe("commandInvariants", () => {...@@ -120,16 +131,16 @@ describe("commandInvariants", () => {
120 readModel,131 readModel,
121 command: {132 command: {
122 type: "thread.create",133 type: "thread.create",
123 commandId: "cmd-2",134 commandId: CommandId.makeUnsafe("cmd-2"),
124 threadId: "thread-3",135 threadId: ThreadId.makeUnsafe("thread-3"),
125 projectId: "project-a",136 projectId: ProjectId.makeUnsafe("project-a"),
126 title: "new",137 title: "new",
127 model: "gpt-5-codex",138 model: "gpt-5-codex",
128 branch: null,139 branch: null,
129 worktreePath: null,140 worktreePath: null,
130 createdAt: now,141 createdAt: now,
131 },142 },
132 threadId: "thread-3",143 threadId: ThreadId.makeUnsafe("thread-3"),
133 }),144 }),
134 );145 );
135146
...@@ -139,16 +150,16 @@ describe("commandInvariants", () => {...@@ -139,16 +150,16 @@ describe("commandInvariants", () => {
139 readModel,150 readModel,
140 command: {151 command: {
141 type: "thread.create",152 type: "thread.create",
142 commandId: "cmd-3",153 commandId: CommandId.makeUnsafe("cmd-3"),
143 threadId: "thread-1",154 threadId: ThreadId.makeUnsafe("thread-1"),
144 projectId: "project-a",155 projectId: ProjectId.makeUnsafe("project-a"),
145 title: "dup",156 title: "dup",
146 model: "gpt-5-codex",157 model: "gpt-5-codex",
147 branch: null,158 branch: null,
148 worktreePath: null,159 worktreePath: null,
149 createdAt: now,160 createdAt: now,
150 },161 },
151 threadId: "thread-1",162 threadId: ThreadId.makeUnsafe("thread-1"),
152 }),163 }),
153 ),164 ),
154 ).rejects.toThrow("already exists");165 ).rejects.toThrow("already exists");
...@@ -157,7 +168,7 @@ describe("commandInvariants", () => {...@@ -157,7 +168,7 @@ describe("commandInvariants", () => {
157 it("requires non-negative integers", async () => {168 it("requires non-negative integers", async () => {
158 await Effect.runPromise(169 await Effect.runPromise(
159 requireNonNegativeInteger({170 requireNonNegativeInteger({
160 commandType: "thread.revert",171 commandType: "thread.checkpoint.revert",
161 field: "turnCount",172 field: "turnCount",
162 value: 0,173 value: 0,
163 }),174 }),
...@@ -166,7 +177,7 @@ describe("commandInvariants", () => {...@@ -166,7 +177,7 @@ describe("commandInvariants", () => {
166 await expect(177 await expect(
167 Effect.runPromise(178 Effect.runPromise(
168 requireNonNegativeInteger({179 requireNonNegativeInteger({
169 commandType: "thread.revert",180 commandType: "thread.checkpoint.revert",
170 field: "turnCount",181 field: "turnCount",
171 value: -1,182 value: -1,
172 }),183 }),
apps/server/src/orchestration/decider.projectScripts.test.ts+33-26
...@@ -1,9 +1,16 @@...@@ -1,9 +1,16 @@
1import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
1import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
2import { Effect } from "effect";3import { Effect } from "effect";
34
4import { decideOrchestrationCommand } from "./decider.ts";5import { decideOrchestrationCommand } from "./decider.ts";
5import { createEmptyReadModel, projectEvent } from "./projector.ts";6import { createEmptyReadModel, projectEvent } from "./projector.ts";
67
8const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
9const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
10const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
11const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
12const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
13
7describe("decider project scripts", () => {14describe("decider project scripts", () => {
8 it("emits empty scripts on project.create", async () => {15 it("emits empty scripts on project.create", async () => {
9 const now = new Date().toISOString();16 const now = new Date().toISOString();
...@@ -13,8 +20,8 @@ describe("decider project scripts", () => {...@@ -13,8 +20,8 @@ describe("decider project scripts", () => {
13 decideOrchestrationCommand({20 decideOrchestrationCommand({
14 command: {21 command: {
15 type: "project.create",22 type: "project.create",
16 commandId: "cmd-project-create-scripts",23 commandId: asCommandId("cmd-project-create-scripts"),
17 projectId: "project-scripts",24 projectId: asProjectId("project-scripts"),
18 title: "Scripts",25 title: "Scripts",
19 workspaceRoot: "/tmp/scripts",26 workspaceRoot: "/tmp/scripts",
20 createdAt: now,27 createdAt: now,
...@@ -34,17 +41,17 @@ describe("decider project scripts", () => {...@@ -34,17 +41,17 @@ describe("decider project scripts", () => {
34 const readModel = await Effect.runPromise(41 const readModel = await Effect.runPromise(
35 projectEvent(initial, {42 projectEvent(initial, {
36 sequence: 1,43 sequence: 1,
37 eventId: "evt-project-create-scripts",44 eventId: asEventId("evt-project-create-scripts"),
38 aggregateType: "project",45 aggregateKind: "project",
39 aggregateId: "project-scripts",46 aggregateId: asProjectId("project-scripts"),
40 type: "project.created",47 type: "project.created",
41 occurredAt: now,48 occurredAt: now,
42 commandId: "cmd-project-create-scripts",49 commandId: asCommandId("cmd-project-create-scripts"),
43 causationEventId: null,50 causationEventId: null,
44 correlationId: "cmd-project-create-scripts",51 correlationId: asCommandId("cmd-project-create-scripts"),
45 metadata: {},52 metadata: {},
46 payload: {53 payload: {
47 projectId: "project-scripts",54 projectId: asProjectId("project-scripts"),
48 title: "Scripts",55 title: "Scripts",
49 workspaceRoot: "/tmp/scripts",56 workspaceRoot: "/tmp/scripts",
50 defaultModel: null,57 defaultModel: null,
...@@ -69,8 +76,8 @@ describe("decider project scripts", () => {...@@ -69,8 +76,8 @@ describe("decider project scripts", () => {
69 decideOrchestrationCommand({76 decideOrchestrationCommand({
70 command: {77 command: {
71 type: "project.meta.update",78 type: "project.meta.update",
72 commandId: "cmd-project-update-scripts",79 commandId: asCommandId("cmd-project-update-scripts"),
73 projectId: "project-scripts",80 projectId: asProjectId("project-scripts"),
74 scripts: Array.from(scripts),81 scripts: Array.from(scripts),
75 },82 },
76 readModel,83 readModel,
...@@ -88,17 +95,17 @@ describe("decider project scripts", () => {...@@ -88,17 +95,17 @@ describe("decider project scripts", () => {
88 const withProject = await Effect.runPromise(95 const withProject = await Effect.runPromise(
89 projectEvent(initial, {96 projectEvent(initial, {
90 sequence: 1,97 sequence: 1,
91 eventId: "evt-project-create",98 eventId: asEventId("evt-project-create"),
92 aggregateType: "project",99 aggregateKind: "project",
93 aggregateId: "project-1",100 aggregateId: asProjectId("project-1"),
94 type: "project.created",101 type: "project.created",
95 occurredAt: now,102 occurredAt: now,
96 commandId: "cmd-project-create",103 commandId: asCommandId("cmd-project-create"),
97 causationEventId: null,104 causationEventId: null,
98 correlationId: "cmd-project-create",105 correlationId: asCommandId("cmd-project-create"),
99 metadata: {},106 metadata: {},
100 payload: {107 payload: {
101 projectId: "project-1",108 projectId: asProjectId("project-1"),
102 title: "Project",109 title: "Project",
103 workspaceRoot: "/tmp/project",110 workspaceRoot: "/tmp/project",
104 defaultModel: null,111 defaultModel: null,
...@@ -111,18 +118,18 @@ describe("decider project scripts", () => {...@@ -111,18 +118,18 @@ describe("decider project scripts", () => {
111 const readModel = await Effect.runPromise(118 const readModel = await Effect.runPromise(
112 projectEvent(withProject, {119 projectEvent(withProject, {
113 sequence: 2,120 sequence: 2,
114 eventId: "evt-thread-create",121 eventId: asEventId("evt-thread-create"),
115 aggregateType: "thread",122 aggregateKind: "thread",
116 aggregateId: "thread-1",123 aggregateId: asThreadId("thread-1"),
117 type: "thread.created",124 type: "thread.created",
118 occurredAt: now,125 occurredAt: now,
119 commandId: "cmd-thread-create",126 commandId: asCommandId("cmd-thread-create"),
120 causationEventId: null,127 causationEventId: null,
121 correlationId: "cmd-thread-create",128 correlationId: asCommandId("cmd-thread-create"),
122 metadata: {},129 metadata: {},
123 payload: {130 payload: {
124 threadId: "thread-1",131 threadId: asThreadId("thread-1"),
125 projectId: "project-1",132 projectId: asProjectId("project-1"),
126 title: "Thread",133 title: "Thread",
127 model: "gpt-5-codex",134 model: "gpt-5-codex",
128 branch: null,135 branch: null,
...@@ -137,10 +144,10 @@ describe("decider project scripts", () => {...@@ -137,10 +144,10 @@ describe("decider project scripts", () => {
137 decideOrchestrationCommand({144 decideOrchestrationCommand({
138 command: {145 command: {
139 type: "thread.turn.start",146 type: "thread.turn.start",
140 commandId: "cmd-turn-start",147 commandId: asCommandId("cmd-turn-start"),
141 threadId: "thread-1",148 threadId: asThreadId("thread-1"),
142 message: {149 message: {
143 messageId: "message-user-1",150 messageId: asMessageId("message-user-1"),
144 role: "user",151 role: "user",
145 text: "hello",152 text: "hello",
146 attachments: [],153 attachments: [],
apps/server/src/orchestration/projector.test.ts+29-26
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { OrchestrationEvent } from "@t3tools/contracts";1import { CommandId, EventId, ProjectId, ThreadId, type OrchestrationEvent } from "@t3tools/contracts";
2import { Effect } from "effect";2import { Effect } from "effect";
3import { describe, expect, it } from "vitest";3import { describe, expect, it } from "vitest";
44
...@@ -8,24 +8,27 @@ function makeEvent(input: {...@@ -8,24 +8,27 @@ function makeEvent(input: {
8 sequence: number;8 sequence: number;
9 type: OrchestrationEvent["type"];9 type: OrchestrationEvent["type"];
10 occurredAt: string;10 occurredAt: string;
11 aggregateType: OrchestrationEvent["aggregateType"];11 aggregateKind: OrchestrationEvent["aggregateKind"];
12 aggregateId: string;12 aggregateId: string;
13 commandId: string | null;13 commandId: string | null;
14 payload: unknown;14 payload: unknown;
15}): OrchestrationEvent {15}): OrchestrationEvent {
16 return {16 return {
17 sequence: input.sequence,17 sequence: input.sequence,
18 eventId: `event-${input.sequence}`,18 eventId: EventId.makeUnsafe(`event-${input.sequence}`),
19 type: input.type,19 type: input.type,
20 aggregateType: input.aggregateType,20 aggregateKind: input.aggregateKind,
21 aggregateId: input.aggregateId as OrchestrationEvent["aggregateId"],21 aggregateId:
22 input.aggregateKind === "project"
23 ? ProjectId.makeUnsafe(input.aggregateId)
24 : ThreadId.makeUnsafe(input.aggregateId),
22 occurredAt: input.occurredAt,25 occurredAt: input.occurredAt,
23 commandId: input.commandId as OrchestrationEvent["commandId"],26 commandId: input.commandId === null ? null : CommandId.makeUnsafe(input.commandId),
24 causationEventId: null,27 causationEventId: null,
25 correlationId: null,28 correlationId: null,
26 metadata: {},29 metadata: {},
27 payload: input.payload,30 payload: input.payload as never,
28 };31 } as OrchestrationEvent;
29}32}
3033
31describe("orchestration projector", () => {34describe("orchestration projector", () => {
...@@ -39,7 +42,7 @@ describe("orchestration projector", () => {...@@ -39,7 +42,7 @@ describe("orchestration projector", () => {
39 makeEvent({42 makeEvent({
40 sequence: 1,43 sequence: 1,
41 type: "thread.created",44 type: "thread.created",
42 aggregateType: "thread",45 aggregateKind: "thread",
43 aggregateId: "thread-1",46 aggregateId: "thread-1",
44 occurredAt: now,47 occurredAt: now,
45 commandId: "cmd-thread-create",48 commandId: "cmd-thread-create",
...@@ -89,7 +92,7 @@ describe("orchestration projector", () => {...@@ -89,7 +92,7 @@ describe("orchestration projector", () => {
89 makeEvent({92 makeEvent({
90 sequence: 1,93 sequence: 1,
91 type: "thread.created",94 type: "thread.created",
92 aggregateType: "thread",95 aggregateKind: "thread",
93 aggregateId: "thread-1",96 aggregateId: "thread-1",
94 occurredAt: now,97 occurredAt: now,
95 commandId: "cmd-invalid",98 commandId: "cmd-invalid",
...@@ -119,7 +122,7 @@ describe("orchestration projector", () => {...@@ -119,7 +122,7 @@ describe("orchestration projector", () => {
119 makeEvent({122 makeEvent({
120 sequence: 7,123 sequence: 7,
121 type: "thread.turn-start-requested",124 type: "thread.turn-start-requested",
122 aggregateType: "thread",125 aggregateKind: "thread",
123 aggregateId: "thread-1",126 aggregateId: "thread-1",
124 occurredAt: "2026-01-01T00:00:00.000Z",127 occurredAt: "2026-01-01T00:00:00.000Z",
125 commandId: "cmd-unhandled",128 commandId: "cmd-unhandled",
...@@ -148,7 +151,7 @@ describe("orchestration projector", () => {...@@ -148,7 +151,7 @@ describe("orchestration projector", () => {
148 makeEvent({151 makeEvent({
149 sequence: 1,152 sequence: 1,
150 type: "thread.created",153 type: "thread.created",
151 aggregateType: "thread",154 aggregateKind: "thread",
152 aggregateId: "thread-1",155 aggregateId: "thread-1",
153 occurredAt: createdAt,156 occurredAt: createdAt,
154 commandId: "cmd-create",157 commandId: "cmd-create",
...@@ -172,7 +175,7 @@ describe("orchestration projector", () => {...@@ -172,7 +175,7 @@ describe("orchestration projector", () => {
172 makeEvent({175 makeEvent({
173 sequence: 2,176 sequence: 2,
174 type: "thread.session-set",177 type: "thread.session-set",
175 aggregateType: "thread",178 aggregateKind: "thread",
176 aggregateId: "thread-1",179 aggregateId: "thread-1",
177 occurredAt: startedAt,180 occurredAt: startedAt,
178 commandId: "cmd-running",181 commandId: "cmd-running",
...@@ -210,7 +213,7 @@ describe("orchestration projector", () => {...@@ -210,7 +213,7 @@ describe("orchestration projector", () => {
210 makeEvent({213 makeEvent({
211 sequence: 1,214 sequence: 1,
212 type: "thread.created",215 type: "thread.created",
213 aggregateType: "thread",216 aggregateKind: "thread",
214 aggregateId: "thread-1",217 aggregateId: "thread-1",
215 occurredAt: createdAt,218 occurredAt: createdAt,
216 commandId: "cmd-create",219 commandId: "cmd-create",
...@@ -234,7 +237,7 @@ describe("orchestration projector", () => {...@@ -234,7 +237,7 @@ describe("orchestration projector", () => {
234 makeEvent({237 makeEvent({
235 sequence: 2,238 sequence: 2,
236 type: "thread.message-sent",239 type: "thread.message-sent",
237 aggregateType: "thread",240 aggregateKind: "thread",
238 aggregateId: "thread-1",241 aggregateId: "thread-1",
239 occurredAt: deltaAt,242 occurredAt: deltaAt,
240 commandId: "cmd-delta",243 commandId: "cmd-delta",
...@@ -258,7 +261,7 @@ describe("orchestration projector", () => {...@@ -258,7 +261,7 @@ describe("orchestration projector", () => {
258 makeEvent({261 makeEvent({
259 sequence: 3,262 sequence: 3,
260 type: "thread.message-sent",263 type: "thread.message-sent",
261 aggregateType: "thread",264 aggregateKind: "thread",
262 aggregateId: "thread-1",265 aggregateId: "thread-1",
263 occurredAt: completeAt,266 occurredAt: completeAt,
264 commandId: "cmd-complete",267 commandId: "cmd-complete",
...@@ -293,7 +296,7 @@ describe("orchestration projector", () => {...@@ -293,7 +296,7 @@ describe("orchestration projector", () => {
293 makeEvent({296 makeEvent({
294 sequence: 1,297 sequence: 1,
295 type: "thread.created",298 type: "thread.created",
296 aggregateType: "thread",299 aggregateKind: "thread",
297 aggregateId: "thread-1",300 aggregateId: "thread-1",
298 occurredAt: createdAt,301 occurredAt: createdAt,
299 commandId: "cmd-create",302 commandId: "cmd-create",
...@@ -315,7 +318,7 @@ describe("orchestration projector", () => {...@@ -315,7 +318,7 @@ describe("orchestration projector", () => {
315 makeEvent({318 makeEvent({
316 sequence: 2,319 sequence: 2,
317 type: "thread.message-sent",320 type: "thread.message-sent",
318 aggregateType: "thread",321 aggregateKind: "thread",
319 aggregateId: "thread-1",322 aggregateId: "thread-1",
320 occurredAt: "2026-02-23T10:00:01.000Z",323 occurredAt: "2026-02-23T10:00:01.000Z",
321 commandId: "cmd-user-1",324 commandId: "cmd-user-1",
...@@ -333,7 +336,7 @@ describe("orchestration projector", () => {...@@ -333,7 +336,7 @@ describe("orchestration projector", () => {
333 makeEvent({336 makeEvent({
334 sequence: 3,337 sequence: 3,
335 type: "thread.message-sent",338 type: "thread.message-sent",
336 aggregateType: "thread",339 aggregateKind: "thread",
337 aggregateId: "thread-1",340 aggregateId: "thread-1",
338 occurredAt: "2026-02-23T10:00:02.000Z",341 occurredAt: "2026-02-23T10:00:02.000Z",
339 commandId: "cmd-assistant-1",342 commandId: "cmd-assistant-1",
...@@ -351,7 +354,7 @@ describe("orchestration projector", () => {...@@ -351,7 +354,7 @@ describe("orchestration projector", () => {
351 makeEvent({354 makeEvent({
352 sequence: 4,355 sequence: 4,
353 type: "thread.turn-diff-completed",356 type: "thread.turn-diff-completed",
354 aggregateType: "thread",357 aggregateKind: "thread",
355 aggregateId: "thread-1",358 aggregateId: "thread-1",
356 occurredAt: "2026-02-23T10:00:02.500Z",359 occurredAt: "2026-02-23T10:00:02.500Z",
357 commandId: "cmd-turn-1-complete",360 commandId: "cmd-turn-1-complete",
...@@ -369,7 +372,7 @@ describe("orchestration projector", () => {...@@ -369,7 +372,7 @@ describe("orchestration projector", () => {
369 makeEvent({372 makeEvent({
370 sequence: 5,373 sequence: 5,
371 type: "thread.activity-appended",374 type: "thread.activity-appended",
372 aggregateType: "thread",375 aggregateKind: "thread",
373 aggregateId: "thread-1",376 aggregateId: "thread-1",
374 occurredAt: "2026-02-23T10:00:02.750Z",377 occurredAt: "2026-02-23T10:00:02.750Z",
375 commandId: "cmd-activity-1",378 commandId: "cmd-activity-1",
...@@ -389,7 +392,7 @@ describe("orchestration projector", () => {...@@ -389,7 +392,7 @@ describe("orchestration projector", () => {
389 makeEvent({392 makeEvent({
390 sequence: 6,393 sequence: 6,
391 type: "thread.message-sent",394 type: "thread.message-sent",
392 aggregateType: "thread",395 aggregateKind: "thread",
393 aggregateId: "thread-1",396 aggregateId: "thread-1",
394 occurredAt: "2026-02-23T10:00:03.000Z",397 occurredAt: "2026-02-23T10:00:03.000Z",
395 commandId: "cmd-user-2",398 commandId: "cmd-user-2",
...@@ -407,7 +410,7 @@ describe("orchestration projector", () => {...@@ -407,7 +410,7 @@ describe("orchestration projector", () => {
407 makeEvent({410 makeEvent({
408 sequence: 7,411 sequence: 7,
409 type: "thread.message-sent",412 type: "thread.message-sent",
410 aggregateType: "thread",413 aggregateKind: "thread",
411 aggregateId: "thread-1",414 aggregateId: "thread-1",
412 occurredAt: "2026-02-23T10:00:04.000Z",415 occurredAt: "2026-02-23T10:00:04.000Z",
413 commandId: "cmd-assistant-2",416 commandId: "cmd-assistant-2",
...@@ -425,7 +428,7 @@ describe("orchestration projector", () => {...@@ -425,7 +428,7 @@ describe("orchestration projector", () => {
425 makeEvent({428 makeEvent({
426 sequence: 8,429 sequence: 8,
427 type: "thread.turn-diff-completed",430 type: "thread.turn-diff-completed",
428 aggregateType: "thread",431 aggregateKind: "thread",
429 aggregateId: "thread-1",432 aggregateId: "thread-1",
430 occurredAt: "2026-02-23T10:00:04.500Z",433 occurredAt: "2026-02-23T10:00:04.500Z",
431 commandId: "cmd-turn-2-complete",434 commandId: "cmd-turn-2-complete",
...@@ -443,7 +446,7 @@ describe("orchestration projector", () => {...@@ -443,7 +446,7 @@ describe("orchestration projector", () => {
443 makeEvent({446 makeEvent({
444 sequence: 9,447 sequence: 9,
445 type: "thread.activity-appended",448 type: "thread.activity-appended",
446 aggregateType: "thread",449 aggregateKind: "thread",
447 aggregateId: "thread-1",450 aggregateId: "thread-1",
448 occurredAt: "2026-02-23T10:00:04.750Z",451 occurredAt: "2026-02-23T10:00:04.750Z",
449 commandId: "cmd-activity-2",452 commandId: "cmd-activity-2",
...@@ -463,7 +466,7 @@ describe("orchestration projector", () => {...@@ -463,7 +466,7 @@ describe("orchestration projector", () => {
463 makeEvent({466 makeEvent({
464 sequence: 10,467 sequence: 10,
465 type: "thread.reverted",468 type: "thread.reverted",
466 aggregateType: "thread",469 aggregateKind: "thread",
467 aggregateId: "thread-1",470 aggregateId: "thread-1",
468 occurredAt: "2026-02-23T10:00:05.000Z",471 occurredAt: "2026-02-23T10:00:05.000Z",
469 commandId: "cmd-revert",472 commandId: "cmd-revert",
apps/server/src/provider/Layers/CodexAdapter.test.ts+46-34
...@@ -1,10 +1,16 @@...@@ -1,10 +1,16 @@
1import type {1import {
2 ProviderApprovalDecision,2 ApprovalRequestId,
3 ProviderEvent,3 EventId,
4 ProviderSendTurnInput,4 ProviderItemId,
5 ProviderSession,5 type ProviderApprovalDecision,
6 ProviderSessionStartInput,6 type ProviderEvent,
7 ProviderTurnStartResult,7 type ProviderSendTurnInput,
8 ProviderSessionId,
9 type ProviderSession,
10 type ProviderSessionStartInput,
11 ProviderThreadId,
12 ProviderTurnId,
13 type ProviderTurnStartResult,
8} from "@t3tools/contracts";14} from "@t3tools/contracts";
9import { afterAll, assert, it, vi } from "@effect/vitest";15import { afterAll, assert, it, vi } from "@effect/vitest";
10import { assertFailure } from "@effect/vitest/utils";16import { assertFailure } from "@effect/vitest/utils";
...@@ -16,15 +22,21 @@ import { ProviderAdapterValidationError } from "../Errors.ts";...@@ -16,15 +22,21 @@ import { ProviderAdapterValidationError } from "../Errors.ts";
16import { CodexAdapter } from "../Services/CodexAdapter.ts";22import { CodexAdapter } from "../Services/CodexAdapter.ts";
17import { makeCodexAdapterLive } from "./CodexAdapter.ts";23import { makeCodexAdapterLive } from "./CodexAdapter.ts";
1824
25const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
26const asThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
27const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
28const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
29const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
30
19class FakeCodexManager extends CodexAppServerManager {31class FakeCodexManager extends CodexAppServerManager {
20 public startSessionImpl = vi.fn(32 public startSessionImpl = vi.fn(
21 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {33 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {
22 const now = new Date().toISOString();34 const now = new Date().toISOString();
23 return {35 return {
24 sessionId: "sess-1",36 sessionId: asSessionId("sess-1"),
25 provider: "codex",37 provider: "codex",
26 status: "ready",38 status: "ready",
27 threadId: "thread-1",39 threadId: asThreadId("thread-1"),
28 cwd: input.cwd,40 cwd: input.cwd,
29 createdAt: now,41 createdAt: now,
30 updatedAt: now,42 updatedAt: now,
...@@ -34,29 +46,29 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -34,29 +46,29 @@ class FakeCodexManager extends CodexAppServerManager {
3446
35 public sendTurnImpl = vi.fn(47 public sendTurnImpl = vi.fn(
36 async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({48 async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({
37 threadId: "thread-1",49 threadId: asThreadId("thread-1"),
38 turnId: "turn-1",50 turnId: asTurnId("turn-1"),
39 }),51 }),
40 );52 );
4153
42 public interruptTurnImpl = vi.fn(54 public interruptTurnImpl = vi.fn(
43 async (_sessionId: string, _turnId?: string): Promise<void> => undefined,55 async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise<void> => undefined,
44 );56 );
4557
46 public readThreadImpl = vi.fn(async (_sessionId: string) => ({58 public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({
47 threadId: "thread-1",59 threadId: asThreadId("thread-1"),
48 turns: [],60 turns: [],
49 }));61 }));
5062
51 public rollbackThreadImpl = vi.fn(async (_sessionId: string, _numTurns: number) => ({63 public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({
52 threadId: "thread-1",64 threadId: asThreadId("thread-1"),
53 turns: [],65 turns: [],
54 }));66 }));
5567
56 public respondToRequestImpl = vi.fn(68 public respondToRequestImpl = vi.fn(
57 async (69 async (
58 _sessionId: string,70 _sessionId: ProviderSessionId,
59 _requestId: string,71 _requestId: ApprovalRequestId,
60 _decision: ProviderApprovalDecision,72 _decision: ProviderApprovalDecision,
61 ): Promise<void> => undefined,73 ): Promise<void> => undefined,
62 );74 );
...@@ -71,33 +83,33 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -71,33 +83,33 @@ class FakeCodexManager extends CodexAppServerManager {
71 return this.sendTurnImpl(input);83 return this.sendTurnImpl(input);
72 }84 }
7385
74 override interruptTurn(sessionId: string, turnId?: string): Promise<void> {86 override interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {
75 return this.interruptTurnImpl(sessionId, turnId);87 return this.interruptTurnImpl(sessionId, turnId);
76 }88 }
7789
78 override readThread(sessionId: string) {90 override readThread(sessionId: ProviderSessionId) {
79 return this.readThreadImpl(sessionId);91 return this.readThreadImpl(sessionId);
80 }92 }
8193
82 override rollbackThread(sessionId: string, numTurns: number) {94 override rollbackThread(sessionId: ProviderSessionId, numTurns: number) {
83 return this.rollbackThreadImpl(sessionId, numTurns);95 return this.rollbackThreadImpl(sessionId, numTurns);
84 }96 }
8597
86 override respondToRequest(98 override respondToRequest(
87 sessionId: string,99 sessionId: ProviderSessionId,
88 requestId: string,100 requestId: ApprovalRequestId,
89 decision: ProviderApprovalDecision,101 decision: ProviderApprovalDecision,
90 ): Promise<void> {102 ): Promise<void> {
91 return this.respondToRequestImpl(sessionId, requestId, decision);103 return this.respondToRequestImpl(sessionId, requestId, decision);
92 }104 }
93105
94 override stopSession(_sessionId: string): void {}106 override stopSession(_sessionId: ProviderSessionId): void {}
95107
96 override listSessions(): ProviderSession[] {108 override listSessions(): ProviderSession[] {
97 return [];109 return [];
98 }110 }
99111
100 override hasSession(_sessionId: string): boolean {112 override hasSession(_sessionId: ProviderSessionId): boolean {
101 return false;113 return false;
102 }114 }
103115
...@@ -144,7 +156,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {...@@ -144,7 +156,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
144 const adapter = yield* CodexAdapter;156 const adapter = yield* CodexAdapter;
145 const result = yield* adapter157 const result = yield* adapter
146 .sendTurn({158 .sendTurn({
147 sessionId: "sess-missing",159 sessionId: asSessionId("sess-missing"),
148 input: "hello",160 input: "hello",
149 attachments: [],161 attachments: [],
150 })162 })
...@@ -176,15 +188,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -176,15 +188,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
176 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);188 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
177189
178 const event: ProviderEvent = {190 const event: ProviderEvent = {
179 id: "evt-msg-complete",191 id: asEventId("evt-msg-complete"),
180 kind: "notification",192 kind: "notification",
181 provider: "codex",193 provider: "codex",
182 sessionId: "sess-1",194 sessionId: asSessionId("sess-1"),
183 createdAt: new Date().toISOString(),195 createdAt: new Date().toISOString(),
184 method: "item/completed",196 method: "item/completed",
185 threadId: "thread-1",197 threadId: asThreadId("thread-1"),
186 turnId: "turn-1",198 turnId: asTurnId("turn-1"),
187 itemId: "msg_1",199 itemId: asItemId("msg_1"),
188 payload: {200 payload: {
189 item: {201 item: {
190 type: "agentMessage",202 type: "agentMessage",
...@@ -220,13 +232,13 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -220,13 +232,13 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
220 ).pipe(Effect.forkChild);232 ).pipe(Effect.forkChild);
221233
222 const event: ProviderEvent = {234 const event: ProviderEvent = {
223 id: "evt-1",235 id: asEventId("evt-1"),
224 kind: "notification",236 kind: "notification",
225 provider: "codex",237 provider: "codex",
226 sessionId: "sess-1",238 sessionId: asSessionId("sess-1"),
227 createdAt: new Date().toISOString(),239 createdAt: new Date().toISOString(),
228 method: "turn/started",240 method: "turn/started",
229 turnId: "turn-1",241 turnId: asTurnId("turn-1"),
230 };242 };
231243
232 lifecycleManager.emit("event", event);244 lifecycleManager.emit("event", event);
apps/server/src/provider/Layers/ProviderService.test.ts+22-36
...@@ -45,17 +45,6 @@ import {...@@ -45,17 +45,6 @@ import {
45 SqlitePersistenceMemory,45 SqlitePersistenceMemory,
46} from "../../persistence/Layers/Sqlite.ts";46} from "../../persistence/Layers/Sqlite.ts";
4747
48function decodeIssueMessage(cause: unknown): string {
49 if (cause instanceof Error && cause.message.length > 0) {
50 return cause.message;
51 }
52 try {
53 return JSON.stringify(cause);
54 } catch {
55 return "Invalid provider input.";
56 }
57}
58
59const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);48const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
60const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);49const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
61const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);50const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
...@@ -526,11 +515,18 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -526,11 +515,18 @@ routing.layer("ProviderServiceLive routing", (it) => {
526 const payload = runningRuntime.value.runtimePayload;515 const payload = runningRuntime.value.runtimePayload;
527 assert.equal(payload !== null && typeof payload === "object", true);516 assert.equal(payload !== null && typeof payload === "object", true);
528 if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {517 if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
529 assert.equal(payload.cwd, process.cwd());518 const runtimePayload = payload as {
530 assert.equal(payload.model, null);519 cwd: string;
531 assert.equal(payload.activeTurnId, "turn-1");520 model: string | null;
532 assert.equal(payload.lastError, null);521 activeTurnId: string | null;
533 assert.equal(payload.lastRuntimeEvent, "provider.sendTurn");522 lastError: string | null;
523 lastRuntimeEvent: string | null;
524 };
525 assert.equal(runtimePayload.cwd, process.cwd());
526 assert.equal(runtimePayload.model, null);
527 assert.equal(runtimePayload.activeTurnId, "turn-1");
528 assert.equal(runtimePayload.lastError, null);
529 assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn");
534 }530 }
535 }531 }
536532
...@@ -669,32 +665,22 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -669,32 +665,22 @@ validation.layer("ProviderServiceLive validation", (it) => {
669 Effect.gen(function* () {665 Effect.gen(function* () {
670 const provider = yield* ProviderService;666 const provider = yield* ProviderService;
671667
672 let parseCause: unknown;
673 let decodeFailed = false;
674 try {
675 Schema.decodeUnknownSync(ProviderSessionStartInput)({
676 provider: "invalid-provider",
677 });
678 } catch (cause) {
679 parseCause = cause;
680 decodeFailed = true;
681 }
682 assert.equal(decodeFailed, true);
683
684 const failure = yield* Effect.result(668 const failure = yield* Effect.result(
685 provider.startSession({669 provider.startSession({
686 provider: "invalid-provider",670 provider: "invalid-provider",
687 } as never),671 } as never),
688 );672 );
689673
690 assertFailure(674 assert.equal(failure._tag, "Failure");
691 failure,675 if (failure._tag !== "Failure") {
692 new ProviderValidationError({676 return;
693 operation: "ProviderService.startSession",677 }
694 issue: decodeIssueMessage(parseCause),678 assert.equal(failure.failure._tag, "ProviderValidationError");
695 cause: parseCause,679 if (failure.failure._tag !== "ProviderValidationError") {
696 }),680 return;
697 );681 }
682 assert.equal(failure.failure.operation, "ProviderService.startSession");
683 assert.equal(failure.failure.issue.includes("invalid-provider"), true);
698 }),684 }),
699 );685 );
700686
apps/server/src/wsServer.test.ts+80-17
...@@ -10,15 +10,20 @@ import WebSocket from "ws";...@@ -10,15 +10,20 @@ import WebSocket from "ws";
1010
11import {11import {
12 DEFAULT_TERMINAL_ID,12 DEFAULT_TERMINAL_ID,
13 EventId,
13 ORCHESTRATION_WS_CHANNELS,14 ORCHESTRATION_WS_CHANNELS,
14 ORCHESTRATION_WS_METHODS,15 ORCHESTRATION_WS_METHODS,
16 ProviderItemId,
17 ProviderSessionId,
18 ProviderThreadId,
19 ProviderTurnId,
15 WS_CHANNELS,20 WS_CHANNELS,
16 WS_METHODS,21 WS_METHODS,
22 type WebSocketResponse,
17 type ProviderRuntimeEvent,23 type ProviderRuntimeEvent,
18 type KeybindingsConfig,24 type KeybindingsConfig,
19 type ResolvedKeybindingsConfig,25 type ResolvedKeybindingsConfig,
20 type WsPush,26 type WsPush,
21 type WsResponse,
22} from "@t3tools/contracts";27} from "@t3tools/contracts";
23import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";28import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
24import type {29import type {
...@@ -34,6 +39,7 @@ import type { TerminalManager } from "./terminalManager";...@@ -34,6 +39,7 @@ import type { TerminalManager } from "./terminalManager";
34import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";39import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
35import { SqlClient } from "effect/unstable/sql";40import { SqlClient } from "effect/unstable/sql";
36import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";41import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
42import type { OpenShape } from "./open";
3743
38interface PendingMessages {44interface PendingMessages {
39 queue: unknown[];45 queue: unknown[];
...@@ -42,6 +48,12 @@ interface PendingMessages {...@@ -42,6 +48,12 @@ interface PendingMessages {
4248
43const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();49const pendingBySocket = new WeakMap<WebSocket, PendingMessages>();
4450
51const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
52const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
53const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
54const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
55const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
56
45class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {57class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
46 private readonly sessions = new Map<string, TerminalSessionSnapshot>();58 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
4759
...@@ -187,7 +199,19 @@ function waitForMessage(ws: WebSocket): Promise<unknown> {...@@ -187,7 +199,19 @@ function waitForMessage(ws: WebSocket): Promise<unknown> {
187 });199 });
188}200}
189201
190async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> {202function asWebSocketResponse(message: unknown): WebSocketResponse | null {
203 if (typeof message !== "object" || message === null) return null;
204 if (!("id" in message)) return null;
205 const id = (message as { id?: unknown }).id;
206 if (typeof id !== "string") return null;
207 return message as WebSocketResponse;
208}
209
210async function sendRequest(
211 ws: WebSocket,
212 method: string,
213 params?: unknown,
214): Promise<WebSocketResponse> {
191 const id = crypto.randomUUID();215 const id = crypto.randomUUID();
192 const body =216 const body =
193 method === ORCHESTRATION_WS_METHODS.dispatchCommand217 method === ORCHESTRATION_WS_METHODS.dispatchCommand
...@@ -200,12 +224,15 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro...@@ -200,12 +224,15 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro
200224
201 // Wait for response with matching id225 // Wait for response with matching id
202 while (true) {226 while (true) {
203 const parsed = (await waitForMessage(ws)) as Record<string, unknown>;227 const parsed = asWebSocketResponse(await waitForMessage(ws));
228 if (!parsed) {
229 continue;
230 }
204 if (parsed.id === id) {231 if (parsed.id === id) {
205 return parsed as WsResponse;232 return parsed;
206 }233 }
207 if (parsed.id === "unknown") {234 if (parsed.id === "unknown") {
208 return parsed as WsResponse;235 return parsed;
209 }236 }
210 }237 }
211}238}
...@@ -233,7 +260,7 @@ async function waitForPush(...@@ -233,7 +260,7 @@ async function waitForPush(
233}260}
234261
235function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {262function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
236 const resolved: ResolvedKeybindingsConfig = [];263 const resolved: Array<ResolvedKeybindingsConfig[number]> = [];
237 for (const binding of bindings) {264 for (const binding of bindings) {
238 const compiled = compileResolvedKeybindingRule(binding);265 const compiled = compileResolvedKeybindingRule(binding);
239 if (!compiled) {266 if (!compiled) {
...@@ -278,6 +305,7 @@ describe("WebSocket Server", () => {...@@ -278,6 +305,7 @@ describe("WebSocket Server", () => {
278 authToken?: string;305 authToken?: string;
279 stateDir?: string;306 stateDir?: string;
280 providerLayer?: Layer.Layer<ProviderService, unknown>;307 providerLayer?: Layer.Layer<ProviderService, unknown>;
308 open?: OpenShape;
281 gitManager?: {309 gitManager?: {
282 status: (input: { cwd: string }) => Promise<unknown>;310 status: (input: { cwd: string }) => Promise<unknown>;
283 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;311 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;
...@@ -297,6 +325,7 @@ describe("WebSocket Server", () => {...@@ -297,6 +325,7 @@ describe("WebSocket Server", () => {
297 ...(options.devUrl ? { devUrl: options.devUrl } : {}),325 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
298 ...(options.authToken ? { authToken: options.authToken } : {}),326 ...(options.authToken ? { authToken: options.authToken } : {}),
299 ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}),327 ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}),
328 ...(options.open ? { open: options.open } : {}),
300 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),329 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
301 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),330 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),
302 });331 });
...@@ -420,6 +449,33 @@ describe("WebSocket Server", () => {...@@ -420,6 +449,33 @@ describe("WebSocket Server", () => {
420 });449 });
421 });450 });
422451
452 it("routes shell.openInEditor through the injected open service", async () => {
453 const openCalls: Array<{ cwd: string; editor: string }> = [];
454 const openService: OpenShape = {
455 openBrowser: () => Effect.void,
456 openInEditor: (input) => {
457 openCalls.push({ cwd: input.cwd, editor: input.editor });
458 return Effect.void;
459 },
460 };
461
462 server = createTestServer({ cwd: "/my/workspace", open: openService });
463 await server.start();
464 const addr = server.httpServer.address();
465 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
466
467 const ws = await connectWs(port);
468 connections.push(ws);
469 await waitForMessage(ws);
470
471 const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, {
472 cwd: "/my/workspace",
473 editor: "cursor",
474 });
475 expect(response.error).toBeUndefined();
476 expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]);
477 });
478
423 it("reads keybindings from ~/.t3/keybindings.json", async () => {479 it("reads keybindings from ~/.t3/keybindings.json", async () => {
424 const fakeHome = makeTempDir("t3code-home-");480 const fakeHome = makeTempDir("t3code-home-");
425 const configDir = path.join(fakeHome, ".t3");481 const configDir = path.join(fakeHome, ".t3");
...@@ -787,7 +843,7 @@ describe("WebSocket Server", () => {...@@ -787,7 +843,7 @@ describe("WebSocket Server", () => {
787843
788 it("keeps orchestration domain push behavior for provider runtime events", async () => {844 it("keeps orchestration domain push behavior for provider runtime events", async () => {
789 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());845 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
790 const sessionId = "sess-test";846 const sessionId = asProviderSessionId("sess-test");
791 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {847 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
792 Effect.runSync(PubSub.publish(runtimeEventPubSub, event));848 Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
793 };849 };
...@@ -798,11 +854,15 @@ describe("WebSocket Server", () => {...@@ -798,11 +854,15 @@ describe("WebSocket Server", () => {
798 sessionId,854 sessionId,
799 provider: "codex",855 provider: "codex",
800 status: "ready",856 status: "ready",
801 threadId: "provider-thread-1",857 threadId: asProviderThreadId("provider-thread-1"),
802 createdAt: new Date().toISOString(),858 createdAt: new Date().toISOString(),
803 updatedAt: new Date().toISOString(),859 updatedAt: new Date().toISOString(),
804 }),860 }),
805 sendTurn: () => Effect.succeed({ threadId: "provider-thread-1", turnId: "provider-turn-1" }),861 sendTurn: () =>
862 Effect.succeed({
863 threadId: asProviderThreadId("provider-thread-1"),
864 turnId: asProviderTurnId("provider-turn-1"),
865 }),
806 interruptTurn: () => unsupported(),866 interruptTurn: () => unsupported(),
807 respondToRequest: () => unsupported(),867 respondToRequest: () => unsupported(),
808 stopSession: () => unsupported(),868 stopSession: () => unsupported(),
...@@ -870,12 +930,12 @@ describe("WebSocket Server", () => {...@@ -870,12 +930,12 @@ describe("WebSocket Server", () => {
870930
871 emitRuntimeEvent({931 emitRuntimeEvent({
872 type: "message.delta",932 type: "message.delta",
873 eventId: "evt-ws-runtime-message-delta",933 eventId: asEventId("evt-ws-runtime-message-delta"),
874 provider: "codex",934 provider: "codex",
875 sessionId,935 sessionId,
876 createdAt: new Date().toISOString(),936 createdAt: new Date().toISOString(),
877 turnId: "turn-1",937 turnId: asProviderTurnId("turn-1"),
878 itemId: "item-1",938 itemId: asProviderItemId("item-1"),
879 delta: "hello from runtime",939 delta: "hello from runtime",
880 });940 });
881941
...@@ -1015,15 +1075,18 @@ describe("WebSocket Server", () => {...@@ -1015,15 +1075,18 @@ describe("WebSocket Server", () => {
1015 // Send garbage1075 // Send garbage
1016 ws.send("not json at all");1076 ws.send("not json at all");
10171077
1018 let response: WsResponse | null = null;1078 let response: WebSocketResponse | null = null;
1019 for (let attempt = 0; attempt < 5; attempt += 1) {1079 for (let attempt = 0; attempt < 5; attempt += 1) {
1020 const message = (await waitForMessage(ws)) as Record<string, unknown>;1080 const message = asWebSocketResponse(await waitForMessage(ws));
1021 if (typeof message.id === "string" && message.id === "unknown") {1081 if (!message) {
1022 response = message as WsResponse;1082 continue;
1083 }
1084 if (message.id === "unknown") {
1085 response = message;
1023 break;1086 break;
1024 }1087 }
1025 if (message.error) {1088 if (message.error) {
1026 response = message as WsResponse;1089 response = message;
1027 break;1090 break;
1028 }1091 }
1029 }1092 }
apps/server/src/wsServer.ts+28-48
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import { spawn } from "node:child_process";
2import fs from "node:fs";1import fs from "node:fs";
3import http from "node:http";2import http from "node:http";
4import os from "node:os";3import os from "node:os";
...@@ -7,7 +6,6 @@ import type { Duplex } from "node:stream";...@@ -7,7 +6,6 @@ import type { Duplex } from "node:stream";
76
8import {7import {
9 CheckpointRef,8 CheckpointRef,
10 EDITORS,
11 ORCHESTRATION_WS_CHANNELS,9 ORCHESTRATION_WS_CHANNELS,
12 ORCHESTRATION_WS_METHODS,10 ORCHESTRATION_WS_METHODS,
13 TerminalEvent,11 TerminalEvent,
...@@ -18,7 +16,7 @@ import {...@@ -18,7 +16,7 @@ import {
18 WsResponse,16 WsResponse,
19} from "@t3tools/contracts";17} from "@t3tools/contracts";
20import { NodeServices } from "@effect/platform-node";18import { NodeServices } from "@effect/platform-node";
21import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, Stream } from "effect";19import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, ServiceMap, Stream } from "effect";
22import { WebSocketServer, type WebSocket } from "ws";20import { WebSocketServer, type WebSocket } from "ws";
2321
24import { createLogger } from "./logger";22import { createLogger } from "./logger";
...@@ -62,12 +60,16 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD...@@ -62,12 +60,16 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
62import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";60import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
63import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";61import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
64import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";62import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
65import { CheckpointStore, type CheckpointStoreShape } from "./checkpointing/Services/CheckpointStore";63import {
64 CheckpointStore,
65 type CheckpointStoreShape,
66} from "./checkpointing/Services/CheckpointStore";
66import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";67import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
67import * as SqlClient from "effect/unstable/sql/SqlClient";68import * as SqlClient from "effect/unstable/sql/SqlClient";
68import * as SqlError from "effect/unstable/sql/SqlError";69import * as SqlError from "effect/unstable/sql/SqlError";
69import * as Migrator from "effect/unstable/sql/Migrator";70import * as Migrator from "effect/unstable/sql/Migrator";
70import { clamp } from "effect/Number";71import { clamp } from "effect/Number";
72import { OpenDefaultService, type OpenShape } from "./open";
7173
72const MIME_TYPES: Record<string, string> = {74const MIME_TYPES: Record<string, string> = {
73 ".html": "text/html; charset=utf-8",75 ".html": "text/html; charset=utf-8",
...@@ -100,8 +102,25 @@ export interface ServerOptions {...@@ -100,8 +102,25 @@ export interface ServerOptions {
100 terminalManager?: TerminalManager | undefined;102 terminalManager?: TerminalManager | undefined;
101 providerLayer?: Layer.Layer<ProviderService, unknown> | undefined;103 providerLayer?: Layer.Layer<ProviderService, unknown> | undefined;
102 authToken?: string | undefined;104 authToken?: string | undefined;
105 open?: OpenShape | undefined;
106}
107
108export interface ServerRuntime {
109 readonly start: () => Promise<void>;
110 readonly stop: () => Promise<void>;
111 readonly httpServer: http.Server;
103}112}
104113
114export interface ServerShape {
115 readonly createServer: (options: ServerOptions) => ServerRuntime;
116}
117
118export class Server extends ServiceMap.Service<Server, ServerShape>()("server/Server") {}
119
120export const ServerLive = Layer.succeed(Server, {
121 createServer,
122} satisfies ServerShape);
123
105const isServerNotRunningError = (error: unknown): boolean => {124const isServerNotRunningError = (error: unknown): boolean => {
106 if (!(error instanceof Error)) return false;125 if (!(error instanceof Error)) return false;
107 const maybeCode = (error as NodeJS.ErrnoException).code;126 const maybeCode = (error as NodeJS.ErrnoException).code;
...@@ -162,9 +181,7 @@ function websocketRawToString(raw: unknown): string | null {...@@ -162,9 +181,7 @@ function websocketRawToString(raw: unknown): string | null {
162 return null;181 return null;
163}182}
164183
165function stripRequestTag<T extends { _tag: string }>(184function stripRequestTag<T extends { _tag: string }>(body: T): Omit<T, "_tag"> {
166 body: T,
167): Omit<T, "_tag"> {
168 const { _tag: _ignoredTag, ...rest } = body;185 const { _tag: _ignoredTag, ...rest } = body;
169 return rest;186 return rest;
170}187}
...@@ -185,7 +202,7 @@ type EffectRuntime = ManagedRuntime.ManagedRuntime<...@@ -185,7 +202,7 @@ type EffectRuntime = ManagedRuntime.ManagedRuntime<
185 unknown202 unknown
186>;203>;
187204
188export function createServer(options: ServerOptions) {205export function createServer(options: ServerOptions): ServerRuntime {
189 const {206 const {
190 port,207 port,
191 host,208 host,
...@@ -200,6 +217,7 @@ export function createServer(options: ServerOptions) {...@@ -200,6 +217,7 @@ export function createServer(options: ServerOptions) {
200 terminalManager = new TerminalManager(),217 terminalManager = new TerminalManager(),
201 providerLayer: customProviderLayer,218 providerLayer: customProviderLayer,
202 authToken,219 authToken,
220 open: openService = OpenDefaultService,
203 } = options;221 } = options;
204222
205 const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");223 const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");
...@@ -626,42 +644,7 @@ export function createServer(options: ServerOptions) {...@@ -626,42 +644,7 @@ export function createServer(options: ServerOptions) {
626 return searchWorkspaceEntries(stripRequestTag(request.body));644 return searchWorkspaceEntries(stripRequestTag(request.body));
627645
628 case WS_METHODS.shellOpenInEditor: {646 case WS_METHODS.shellOpenInEditor: {
629 const body = request.body;647 await Effect.runPromise(openService.openInEditor(stripRequestTag(request.body)));
630 const editorDef = EDITORS.find((e) => e.id === body.editor);
631 if (!editorDef) throw new Error(`Unknown editor: ${body.editor}`);
632
633 let command: string;
634 let args: string[];
635
636 if (editorDef.command) {
637 command = editorDef.command;
638 args = [body.cwd];
639 } else if (editorDef.id === "file-manager") {
640 // Use platform-specific file manager command
641 switch (process.platform) {
642 case "darwin":
643 command = "open";
644 break;
645 case "win32":
646 command = "explorer";
647 break;
648 default:
649 command = "xdg-open";
650 break;
651 }
652 args = [body.cwd];
653 } else {
654 return undefined;
655 }
656
657 const child = spawn(command, args, {
658 detached: true,
659 stdio: "ignore",
660 });
661 child.on("error", () => {
662 /* ignore spawn failures for detached editors */
663 });
664 child.unref();
665 return undefined;648 return undefined;
666 }649 }
667650
...@@ -763,10 +746,7 @@ export function createServer(options: ServerOptions) {...@@ -763,10 +746,7 @@ export function createServer(options: ServerOptions) {
763 );746 );
764 return makeProviderServiceLive({747 return makeProviderServiceLive({
765 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),748 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
766 }).pipe(749 }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
767 Layer.provide(adapterRegistryLayer),
768 Layer.provide(providerSessionDirectoryLayer),
769 );
770 })();750 })();
771751
772 const runtimeServicesLayer = Layer.mergeAll(752 const runtimeServicesLayer = Layer.mergeAll(
apps/server/tsconfig.json+1-2
...@@ -12,6 +12,5 @@...@@ -12,6 +12,5 @@
12 }12 }
13 ]13 ]
14 },14 },
15 "include": ["src", "tsdown.config.ts", "scripts", "integration"],15 "include": ["src", "tsdown.config.ts", "scripts", "integration"]
16 "exclude": ["src/**/*.test.ts"]
17}16}
packages/contracts/src/git.ts+1-1
...@@ -68,7 +68,7 @@ export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>...@@ -68,7 +68,7 @@ export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>
68)({68)({
69 cwd: TrimmedNonEmptyString,69 cwd: TrimmedNonEmptyString,
70 path: TrimmedNonEmptyString,70 path: TrimmedNonEmptyString,
71 force: Schema.Boolean.pipe(Schema.NullOr),71 force: Schema.optional(Schema.Boolean),
72}) {}72}) {}
7373
74export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>(74export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>(
packages/contracts/src/keybindings.test.ts+1-3
...@@ -12,9 +12,7 @@ const decode = <S extends Schema.Top>(schema: S, input: unknown): Schema.Schema....@@ -12,9 +12,7 @@ const decode = <S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.
12 Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>;12 Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>;
1313
14const decodeResolvedRuleStrict = (input: unknown) =>14const decodeResolvedRuleStrict = (input: unknown) =>
15 Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input, {15 Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input);
16 onExcessProperty: "error",
17 });
1816
19describe("keybindings contracts", () => {17describe("keybindings contracts", () => {
20 it("parses keybinding rules", () => {18 it("parses keybinding rules", () => {
packages/contracts/src/keybindings.ts+8-5
...@@ -70,11 +70,14 @@ export type KeybindingWhenNode =...@@ -70,11 +70,14 @@ export type KeybindingWhenNode =
7070
71export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>(71export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>(
72 "ResolvedKeybindingRule",72 "ResolvedKeybindingRule",
73)({73)(
74 command: KeybindingCommand,74 {
75 shortcut: KeybindingShortcut,75 command: KeybindingCommand,
76 whenAst: Schema.optional(KeybindingWhenNode),76 shortcut: KeybindingShortcut,
77}) {}77 whenAst: Schema.optional(KeybindingWhenNode),
78 },
79 { parseOptions: { onExcessProperty: "error" } },
80) {}
7881
79export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(82export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(
80 Schema.isMaxLength(256),83 Schema.isMaxLength(256),
scripts/dev-runner.mjs+9
...@@ -84,6 +84,15 @@ function main() {...@@ -84,6 +84,15 @@ function main() {
84 VITE_WS_URL: `ws://localhost:${serverPort}`,84 VITE_WS_URL: `ws://localhost:${serverPort}`,
85 VITE_DEV_SERVER_URL: `http://localhost:${webPort}`,85 VITE_DEV_SERVER_URL: `http://localhost:${webPort}`,
86 };86 };
87
88 if (mode === "dev" || mode === "dev:server" || mode === "dev:web") {
89 // Running server/web in browser mode should not inherit desktop launcher state.
90 env.T3CODE_MODE = "web";
91 delete env.T3CODE_NO_BROWSER;
92 delete env.T3CODE_AUTH_TOKEN;
93 delete env.T3CODE_DESKTOP_WS_URL;
94 }
95
87 if (mode === "dev" && !env.T3CODE_LOG_WS_EVENTS) {96 if (mode === "dev" && !env.T3CODE_LOG_WS_EVENTS) {
88 env.T3CODE_LOG_WS_EVENTS = "1";97 env.T3CODE_LOG_WS_EVENTS = "1";
89 }98 }