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 @@
11import { describe, expect, it, vi } from "vitest";
2import { ProviderSessionId } from "@t3tools/contracts";
23
34import {
45 CodexAppServerManager,
......@@ -7,6 +8,8 @@ import {
78 normalizeCodexModelSlug,
89} from "./codexAppServerManager";
910
11const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
12
1013function createSendTurnHarness() {
1114 const manager = new CodexAppServerManager();
1215 const context = {
......@@ -183,7 +186,7 @@ describe("sendTurn", () => {
183186 createSendTurnHarness();
184187
185188 const result = await manager.sendTurn({
186 sessionId: "sess_1",
189 sessionId: asSessionId("sess_1"),
187190 input: "Inspect this image",
188191 attachments: [
189192 {
......@@ -229,7 +232,7 @@ describe("sendTurn", () => {
229232 const { manager, context, sendRequest } = createSendTurnHarness();
230233
231234 await manager.sendTurn({
232 sessionId: "sess_1",
235 sessionId: asSessionId("sess_1"),
233236 attachments: [
234237 {
235238 type: "image",
......@@ -257,7 +260,7 @@ describe("sendTurn", () => {
257260
258261 await expect(
259262 manager.sendTurn({
260 sessionId: "sess_1",
263 sessionId: asSessionId("sess_1"),
261264 }),
262265 ).rejects.toThrow("Turn input must include text or attachments.");
263266 });
......@@ -278,7 +281,7 @@ describe("thread checkpoint control", () => {
278281 },
279282 });
280283
281 const result = await manager.readThread("sess_1");
284 const result = await manager.readThread(asSessionId("sess_1"));
282285
283286 expect(requireSession).toHaveBeenCalledWith("sess_1");
284287 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
......@@ -308,7 +311,7 @@ describe("thread checkpoint control", () => {
308311 ],
309312 });
310313
311 const result = await manager.readThread("sess_1");
314 const result = await manager.readThread(asSessionId("sess_1"));
312315
313316 expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
314317 threadId: "thread_1",
......@@ -334,7 +337,7 @@ describe("thread checkpoint control", () => {
334337 },
335338 });
336339
337 const result = await manager.rollbackThread("sess_1", 2);
340 const result = await manager.rollbackThread(asSessionId("sess_1"), 2);
338341
339342 expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {
340343 threadId: "thread_1",
apps/server/src/index.ts+17-177
......@@ -1,177 +1,17 @@
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 { fixPath } from "./fixPath";
8import { createLogger } from "./logger";
9import { createServer } from "./wsServer";
10
11fixPath();
12
13const DEFAULT_PORT = 3773;
14// Dummy comment for a no-op change request.
15// Additional dummy comment requested in chat.
16const cwd = process.cwd();
17const logger = createLogger("server");
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});
1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { CliConfigLive, makeCliCommand } from "./main";
7import { OpenLive } from "./open";
8import { Command } from "effect/unstable/cli";
9import { version } from "../package.json" with { type: "json" };
10import { ServerLive } from "./wsServer";
11
12const RuntimeLayer = Layer.mergeAll(CliConfigLive, ServerLive, OpenLive, NodeServices.layer);
13
14Command.run(makeCliCommand(), { version }).pipe(
15 Effect.provide(RuntimeLayer),
16 NodeRuntime.runMain,
17);
apps/server/src/keybindings.ts+1-1
......@@ -326,7 +326,7 @@ function loadCustomKeybindingsConfig(
326326 const sanitized: Mutable<KeybindingsConfig> = [];
327327 let invalidEntries = 0;
328328 for (const entry of parsed) {
329 const result = Schema.decodeUnknownExit(KeybindingRule)(entry);
329 const result = Schema.decodeExit(KeybindingRule)(entry);
330330 if (result._tag === "Success") {
331331 if (!compileResolvedKeybindingRule(result.value)) {
332332 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 @@
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";
210import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect";
311import { describe, expect, it } from "vitest";
412
......@@ -14,6 +22,13 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
1422import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
1523import { 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
1732async function createOrchestrationSystem() {
1833 const orchestrationLayer = OrchestrationEngineLive.pipe(
1934 Layer.provide(OrchestrationProjectionPipelineLive),
......@@ -43,8 +58,8 @@ describe("OrchestrationEngine", () => {
4358 await system.run(
4459 engine.dispatch({
4560 type: "project.create",
46 commandId: "cmd-project-1-create",
47 projectId: "project-1",
61 commandId: asCommandId("cmd-project-1-create"),
62 projectId: asProjectId("project-1"),
4863 title: "Project 1",
4964 workspaceRoot: "/tmp/project-1",
5065 defaultModel: "gpt-5-codex",
......@@ -54,9 +69,9 @@ describe("OrchestrationEngine", () => {
5469 await system.run(
5570 engine.dispatch({
5671 type: "thread.create",
57 commandId: "cmd-thread-1-create",
58 threadId: "thread-1",
59 projectId: "project-1",
72 commandId: asCommandId("cmd-thread-1-create"),
73 threadId: asThreadId("thread-1"),
74 projectId: asProjectId("project-1"),
6075 title: "Thread",
6176 model: "gpt-5-codex",
6277 branch: null,
......@@ -67,10 +82,10 @@ describe("OrchestrationEngine", () => {
6782 await system.run(
6883 engine.dispatch({
6984 type: "thread.turn.start",
70 commandId: "cmd-turn-start-1",
71 threadId: "thread-1",
85 commandId: asCommandId("cmd-turn-start-1"),
86 threadId: asThreadId("thread-1"),
7287 message: {
73 messageId: "msg-1",
88 messageId: asMessageId("msg-1"),
7489 role: "user",
7590 text: "hello",
7691 attachments: [],
......@@ -93,8 +108,8 @@ describe("OrchestrationEngine", () => {
93108 await system.run(
94109 engine.dispatch({
95110 type: "project.create",
96 commandId: "cmd-project-replay-create",
97 projectId: "project-replay",
111 commandId: asCommandId("cmd-project-replay-create"),
112 projectId: asProjectId("project-replay"),
98113 title: "Replay Project",
99114 workspaceRoot: "/tmp/project-replay",
100115 defaultModel: "gpt-5-codex",
......@@ -104,9 +119,9 @@ describe("OrchestrationEngine", () => {
104119 await system.run(
105120 engine.dispatch({
106121 type: "thread.create",
107 commandId: "cmd-thread-replay-create",
108 threadId: "thread-replay",
109 projectId: "project-replay",
122 commandId: asCommandId("cmd-thread-replay-create"),
123 threadId: asThreadId("thread-replay"),
124 projectId: asProjectId("project-replay"),
110125 title: "replay",
111126 model: "gpt-5-codex",
112127 branch: null,
......@@ -117,8 +132,8 @@ describe("OrchestrationEngine", () => {
117132 await system.run(
118133 engine.dispatch({
119134 type: "thread.delete",
120 commandId: "cmd-thread-replay-delete",
121 threadId: "thread-replay",
135 commandId: asCommandId("cmd-thread-replay-delete"),
136 threadId: asThreadId("thread-replay"),
122137 }),
123138 );
124139
......@@ -143,8 +158,8 @@ describe("OrchestrationEngine", () => {
143158 await system.run(
144159 engine.dispatch({
145160 type: "project.create",
146 commandId: "cmd-project-stream-create",
147 projectId: "project-stream",
161 commandId: asCommandId("cmd-project-stream-create"),
162 projectId: asProjectId("project-stream"),
148163 title: "Stream Project",
149164 workspaceRoot: "/tmp/project-stream",
150165 defaultModel: "gpt-5-codex",
......@@ -164,9 +179,9 @@ describe("OrchestrationEngine", () => {
164179 yield* Effect.sleep("10 millis");
165180 yield* engine.dispatch({
166181 type: "thread.create",
167 commandId: "cmd-stream-thread-create",
168 threadId: "thread-stream",
169 projectId: "project-stream",
182 commandId: asCommandId("cmd-stream-thread-create"),
183 threadId: asThreadId("thread-stream"),
184 projectId: asProjectId("project-stream"),
170185 title: "domain-stream",
171186 model: "gpt-5-codex",
172187 branch: null,
......@@ -175,8 +190,8 @@ describe("OrchestrationEngine", () => {
175190 });
176191 yield* engine.dispatch({
177192 type: "thread.meta.update",
178 commandId: "cmd-stream-thread-update",
179 threadId: "thread-stream",
193 commandId: asCommandId("cmd-stream-thread-update"),
194 threadId: asThreadId("thread-stream"),
180195 title: "domain-stream-updated",
181196 });
182197 eventTypes.push((yield* Queue.take(eventQueue)).type);
......@@ -196,8 +211,8 @@ describe("OrchestrationEngine", () => {
196211 await system.run(
197212 engine.dispatch({
198213 type: "project.create",
199 commandId: "cmd-project-turn-diff-create",
200 projectId: "project-turn-diff",
214 commandId: asCommandId("cmd-project-turn-diff-create"),
215 projectId: asProjectId("project-turn-diff"),
201216 title: "Turn Diff Project",
202217 workspaceRoot: "/tmp/project-turn-diff",
203218 defaultModel: "gpt-5-codex",
......@@ -207,9 +222,9 @@ describe("OrchestrationEngine", () => {
207222 await system.run(
208223 engine.dispatch({
209224 type: "thread.create",
210 commandId: "cmd-thread-turn-diff-create",
211 threadId: "thread-turn-diff",
212 projectId: "project-turn-diff",
225 commandId: asCommandId("cmd-thread-turn-diff-create"),
226 threadId: asThreadId("thread-turn-diff"),
227 projectId: asProjectId("project-turn-diff"),
213228 title: "Turn diff thread",
214229 model: "gpt-5-codex",
215230 branch: null,
......@@ -220,11 +235,11 @@ describe("OrchestrationEngine", () => {
220235 await system.run(
221236 engine.dispatch({
222237 type: "thread.turn.diff.complete",
223 commandId: "cmd-turn-diff-complete",
224 threadId: "thread-turn-diff",
225 turnId: "turn-1",
238 commandId: asCommandId("cmd-turn-diff-complete"),
239 threadId: asThreadId("thread-turn-diff"),
240 turnId: asTurnId("turn-1"),
226241 completedAt: createdAt,
227 checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1",
242 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),
228243 status: "ready",
229244 files: [],
230245 checkpointTurnCount: 1,
......@@ -237,9 +252,9 @@ describe("OrchestrationEngine", () => {
237252 );
238253 expect(thread?.checkpoints).toEqual([
239254 {
240 turnId: "turn-1",
255 turnId: asTurnId("turn-1"),
241256 checkpointTurnCount: 1,
242 checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1",
257 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),
243258 status: "ready",
244259 files: [],
245260 assistantMessageId: null,
......@@ -250,13 +265,17 @@ describe("OrchestrationEngine", () => {
250265 });
251266
252267 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[] = [];
254273 let nextSequence = 1;
255274 let shouldFailFirstAppend = true;
256275
257276 const flakyStore: OrchestrationEventStoreShape = {
258277 append(event) {
259 if (shouldFailFirstAppend && event.commandId === "cmd-flaky-1") {
278 if (shouldFailFirstAppend && event.commandId === asCommandId("cmd-flaky-1")) {
260279 shouldFailFirstAppend = false;
261280 return Effect.fail(
262281 new PersistenceSqlError({
......@@ -268,7 +287,7 @@ describe("OrchestrationEngine", () => {
268287 const savedEvent = {
269288 ...event,
270289 sequence: nextSequence,
271 } satisfies OrchestrationEvent;
290 } as StoredEvent;
272291 nextSequence += 1;
273292 events.push(savedEvent);
274293 return Effect.succeed(savedEvent);
......@@ -295,8 +314,8 @@ describe("OrchestrationEngine", () => {
295314 await runtime.runPromise(
296315 engine.dispatch({
297316 type: "project.create",
298 commandId: "cmd-project-flaky-create",
299 projectId: "project-flaky",
317 commandId: asCommandId("cmd-project-flaky-create"),
318 projectId: asProjectId("project-flaky"),
300319 title: "Flaky Project",
301320 workspaceRoot: "/tmp/project-flaky",
302321 defaultModel: "gpt-5-codex",
......@@ -308,9 +327,9 @@ describe("OrchestrationEngine", () => {
308327 runtime.runPromise(
309328 engine.dispatch({
310329 type: "thread.create",
311 commandId: "cmd-flaky-1",
312 threadId: "thread-flaky-fail",
313 projectId: "project-flaky",
330 commandId: asCommandId("cmd-flaky-1"),
331 threadId: asThreadId("thread-flaky-fail"),
332 projectId: asProjectId("project-flaky"),
314333 title: "flaky-fail",
315334 model: "gpt-5-codex",
316335 branch: null,
......@@ -323,9 +342,9 @@ describe("OrchestrationEngine", () => {
323342 const result = await runtime.runPromise(
324343 engine.dispatch({
325344 type: "thread.create",
326 commandId: "cmd-flaky-2",
327 threadId: "thread-flaky-ok",
328 projectId: "project-flaky",
345 commandId: asCommandId("cmd-flaky-2"),
346 threadId: asThreadId("thread-flaky-ok"),
347 projectId: asProjectId("project-flaky"),
329348 title: "flaky-ok",
330349 model: "gpt-5-codex",
331350 branch: null,
......@@ -347,10 +366,10 @@ describe("OrchestrationEngine", () => {
347366 system.run(
348367 engine.dispatch({
349368 type: "thread.turn.start",
350 commandId: "cmd-invariant-missing-thread",
351 threadId: "thread-missing",
369 commandId: asCommandId("cmd-invariant-missing-thread"),
370 threadId: asThreadId("thread-missing"),
352371 message: {
353 messageId: "msg-missing",
372 messageId: asMessageId("msg-missing"),
354373 role: "user",
355374 text: "hello",
356375 attachments: [],
......@@ -371,8 +390,8 @@ describe("OrchestrationEngine", () => {
371390 await system.run(
372391 engine.dispatch({
373392 type: "project.create",
374 commandId: "cmd-project-duplicate-create",
375 projectId: "project-duplicate",
393 commandId: asCommandId("cmd-project-duplicate-create"),
394 projectId: asProjectId("project-duplicate"),
376395 title: "Duplicate Project",
377396 workspaceRoot: "/tmp/project-duplicate",
378397 defaultModel: "gpt-5-codex",
......@@ -383,9 +402,9 @@ describe("OrchestrationEngine", () => {
383402 await system.run(
384403 engine.dispatch({
385404 type: "thread.create",
386 commandId: "cmd-thread-duplicate-1",
387 threadId: "thread-duplicate",
388 projectId: "project-duplicate",
405 commandId: asCommandId("cmd-thread-duplicate-1"),
406 threadId: asThreadId("thread-duplicate"),
407 projectId: asProjectId("project-duplicate"),
389408 title: "duplicate",
390409 model: "gpt-5-codex",
391410 branch: null,
......@@ -398,9 +417,9 @@ describe("OrchestrationEngine", () => {
398417 system.run(
399418 engine.dispatch({
400419 type: "thread.create",
401 commandId: "cmd-thread-duplicate-2",
402 threadId: "thread-duplicate",
403 projectId: "project-duplicate",
420 commandId: asCommandId("cmd-thread-duplicate-2"),
421 threadId: asThreadId("thread-duplicate"),
422 projectId: asProjectId("project-duplicate"),
404423 title: "duplicate",
405424 model: "gpt-5-codex",
406425 branch: null,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+3-1
......@@ -41,6 +41,8 @@ const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
4141const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
4242const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
4343const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
44const asProviderThreadId = (value: string): ProviderThreadId =>
45 ProviderThreadId.makeUnsafe(value);
4446const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4547
4648function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
......@@ -62,7 +64,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true) {
6264 sessionId: asSessionId("sess-1"),
6365 provider: "codex",
6466 status: "ready",
65 threadId: asThreadId("thread-1"),
67 threadId: asProviderThreadId("provider-thread-1"),
6668 cwd,
6769 createdAt: now,
6870 updatedAt: now,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+34-15
......@@ -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";
111import { assert, it } from "@effect/vitest";
212import { Effect, Layer } from "effect";
313import * as SqlClient from "effect/unstable/sql/SqlClient";
......@@ -7,6 +17,15 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
717import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
818import { 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
1029const projectionSnapshotLayer = it.layer(
1130 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
1231);
......@@ -185,7 +204,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
185204 assert.equal(snapshot.updatedAt, "2026-02-24T00:00:09.000Z");
186205 assert.deepEqual(snapshot.projects, [
187206 {
188 id: "project-1",
207 id: asProjectId("project-1"),
189208 title: "Project 1",
190209 workspaceRoot: "/tmp/project-1",
191210 defaultModel: "gpt-5-codex",
......@@ -205,22 +224,22 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
205224 ]);
206225 assert.deepEqual(snapshot.threads, [
207226 {
208 id: "thread-1",
209 projectId: "project-1",
227 id: asThreadId("thread-1"),
228 projectId: asProjectId("project-1"),
210229 title: "Thread 1",
211230 model: "gpt-5-codex",
212231 branch: null,
213232 worktreePath: null,
214 latestTurnId: "turn-1",
233 latestTurnId: asTurnId("turn-1"),
215234 createdAt: "2026-02-24T00:00:02.000Z",
216235 updatedAt: "2026-02-24T00:00:03.000Z",
217236 deletedAt: null,
218237 messages: [
219238 {
220 id: "message-1",
239 id: asMessageId("message-1"),
221240 role: "assistant",
222241 text: "hello from projection",
223 turnId: "turn-1",
242 turnId: asTurnId("turn-1"),
224243 streaming: false,
225244 createdAt: "2026-02-24T00:00:04.000Z",
226245 updatedAt: "2026-02-24T00:00:05.000Z",
......@@ -228,33 +247,33 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
228247 ],
229248 activities: [
230249 {
231 id: "activity-1",
250 id: asEventId("activity-1"),
232251 tone: "info",
233252 kind: "runtime.note",
234253 summary: "provider started",
235254 payload: { stage: "start" },
236 turnId: "turn-1",
255 turnId: asTurnId("turn-1"),
237256 createdAt: "2026-02-24T00:00:06.000Z",
238257 },
239258 ],
240259 checkpoints: [
241260 {
242 turnId: "turn-1",
261 turnId: asTurnId("turn-1"),
243262 checkpointTurnCount: 1,
244 checkpointRef: "checkpoint-1",
263 checkpointRef: asCheckpointRef("checkpoint-1"),
245264 status: "ready",
246265 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
247 assistantMessageId: "message-1",
266 assistantMessageId: asMessageId("message-1"),
248267 completedAt: "2026-02-24T00:00:08.000Z",
249268 },
250269 ],
251270 session: {
252 threadId: "thread-1",
271 threadId: asThreadId("thread-1"),
253272 status: "running",
254273 providerName: "codex",
255 providerSessionId: "provider-session-1",
256 providerThreadId: "provider-thread-1",
257 activeTurnId: "turn-1",
274 providerSessionId: asProviderSessionId("provider-session-1"),
275 providerThreadId: asProviderThreadId("provider-thread-1"),
276 activeTurnId: asTurnId("turn-1"),
258277 lastError: null,
259278 updatedAt: "2026-02-24T00:00:07.000Z",
260279 },
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+7-3
......@@ -2,11 +2,13 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts";
22import {
33 ApprovalRequestId,
44 CommandId,
5 MessageId,
56 ProjectId,
67 ProviderSessionId,
78 ProviderThreadId,
89 ProviderTurnId,
910 ThreadId,
11 TurnId,
1012} from "@t3tools/contracts";
1113import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
1214import { afterEach, describe, expect, it, vi } from "vitest";
......@@ -30,6 +32,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
3032const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
3133const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
3234const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
35const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
36const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
3337
3438async function waitFor(
3539 predicate: () => boolean,
......@@ -166,7 +170,7 @@ describe("ProviderCommandReactor", () => {
166170 commandId: asCommandId("cmd-turn-start-1"),
167171 threadId: asThreadId("thread-1"),
168172 message: {
169 messageId: "user-message-1",
173 messageId: asMessageId("user-message-1"),
170174 role: "user",
171175 text: "hello reactor",
172176 attachments: [],
......@@ -198,7 +202,7 @@ describe("ProviderCommandReactor", () => {
198202 providerName: "codex",
199203 providerSessionId: asSessionId("sess-1"),
200204 providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
201 activeTurnId: "turn-1",
205 activeTurnId: asTurnId("turn-1"),
202206 lastError: null,
203207 updatedAt: now,
204208 },
......@@ -211,7 +215,7 @@ describe("ProviderCommandReactor", () => {
211215 type: "thread.turn.interrupt",
212216 commandId: asCommandId("cmd-turn-interrupt"),
213217 threadId: asThreadId("thread-1"),
214 turnId: "turn-1",
218 turnId: asTurnId("turn-1"),
215219 createdAt: now,
216220 }),
217221 );
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1-1
......@@ -313,7 +313,7 @@ describe("ProviderRuntimeIngestion", () => {
313313 sessionId: asSessionId("sess-1"),
314314 createdAt: now,
315315 turnId: asProviderTurnId("turn-9"),
316 toolKind: "read",
316 toolKind: "other",
317317 title: "Read file",
318318 detail: "/tmp/file.ts",
319319 });
apps/server/src/orchestration/commandInvariants.test.ts+39-28
......@@ -1,5 +1,12 @@
11import { 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";
310import { Effect } from "effect";
411
512import {
......@@ -17,7 +24,7 @@ const readModel: OrchestrationReadModel = {
1724 updatedAt: now,
1825 projects: [
1926 {
20 id: "project-a",
27 id: ProjectId.makeUnsafe("project-a"),
2128 title: "Project A",
2229 workspaceRoot: "/tmp/project-a",
2330 defaultModel: "gpt-5-codex",
......@@ -27,7 +34,7 @@ const readModel: OrchestrationReadModel = {
2734 deletedAt: null,
2835 },
2936 {
30 id: "project-b",
37 id: ProjectId.makeUnsafe("project-b"),
3138 title: "Project B",
3239 workspaceRoot: "/tmp/project-b",
3340 defaultModel: "gpt-5-codex",
......@@ -39,8 +46,8 @@ const readModel: OrchestrationReadModel = {
3946 ],
4047 threads: [
4148 {
42 id: "thread-1",
43 projectId: "project-a",
49 id: ThreadId.makeUnsafe("thread-1"),
50 projectId: ProjectId.makeUnsafe("project-a"),
4451 title: "Thread A",
4552 model: "gpt-5-codex",
4653 branch: null,
......@@ -52,10 +59,11 @@ const readModel: OrchestrationReadModel = {
5259 session: null,
5360 activities: [],
5461 checkpoints: [],
62 deletedAt: null,
5563 },
5664 {
57 id: "thread-2",
58 projectId: "project-b",
65 id: ThreadId.makeUnsafe("thread-2"),
66 projectId: ProjectId.makeUnsafe("project-b"),
5967 title: "Thread B",
6068 model: "gpt-5-codex",
6169 branch: null,
......@@ -67,16 +75,17 @@ const readModel: OrchestrationReadModel = {
6775 session: null,
6876 activities: [],
6977 checkpoints: [],
78 deletedAt: null,
7079 },
7180 ],
7281};
7382
7483const messageSendCommand: OrchestrationCommand = {
7584 type: "thread.turn.start",
76 commandId: "cmd-1",
77 threadId: "thread-1",
85 commandId: CommandId.makeUnsafe("cmd-1"),
86 threadId: ThreadId.makeUnsafe("thread-1"),
7887 message: {
79 messageId: "msg-1",
88 messageId: MessageId.makeUnsafe("msg-1"),
8089 role: "user",
8190 text: "hello",
8291 attachments: [],
......@@ -86,11 +95,13 @@ const messageSendCommand: OrchestrationCommand = {
8695
8796describe("commandInvariants", () => {
8897 it("finds threads by id and project", () => {
89 expect(findThreadById(readModel, "thread-1")?.projectId).toBe("project-a");
90 expect(findThreadById(readModel, "missing")).toBeUndefined();
91 expect(listThreadsByProjectId(readModel, "project-b").map((thread) => thread.id)).toEqual([
92 "thread-2",
93 ]);
98 expect(findThreadById(readModel, ThreadId.makeUnsafe("thread-1"))?.projectId).toBe("project-a");
99 expect(findThreadById(readModel, ThreadId.makeUnsafe("missing"))).toBeUndefined();
100 expect(
101 listThreadsByProjectId(readModel, ProjectId.makeUnsafe("project-b")).map(
102 (thread) => thread.id,
103 ),
104 ).toEqual([ThreadId.makeUnsafe("thread-2")]);
94105 });
95106
96107 it("requires existing thread", async () => {
......@@ -98,17 +109,17 @@ describe("commandInvariants", () => {
98109 requireThread({
99110 readModel,
100111 command: messageSendCommand,
101 threadId: "thread-1",
112 threadId: ThreadId.makeUnsafe("thread-1"),
102113 }),
103114 );
104 expect(thread.id).toBe("thread-1");
115 expect(thread.id).toBe(ThreadId.makeUnsafe("thread-1"));
105116
106117 await expect(
107118 Effect.runPromise(
108119 requireThread({
109120 readModel,
110121 command: messageSendCommand,
111 threadId: "missing",
122 threadId: ThreadId.makeUnsafe("missing"),
112123 }),
113124 ),
114125 ).rejects.toThrow("does not exist");
......@@ -120,16 +131,16 @@ describe("commandInvariants", () => {
120131 readModel,
121132 command: {
122133 type: "thread.create",
123 commandId: "cmd-2",
124 threadId: "thread-3",
125 projectId: "project-a",
134 commandId: CommandId.makeUnsafe("cmd-2"),
135 threadId: ThreadId.makeUnsafe("thread-3"),
136 projectId: ProjectId.makeUnsafe("project-a"),
126137 title: "new",
127138 model: "gpt-5-codex",
128139 branch: null,
129140 worktreePath: null,
130141 createdAt: now,
131142 },
132 threadId: "thread-3",
143 threadId: ThreadId.makeUnsafe("thread-3"),
133144 }),
134145 );
135146
......@@ -139,16 +150,16 @@ describe("commandInvariants", () => {
139150 readModel,
140151 command: {
141152 type: "thread.create",
142 commandId: "cmd-3",
143 threadId: "thread-1",
144 projectId: "project-a",
153 commandId: CommandId.makeUnsafe("cmd-3"),
154 threadId: ThreadId.makeUnsafe("thread-1"),
155 projectId: ProjectId.makeUnsafe("project-a"),
145156 title: "dup",
146157 model: "gpt-5-codex",
147158 branch: null,
148159 worktreePath: null,
149160 createdAt: now,
150161 },
151 threadId: "thread-1",
162 threadId: ThreadId.makeUnsafe("thread-1"),
152163 }),
153164 ),
154165 ).rejects.toThrow("already exists");
......@@ -157,7 +168,7 @@ describe("commandInvariants", () => {
157168 it("requires non-negative integers", async () => {
158169 await Effect.runPromise(
159170 requireNonNegativeInteger({
160 commandType: "thread.revert",
171 commandType: "thread.checkpoint.revert",
161172 field: "turnCount",
162173 value: 0,
163174 }),
......@@ -166,7 +177,7 @@ describe("commandInvariants", () => {
166177 await expect(
167178 Effect.runPromise(
168179 requireNonNegativeInteger({
169 commandType: "thread.revert",
180 commandType: "thread.checkpoint.revert",
170181 field: "turnCount",
171182 value: -1,
172183 }),
apps/server/src/orchestration/decider.projectScripts.test.ts+33-26
......@@ -1,9 +1,16 @@
1import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
12import { describe, expect, it } from "vitest";
23import { Effect } from "effect";
34
45import { decideOrchestrationCommand } from "./decider.ts";
56import { 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
714describe("decider project scripts", () => {
815 it("emits empty scripts on project.create", async () => {
916 const now = new Date().toISOString();
......@@ -13,8 +20,8 @@ describe("decider project scripts", () => {
1320 decideOrchestrationCommand({
1421 command: {
1522 type: "project.create",
16 commandId: "cmd-project-create-scripts",
17 projectId: "project-scripts",
23 commandId: asCommandId("cmd-project-create-scripts"),
24 projectId: asProjectId("project-scripts"),
1825 title: "Scripts",
1926 workspaceRoot: "/tmp/scripts",
2027 createdAt: now,
......@@ -34,17 +41,17 @@ describe("decider project scripts", () => {
3441 const readModel = await Effect.runPromise(
3542 projectEvent(initial, {
3643 sequence: 1,
37 eventId: "evt-project-create-scripts",
38 aggregateType: "project",
39 aggregateId: "project-scripts",
44 eventId: asEventId("evt-project-create-scripts"),
45 aggregateKind: "project",
46 aggregateId: asProjectId("project-scripts"),
4047 type: "project.created",
4148 occurredAt: now,
42 commandId: "cmd-project-create-scripts",
49 commandId: asCommandId("cmd-project-create-scripts"),
4350 causationEventId: null,
44 correlationId: "cmd-project-create-scripts",
51 correlationId: asCommandId("cmd-project-create-scripts"),
4552 metadata: {},
4653 payload: {
47 projectId: "project-scripts",
54 projectId: asProjectId("project-scripts"),
4855 title: "Scripts",
4956 workspaceRoot: "/tmp/scripts",
5057 defaultModel: null,
......@@ -69,8 +76,8 @@ describe("decider project scripts", () => {
6976 decideOrchestrationCommand({
7077 command: {
7178 type: "project.meta.update",
72 commandId: "cmd-project-update-scripts",
73 projectId: "project-scripts",
79 commandId: asCommandId("cmd-project-update-scripts"),
80 projectId: asProjectId("project-scripts"),
7481 scripts: Array.from(scripts),
7582 },
7683 readModel,
......@@ -88,17 +95,17 @@ describe("decider project scripts", () => {
8895 const withProject = await Effect.runPromise(
8996 projectEvent(initial, {
9097 sequence: 1,
91 eventId: "evt-project-create",
92 aggregateType: "project",
93 aggregateId: "project-1",
98 eventId: asEventId("evt-project-create"),
99 aggregateKind: "project",
100 aggregateId: asProjectId("project-1"),
94101 type: "project.created",
95102 occurredAt: now,
96 commandId: "cmd-project-create",
103 commandId: asCommandId("cmd-project-create"),
97104 causationEventId: null,
98 correlationId: "cmd-project-create",
105 correlationId: asCommandId("cmd-project-create"),
99106 metadata: {},
100107 payload: {
101 projectId: "project-1",
108 projectId: asProjectId("project-1"),
102109 title: "Project",
103110 workspaceRoot: "/tmp/project",
104111 defaultModel: null,
......@@ -111,18 +118,18 @@ describe("decider project scripts", () => {
111118 const readModel = await Effect.runPromise(
112119 projectEvent(withProject, {
113120 sequence: 2,
114 eventId: "evt-thread-create",
115 aggregateType: "thread",
116 aggregateId: "thread-1",
121 eventId: asEventId("evt-thread-create"),
122 aggregateKind: "thread",
123 aggregateId: asThreadId("thread-1"),
117124 type: "thread.created",
118125 occurredAt: now,
119 commandId: "cmd-thread-create",
126 commandId: asCommandId("cmd-thread-create"),
120127 causationEventId: null,
121 correlationId: "cmd-thread-create",
128 correlationId: asCommandId("cmd-thread-create"),
122129 metadata: {},
123130 payload: {
124 threadId: "thread-1",
125 projectId: "project-1",
131 threadId: asThreadId("thread-1"),
132 projectId: asProjectId("project-1"),
126133 title: "Thread",
127134 model: "gpt-5-codex",
128135 branch: null,
......@@ -137,10 +144,10 @@ describe("decider project scripts", () => {
137144 decideOrchestrationCommand({
138145 command: {
139146 type: "thread.turn.start",
140 commandId: "cmd-turn-start",
141 threadId: "thread-1",
147 commandId: asCommandId("cmd-turn-start"),
148 threadId: asThreadId("thread-1"),
142149 message: {
143 messageId: "message-user-1",
150 messageId: asMessageId("message-user-1"),
144151 role: "user",
145152 text: "hello",
146153 attachments: [],
apps/server/src/orchestration/projector.test.ts+29-26
......@@ -1,4 +1,4 @@
1import type { OrchestrationEvent } from "@t3tools/contracts";
1import { CommandId, EventId, ProjectId, ThreadId, type OrchestrationEvent } from "@t3tools/contracts";
22import { Effect } from "effect";
33import { describe, expect, it } from "vitest";
44
......@@ -8,24 +8,27 @@ function makeEvent(input: {
88 sequence: number;
99 type: OrchestrationEvent["type"];
1010 occurredAt: string;
11 aggregateType: OrchestrationEvent["aggregateType"];
11 aggregateKind: OrchestrationEvent["aggregateKind"];
1212 aggregateId: string;
1313 commandId: string | null;
1414 payload: unknown;
1515}): OrchestrationEvent {
1616 return {
1717 sequence: input.sequence,
18 eventId: `event-${input.sequence}`,
18 eventId: EventId.makeUnsafe(`event-${input.sequence}`),
1919 type: input.type,
20 aggregateType: input.aggregateType,
21 aggregateId: input.aggregateId as OrchestrationEvent["aggregateId"],
20 aggregateKind: input.aggregateKind,
21 aggregateId:
22 input.aggregateKind === "project"
23 ? ProjectId.makeUnsafe(input.aggregateId)
24 : ThreadId.makeUnsafe(input.aggregateId),
2225 occurredAt: input.occurredAt,
23 commandId: input.commandId as OrchestrationEvent["commandId"],
26 commandId: input.commandId === null ? null : CommandId.makeUnsafe(input.commandId),
2427 causationEventId: null,
2528 correlationId: null,
2629 metadata: {},
27 payload: input.payload,
28 };
30 payload: input.payload as never,
31 } as OrchestrationEvent;
2932}
3033
3134describe("orchestration projector", () => {
......@@ -39,7 +42,7 @@ describe("orchestration projector", () => {
3942 makeEvent({
4043 sequence: 1,
4144 type: "thread.created",
42 aggregateType: "thread",
45 aggregateKind: "thread",
4346 aggregateId: "thread-1",
4447 occurredAt: now,
4548 commandId: "cmd-thread-create",
......@@ -89,7 +92,7 @@ describe("orchestration projector", () => {
8992 makeEvent({
9093 sequence: 1,
9194 type: "thread.created",
92 aggregateType: "thread",
95 aggregateKind: "thread",
9396 aggregateId: "thread-1",
9497 occurredAt: now,
9598 commandId: "cmd-invalid",
......@@ -119,7 +122,7 @@ describe("orchestration projector", () => {
119122 makeEvent({
120123 sequence: 7,
121124 type: "thread.turn-start-requested",
122 aggregateType: "thread",
125 aggregateKind: "thread",
123126 aggregateId: "thread-1",
124127 occurredAt: "2026-01-01T00:00:00.000Z",
125128 commandId: "cmd-unhandled",
......@@ -148,7 +151,7 @@ describe("orchestration projector", () => {
148151 makeEvent({
149152 sequence: 1,
150153 type: "thread.created",
151 aggregateType: "thread",
154 aggregateKind: "thread",
152155 aggregateId: "thread-1",
153156 occurredAt: createdAt,
154157 commandId: "cmd-create",
......@@ -172,7 +175,7 @@ describe("orchestration projector", () => {
172175 makeEvent({
173176 sequence: 2,
174177 type: "thread.session-set",
175 aggregateType: "thread",
178 aggregateKind: "thread",
176179 aggregateId: "thread-1",
177180 occurredAt: startedAt,
178181 commandId: "cmd-running",
......@@ -210,7 +213,7 @@ describe("orchestration projector", () => {
210213 makeEvent({
211214 sequence: 1,
212215 type: "thread.created",
213 aggregateType: "thread",
216 aggregateKind: "thread",
214217 aggregateId: "thread-1",
215218 occurredAt: createdAt,
216219 commandId: "cmd-create",
......@@ -234,7 +237,7 @@ describe("orchestration projector", () => {
234237 makeEvent({
235238 sequence: 2,
236239 type: "thread.message-sent",
237 aggregateType: "thread",
240 aggregateKind: "thread",
238241 aggregateId: "thread-1",
239242 occurredAt: deltaAt,
240243 commandId: "cmd-delta",
......@@ -258,7 +261,7 @@ describe("orchestration projector", () => {
258261 makeEvent({
259262 sequence: 3,
260263 type: "thread.message-sent",
261 aggregateType: "thread",
264 aggregateKind: "thread",
262265 aggregateId: "thread-1",
263266 occurredAt: completeAt,
264267 commandId: "cmd-complete",
......@@ -293,7 +296,7 @@ describe("orchestration projector", () => {
293296 makeEvent({
294297 sequence: 1,
295298 type: "thread.created",
296 aggregateType: "thread",
299 aggregateKind: "thread",
297300 aggregateId: "thread-1",
298301 occurredAt: createdAt,
299302 commandId: "cmd-create",
......@@ -315,7 +318,7 @@ describe("orchestration projector", () => {
315318 makeEvent({
316319 sequence: 2,
317320 type: "thread.message-sent",
318 aggregateType: "thread",
321 aggregateKind: "thread",
319322 aggregateId: "thread-1",
320323 occurredAt: "2026-02-23T10:00:01.000Z",
321324 commandId: "cmd-user-1",
......@@ -333,7 +336,7 @@ describe("orchestration projector", () => {
333336 makeEvent({
334337 sequence: 3,
335338 type: "thread.message-sent",
336 aggregateType: "thread",
339 aggregateKind: "thread",
337340 aggregateId: "thread-1",
338341 occurredAt: "2026-02-23T10:00:02.000Z",
339342 commandId: "cmd-assistant-1",
......@@ -351,7 +354,7 @@ describe("orchestration projector", () => {
351354 makeEvent({
352355 sequence: 4,
353356 type: "thread.turn-diff-completed",
354 aggregateType: "thread",
357 aggregateKind: "thread",
355358 aggregateId: "thread-1",
356359 occurredAt: "2026-02-23T10:00:02.500Z",
357360 commandId: "cmd-turn-1-complete",
......@@ -369,7 +372,7 @@ describe("orchestration projector", () => {
369372 makeEvent({
370373 sequence: 5,
371374 type: "thread.activity-appended",
372 aggregateType: "thread",
375 aggregateKind: "thread",
373376 aggregateId: "thread-1",
374377 occurredAt: "2026-02-23T10:00:02.750Z",
375378 commandId: "cmd-activity-1",
......@@ -389,7 +392,7 @@ describe("orchestration projector", () => {
389392 makeEvent({
390393 sequence: 6,
391394 type: "thread.message-sent",
392 aggregateType: "thread",
395 aggregateKind: "thread",
393396 aggregateId: "thread-1",
394397 occurredAt: "2026-02-23T10:00:03.000Z",
395398 commandId: "cmd-user-2",
......@@ -407,7 +410,7 @@ describe("orchestration projector", () => {
407410 makeEvent({
408411 sequence: 7,
409412 type: "thread.message-sent",
410 aggregateType: "thread",
413 aggregateKind: "thread",
411414 aggregateId: "thread-1",
412415 occurredAt: "2026-02-23T10:00:04.000Z",
413416 commandId: "cmd-assistant-2",
......@@ -425,7 +428,7 @@ describe("orchestration projector", () => {
425428 makeEvent({
426429 sequence: 8,
427430 type: "thread.turn-diff-completed",
428 aggregateType: "thread",
431 aggregateKind: "thread",
429432 aggregateId: "thread-1",
430433 occurredAt: "2026-02-23T10:00:04.500Z",
431434 commandId: "cmd-turn-2-complete",
......@@ -443,7 +446,7 @@ describe("orchestration projector", () => {
443446 makeEvent({
444447 sequence: 9,
445448 type: "thread.activity-appended",
446 aggregateType: "thread",
449 aggregateKind: "thread",
447450 aggregateId: "thread-1",
448451 occurredAt: "2026-02-23T10:00:04.750Z",
449452 commandId: "cmd-activity-2",
......@@ -463,7 +466,7 @@ describe("orchestration projector", () => {
463466 makeEvent({
464467 sequence: 10,
465468 type: "thread.reverted",
466 aggregateType: "thread",
469 aggregateKind: "thread",
467470 aggregateId: "thread-1",
468471 occurredAt: "2026-02-23T10:00:05.000Z",
469472 commandId: "cmd-revert",
apps/server/src/provider/Layers/CodexAdapter.test.ts+46-34
......@@ -1,10 +1,16 @@
1import type {
2 ProviderApprovalDecision,
3 ProviderEvent,
4 ProviderSendTurnInput,
5 ProviderSession,
6 ProviderSessionStartInput,
7 ProviderTurnStartResult,
1import {
2 ApprovalRequestId,
3 EventId,
4 ProviderItemId,
5 type ProviderApprovalDecision,
6 type ProviderEvent,
7 type ProviderSendTurnInput,
8 ProviderSessionId,
9 type ProviderSession,
10 type ProviderSessionStartInput,
11 ProviderThreadId,
12 ProviderTurnId,
13 type ProviderTurnStartResult,
814} from "@t3tools/contracts";
915import { afterAll, assert, it, vi } from "@effect/vitest";
1016import { assertFailure } from "@effect/vitest/utils";
......@@ -16,15 +22,21 @@ import { ProviderAdapterValidationError } from "../Errors.ts";
1622import { CodexAdapter } from "../Services/CodexAdapter.ts";
1723import { 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
1931class FakeCodexManager extends CodexAppServerManager {
2032 public startSessionImpl = vi.fn(
2133 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {
2234 const now = new Date().toISOString();
2335 return {
24 sessionId: "sess-1",
36 sessionId: asSessionId("sess-1"),
2537 provider: "codex",
2638 status: "ready",
27 threadId: "thread-1",
39 threadId: asThreadId("thread-1"),
2840 cwd: input.cwd,
2941 createdAt: now,
3042 updatedAt: now,
......@@ -34,29 +46,29 @@ class FakeCodexManager extends CodexAppServerManager {
3446
3547 public sendTurnImpl = vi.fn(
3648 async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({
37 threadId: "thread-1",
38 turnId: "turn-1",
49 threadId: asThreadId("thread-1"),
50 turnId: asTurnId("turn-1"),
3951 }),
4052 );
4153
4254 public interruptTurnImpl = vi.fn(
43 async (_sessionId: string, _turnId?: string): Promise<void> => undefined,
55 async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise<void> => undefined,
4456 );
4557
46 public readThreadImpl = vi.fn(async (_sessionId: string) => ({
47 threadId: "thread-1",
58 public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({
59 threadId: asThreadId("thread-1"),
4860 turns: [],
4961 }));
5062
51 public rollbackThreadImpl = vi.fn(async (_sessionId: string, _numTurns: number) => ({
52 threadId: "thread-1",
63 public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({
64 threadId: asThreadId("thread-1"),
5365 turns: [],
5466 }));
5567
5668 public respondToRequestImpl = vi.fn(
5769 async (
58 _sessionId: string,
59 _requestId: string,
70 _sessionId: ProviderSessionId,
71 _requestId: ApprovalRequestId,
6072 _decision: ProviderApprovalDecision,
6173 ): Promise<void> => undefined,
6274 );
......@@ -71,33 +83,33 @@ class FakeCodexManager extends CodexAppServerManager {
7183 return this.sendTurnImpl(input);
7284 }
7385
74 override interruptTurn(sessionId: string, turnId?: string): Promise<void> {
86 override interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> {
7587 return this.interruptTurnImpl(sessionId, turnId);
7688 }
7789
78 override readThread(sessionId: string) {
90 override readThread(sessionId: ProviderSessionId) {
7991 return this.readThreadImpl(sessionId);
8092 }
8193
82 override rollbackThread(sessionId: string, numTurns: number) {
94 override rollbackThread(sessionId: ProviderSessionId, numTurns: number) {
8395 return this.rollbackThreadImpl(sessionId, numTurns);
8496 }
8597
8698 override respondToRequest(
87 sessionId: string,
88 requestId: string,
99 sessionId: ProviderSessionId,
100 requestId: ApprovalRequestId,
89101 decision: ProviderApprovalDecision,
90102 ): Promise<void> {
91103 return this.respondToRequestImpl(sessionId, requestId, decision);
92104 }
93105
94 override stopSession(_sessionId: string): void {}
106 override stopSession(_sessionId: ProviderSessionId): void {}
95107
96108 override listSessions(): ProviderSession[] {
97109 return [];
98110 }
99111
100 override hasSession(_sessionId: string): boolean {
112 override hasSession(_sessionId: ProviderSessionId): boolean {
101113 return false;
102114 }
103115
......@@ -144,7 +156,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
144156 const adapter = yield* CodexAdapter;
145157 const result = yield* adapter
146158 .sendTurn({
147 sessionId: "sess-missing",
159 sessionId: asSessionId("sess-missing"),
148160 input: "hello",
149161 attachments: [],
150162 })
......@@ -176,15 +188,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
176188 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
177189
178190 const event: ProviderEvent = {
179 id: "evt-msg-complete",
191 id: asEventId("evt-msg-complete"),
180192 kind: "notification",
181193 provider: "codex",
182 sessionId: "sess-1",
194 sessionId: asSessionId("sess-1"),
183195 createdAt: new Date().toISOString(),
184196 method: "item/completed",
185 threadId: "thread-1",
186 turnId: "turn-1",
187 itemId: "msg_1",
197 threadId: asThreadId("thread-1"),
198 turnId: asTurnId("turn-1"),
199 itemId: asItemId("msg_1"),
188200 payload: {
189201 item: {
190202 type: "agentMessage",
......@@ -220,13 +232,13 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
220232 ).pipe(Effect.forkChild);
221233
222234 const event: ProviderEvent = {
223 id: "evt-1",
235 id: asEventId("evt-1"),
224236 kind: "notification",
225237 provider: "codex",
226 sessionId: "sess-1",
238 sessionId: asSessionId("sess-1"),
227239 createdAt: new Date().toISOString(),
228240 method: "turn/started",
229 turnId: "turn-1",
241 turnId: asTurnId("turn-1"),
230242 };
231243
232244 lifecycleManager.emit("event", event);
apps/server/src/provider/Layers/ProviderService.test.ts+22-36
......@@ -45,17 +45,6 @@ import {
4545 SqlitePersistenceMemory,
4646} 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
5948const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
6049const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
6150const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
......@@ -526,11 +515,18 @@ routing.layer("ProviderServiceLive routing", (it) => {
526515 const payload = runningRuntime.value.runtimePayload;
527516 assert.equal(payload !== null && typeof payload === "object", true);
528517 if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
529 assert.equal(payload.cwd, process.cwd());
530 assert.equal(payload.model, null);
531 assert.equal(payload.activeTurnId, "turn-1");
532 assert.equal(payload.lastError, null);
533 assert.equal(payload.lastRuntimeEvent, "provider.sendTurn");
518 const runtimePayload = payload as {
519 cwd: string;
520 model: string | null;
521 activeTurnId: string | null;
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");
534530 }
535531 }
536532
......@@ -669,32 +665,22 @@ validation.layer("ProviderServiceLive validation", (it) => {
669665 Effect.gen(function* () {
670666 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
684668 const failure = yield* Effect.result(
685669 provider.startSession({
686670 provider: "invalid-provider",
687671 } as never),
688672 );
689673
690 assertFailure(
691 failure,
692 new ProviderValidationError({
693 operation: "ProviderService.startSession",
694 issue: decodeIssueMessage(parseCause),
695 cause: parseCause,
696 }),
697 );
674 assert.equal(failure._tag, "Failure");
675 if (failure._tag !== "Failure") {
676 return;
677 }
678 assert.equal(failure.failure._tag, "ProviderValidationError");
679 if (failure.failure._tag !== "ProviderValidationError") {
680 return;
681 }
682 assert.equal(failure.failure.operation, "ProviderService.startSession");
683 assert.equal(failure.failure.issue.includes("invalid-provider"), true);
698684 }),
699685 );
700686
apps/server/src/wsServer.test.ts+80-17
......@@ -10,15 +10,20 @@ import WebSocket from "ws";
1010
1111import {
1212 DEFAULT_TERMINAL_ID,
13 EventId,
1314 ORCHESTRATION_WS_CHANNELS,
1415 ORCHESTRATION_WS_METHODS,
16 ProviderItemId,
17 ProviderSessionId,
18 ProviderThreadId,
19 ProviderTurnId,
1520 WS_CHANNELS,
1621 WS_METHODS,
22 type WebSocketResponse,
1723 type ProviderRuntimeEvent,
1824 type KeybindingsConfig,
1925 type ResolvedKeybindingsConfig,
2026 type WsPush,
21 type WsResponse,
2227} from "@t3tools/contracts";
2328import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
2429import type {
......@@ -34,6 +39,7 @@ import type { TerminalManager } from "./terminalManager";
3439import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite";
3540import { SqlClient } from "effect/unstable/sql";
3641import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
42import type { OpenShape } from "./open";
3743
3844interface PendingMessages {
3945 queue: unknown[];
......@@ -42,6 +48,12 @@ interface PendingMessages {
4248
4349const 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
4557class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> {
4658 private readonly sessions = new Map<string, TerminalSessionSnapshot>();
4759
......@@ -187,7 +199,19 @@ function waitForMessage(ws: WebSocket): Promise<unknown> {
187199 });
188200}
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> {
191215 const id = crypto.randomUUID();
192216 const body =
193217 method === ORCHESTRATION_WS_METHODS.dispatchCommand
......@@ -200,12 +224,15 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro
200224
201225 // Wait for response with matching id
202226 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 }
204231 if (parsed.id === id) {
205 return parsed as WsResponse;
232 return parsed;
206233 }
207234 if (parsed.id === "unknown") {
208 return parsed as WsResponse;
235 return parsed;
209236 }
210237 }
211238}
......@@ -233,7 +260,7 @@ async function waitForPush(
233260}
234261
235262function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
236 const resolved: ResolvedKeybindingsConfig = [];
263 const resolved: Array<ResolvedKeybindingsConfig[number]> = [];
237264 for (const binding of bindings) {
238265 const compiled = compileResolvedKeybindingRule(binding);
239266 if (!compiled) {
......@@ -278,6 +305,7 @@ describe("WebSocket Server", () => {
278305 authToken?: string;
279306 stateDir?: string;
280307 providerLayer?: Layer.Layer<ProviderService, unknown>;
308 open?: OpenShape;
281309 gitManager?: {
282310 status: (input: { cwd: string }) => Promise<unknown>;
283311 runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>;
......@@ -297,6 +325,7 @@ describe("WebSocket Server", () => {
297325 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
298326 ...(options.authToken ? { authToken: options.authToken } : {}),
299327 ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}),
328 ...(options.open ? { open: options.open } : {}),
300329 ...(options.gitManager ? { gitManager: options.gitManager as never } : {}),
301330 ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}),
302331 });
......@@ -420,6 +449,33 @@ describe("WebSocket Server", () => {
420449 });
421450 });
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
423479 it("reads keybindings from ~/.t3/keybindings.json", async () => {
424480 const fakeHome = makeTempDir("t3code-home-");
425481 const configDir = path.join(fakeHome, ".t3");
......@@ -787,7 +843,7 @@ describe("WebSocket Server", () => {
787843
788844 it("keeps orchestration domain push behavior for provider runtime events", async () => {
789845 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
790 const sessionId = "sess-test";
846 const sessionId = asProviderSessionId("sess-test");
791847 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
792848 Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
793849 };
......@@ -798,11 +854,15 @@ describe("WebSocket Server", () => {
798854 sessionId,
799855 provider: "codex",
800856 status: "ready",
801 threadId: "provider-thread-1",
857 threadId: asProviderThreadId("provider-thread-1"),
802858 createdAt: new Date().toISOString(),
803859 updatedAt: new Date().toISOString(),
804860 }),
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 }),
806866 interruptTurn: () => unsupported(),
807867 respondToRequest: () => unsupported(),
808868 stopSession: () => unsupported(),
......@@ -870,12 +930,12 @@ describe("WebSocket Server", () => {
870930
871931 emitRuntimeEvent({
872932 type: "message.delta",
873 eventId: "evt-ws-runtime-message-delta",
933 eventId: asEventId("evt-ws-runtime-message-delta"),
874934 provider: "codex",
875935 sessionId,
876936 createdAt: new Date().toISOString(),
877 turnId: "turn-1",
878 itemId: "item-1",
937 turnId: asProviderTurnId("turn-1"),
938 itemId: asProviderItemId("item-1"),
879939 delta: "hello from runtime",
880940 });
881941
......@@ -1015,15 +1075,18 @@ describe("WebSocket Server", () => {
10151075 // Send garbage
10161076 ws.send("not json at all");
10171077
1018 let response: WsResponse | null = null;
1078 let response: WebSocketResponse | null = null;
10191079 for (let attempt = 0; attempt < 5; attempt += 1) {
1020 const message = (await waitForMessage(ws)) as Record<string, unknown>;
1021 if (typeof message.id === "string" && message.id === "unknown") {
1022 response = message as WsResponse;
1080 const message = asWebSocketResponse(await waitForMessage(ws));
1081 if (!message) {
1082 continue;
1083 }
1084 if (message.id === "unknown") {
1085 response = message;
10231086 break;
10241087 }
10251088 if (message.error) {
1026 response = message as WsResponse;
1089 response = message;
10271090 break;
10281091 }
10291092 }
apps/server/src/wsServer.ts+28-48
......@@ -1,4 +1,3 @@
1import { spawn } from "node:child_process";
21import fs from "node:fs";
32import http from "node:http";
43import os from "node:os";
......@@ -7,7 +6,6 @@ import type { Duplex } from "node:stream";
76
87import {
98 CheckpointRef,
10 EDITORS,
119 ORCHESTRATION_WS_CHANNELS,
1210 ORCHESTRATION_WS_METHODS,
1311 TerminalEvent,
......@@ -18,7 +16,7 @@ import {
1816 WsResponse,
1917} from "@t3tools/contracts";
2018import { 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";
2220import { WebSocketServer, type WebSocket } from "ws";
2321
2422import { createLogger } from "./logger";
......@@ -62,12 +60,16 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
6260import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
6361import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
6462import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
65import { CheckpointStore, type CheckpointStoreShape } from "./checkpointing/Services/CheckpointStore";
63import {
64 CheckpointStore,
65 type CheckpointStoreShape,
66} from "./checkpointing/Services/CheckpointStore";
6667import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
6768import * as SqlClient from "effect/unstable/sql/SqlClient";
6869import * as SqlError from "effect/unstable/sql/SqlError";
6970import * as Migrator from "effect/unstable/sql/Migrator";
7071import { clamp } from "effect/Number";
72import { OpenDefaultService, type OpenShape } from "./open";
7173
7274const MIME_TYPES: Record<string, string> = {
7375 ".html": "text/html; charset=utf-8",
......@@ -100,8 +102,25 @@ export interface ServerOptions {
100102 terminalManager?: TerminalManager | undefined;
101103 providerLayer?: Layer.Layer<ProviderService, unknown> | undefined;
102104 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;
103112}
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
105124const isServerNotRunningError = (error: unknown): boolean => {
106125 if (!(error instanceof Error)) return false;
107126 const maybeCode = (error as NodeJS.ErrnoException).code;
......@@ -162,9 +181,7 @@ function websocketRawToString(raw: unknown): string | null {
162181 return null;
163182}
164183
165function stripRequestTag<T extends { _tag: string }>(
166 body: T,
167): Omit<T, "_tag"> {
184function stripRequestTag<T extends { _tag: string }>(body: T): Omit<T, "_tag"> {
168185 const { _tag: _ignoredTag, ...rest } = body;
169186 return rest;
170187}
......@@ -185,7 +202,7 @@ type EffectRuntime = ManagedRuntime.ManagedRuntime<
185202 unknown
186203>;
187204
188export function createServer(options: ServerOptions) {
205export function createServer(options: ServerOptions): ServerRuntime {
189206 const {
190207 port,
191208 host,
......@@ -200,6 +217,7 @@ export function createServer(options: ServerOptions) {
200217 terminalManager = new TerminalManager(),
201218 providerLayer: customProviderLayer,
202219 authToken,
220 open: openService = OpenDefaultService,
203221 } = options;
204222
205223 const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata");
......@@ -626,42 +644,7 @@ export function createServer(options: ServerOptions) {
626644 return searchWorkspaceEntries(stripRequestTag(request.body));
627645
628646 case WS_METHODS.shellOpenInEditor: {
629 const body = 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();
647 await Effect.runPromise(openService.openInEditor(stripRequestTag(request.body)));
665648 return undefined;
666649 }
667650
......@@ -763,10 +746,7 @@ export function createServer(options: ServerOptions) {
763746 );
764747 return makeProviderServiceLive({
765748 canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"),
766 }).pipe(
767 Layer.provide(adapterRegistryLayer),
768 Layer.provide(providerSessionDirectoryLayer),
769 );
749 }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer));
770750 })();
771751
772752 const runtimeServicesLayer = Layer.mergeAll(
apps/server/tsconfig.json+1-2
......@@ -12,6 +12,5 @@
1212 }
1313 ]
1414 },
15 "include": ["src", "tsdown.config.ts", "scripts", "integration"],
16 "exclude": ["src/**/*.test.ts"]
15 "include": ["src", "tsdown.config.ts", "scripts", "integration"]
1716}
packages/contracts/src/git.ts+1-1
......@@ -68,7 +68,7 @@ export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>
6868)({
6969 cwd: TrimmedNonEmptyString,
7070 path: TrimmedNonEmptyString,
71 force: Schema.Boolean.pipe(Schema.NullOr),
71 force: Schema.optional(Schema.Boolean),
7272}) {}
7373
7474export 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.
1212 Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>;
1313
1414const decodeResolvedRuleStrict = (input: unknown) =>
15 Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input, {
16 onExcessProperty: "error",
17 });
15 Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input);
1816
1917describe("keybindings contracts", () => {
2018 it("parses keybinding rules", () => {
packages/contracts/src/keybindings.ts+8-5
......@@ -70,11 +70,14 @@ export type KeybindingWhenNode =
7070
7171export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>(
7272 "ResolvedKeybindingRule",
73)({
74 command: KeybindingCommand,
75 shortcut: KeybindingShortcut,
76 whenAst: Schema.optional(KeybindingWhenNode),
77}) {}
73)(
74 {
75 command: KeybindingCommand,
76 shortcut: KeybindingShortcut,
77 whenAst: Schema.optional(KeybindingWhenNode),
78 },
79 { parseOptions: { onExcessProperty: "error" } },
80) {}
7881
7982export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(
8083 Schema.isMaxLength(256),
scripts/dev-runner.mjs+9
......@@ -84,6 +84,15 @@ function main() {
8484 VITE_WS_URL: `ws://localhost:${serverPort}`,
8585 VITE_DEV_SERVER_URL: `http://localhost:${webPort}`,
8686 };
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
8796 if (mode === "dev" && !env.T3CODE_LOG_WS_EVENTS) {
8897 env.T3CODE_LOG_WS_EVENTS = "1";
8998 }