| author | |
| committer | |
| log | e63d5b240f908ec3cf69b49ac7488b33dc5231c0 |
| tree | 9a94782305a744718f9ddb192be5d5f0b97aaaaa |
| parent | 877ab769345a13e90509c564b62ef3f50f480607 |
23 files changed, 952 insertions(+), 486 deletions(-)
apps/server/src/codexAppServerManager.test.ts+9-6| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { describe, expect, it, vi } from "vitest"; |
| 2 | import { ProviderSessionId } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | CodexAppServerManager, |
| ... | ... | @@ -7,6 +8,8 @@ import { |
| 7 | 8 | normalizeCodexModelSlug, |
| 8 | 9 | } from "./codexAppServerManager"; |
| 9 | 10 | |
| 11 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 12 | ||
| 10 | 13 | function createSendTurnHarness() { |
| 11 | 14 | const manager = new CodexAppServerManager(); |
| 12 | 15 | const context = { |
| ... | ... | @@ -183,7 +186,7 @@ describe("sendTurn", () => { |
| 183 | 186 | createSendTurnHarness(); |
| 184 | 187 | |
| 185 | 188 | const result = await manager.sendTurn({ |
| 186 | sessionId: "sess_1", | |
| 189 | sessionId: asSessionId("sess_1"), | |
| 187 | 190 | input: "Inspect this image", |
| 188 | 191 | attachments: [ |
| 189 | 192 | { |
| ... | ... | @@ -229,7 +232,7 @@ describe("sendTurn", () => { |
| 229 | 232 | const { manager, context, sendRequest } = createSendTurnHarness(); |
| 230 | 233 | |
| 231 | 234 | await manager.sendTurn({ |
| 232 | sessionId: "sess_1", | |
| 235 | sessionId: asSessionId("sess_1"), | |
| 233 | 236 | attachments: [ |
| 234 | 237 | { |
| 235 | 238 | type: "image", |
| ... | ... | @@ -257,7 +260,7 @@ describe("sendTurn", () => { |
| 257 | 260 | |
| 258 | 261 | await expect( |
| 259 | 262 | manager.sendTurn({ |
| 260 | sessionId: "sess_1", | |
| 263 | sessionId: asSessionId("sess_1"), | |
| 261 | 264 | }), |
| 262 | 265 | ).rejects.toThrow("Turn input must include text or attachments."); |
| 263 | 266 | }); |
| ... | ... | @@ -278,7 +281,7 @@ describe("thread checkpoint control", () => { |
| 278 | 281 | }, |
| 279 | 282 | }); |
| 280 | 283 | |
| 281 | const result = await manager.readThread("sess_1"); | |
| 284 | const result = await manager.readThread(asSessionId("sess_1")); | |
| 282 | 285 | |
| 283 | 286 | expect(requireSession).toHaveBeenCalledWith("sess_1"); |
| 284 | 287 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { |
| ... | ... | @@ -308,7 +311,7 @@ describe("thread checkpoint control", () => { |
| 308 | 311 | ], |
| 309 | 312 | }); |
| 310 | 313 | |
| 311 | const result = await manager.readThread("sess_1"); | |
| 314 | const result = await manager.readThread(asSessionId("sess_1")); | |
| 312 | 315 | |
| 313 | 316 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { |
| 314 | 317 | threadId: "thread_1", |
| ... | ... | @@ -334,7 +337,7 @@ describe("thread checkpoint control", () => { |
| 334 | 337 | }, |
| 335 | 338 | }); |
| 336 | 339 | |
| 337 | const result = await manager.rollbackThread("sess_1", 2); | |
| 340 | const result = await manager.rollbackThread(asSessionId("sess_1"), 2); | |
| 338 | 341 | |
| 339 | 342 | expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { |
| 340 | 343 | threadId: "thread_1", |
apps/server/src/index.ts+17-177| ... | ... | @@ -1,177 +1,17 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import net from "node:net"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { fileURLToPath } from "node:url"; | |
| 6 | ||
| 7 | import { fixPath } from "./fixPath"; | |
| 8 | import { createLogger } from "./logger"; | |
| 9 | import { createServer } from "./wsServer"; | |
| 10 | ||
| 11 | fixPath(); | |
| 12 | ||
| 13 | const DEFAULT_PORT = 3773; | |
| 14 | // Dummy comment for a no-op change request. | |
| 15 | // Additional dummy comment requested in chat. | |
| 16 | const cwd = process.cwd(); | |
| 17 | const logger = createLogger("server"); | |
| 18 | ||
| 19 | type RuntimeMode = "web" | "desktop"; | |
| 20 | ||
| 21 | function 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 | ||
| 29 | function 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 | ||
| 38 | function 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 | ||
| 46 | function 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 | ||
| 53 | async 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 | ||
| 75 | function 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 | ||
| 100 | async 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 | ||
| 174 | main().catch((err) => { | |
| 175 | logger.error("failed to start T3 Code", { error: err }); | |
| 176 | process.exit(1); | |
| 177 | }); | |
| 1 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | ||
| 6 | import { CliConfigLive, makeCliCommand } from "./main"; | |
| 7 | import { OpenLive } from "./open"; | |
| 8 | import { Command } from "effect/unstable/cli"; | |
| 9 | import { version } from "../package.json" with { type: "json" }; | |
| 10 | import { ServerLive } from "./wsServer"; | |
| 11 | ||
| 12 | const RuntimeLayer = Layer.mergeAll(CliConfigLive, ServerLive, OpenLive, NodeServices.layer); | |
| 13 | ||
| 14 | Command.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( |
| 326 | 326 | const sanitized: Mutable<KeybindingsConfig> = []; |
| 327 | 327 | let invalidEntries = 0; |
| 328 | 328 | for (const entry of parsed) { |
| 329 | const result = Schema.decodeUnknownExit(KeybindingRule)(entry); | |
| 329 | const result = Schema.decodeExit(KeybindingRule)(entry); | |
| 330 | 330 | if (result._tag === "Success") { |
| 331 | 331 | if (!compileResolvedKeybindingRule(result.value)) { |
| 332 | 332 | invalidEntries += 1; |
apps/server/src/main.test.ts created+131| ... | ... | @@ -0,0 +1,131 @@ |
| 1 | import { NodeServices } from "@effect/platform-node"; | |
| 2 | import { assert, it, vi } from "@effect/vitest"; | |
| 3 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Command from "effect/unstable/cli/Command"; | |
| 7 | import { beforeEach } from "vitest"; | |
| 8 | ||
| 9 | import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; | |
| 10 | import { Open, type OpenShape } from "./open"; | |
| 11 | import { Server, type ServerShape, type ServerOptions } from "./wsServer"; | |
| 12 | ||
| 13 | const start = vi.fn(async () => undefined); | |
| 14 | const stop = vi.fn(async () => undefined); | |
| 15 | const createServer = vi.fn((_: ServerOptions) => ({ | |
| 16 | start, | |
| 17 | stop, | |
| 18 | httpServer: undefined as never, | |
| 19 | })); | |
| 20 | const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)); | |
| 21 | ||
| 22 | const 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 | ||
| 39 | const 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 | ||
| 47 | beforeEach(() => { | |
| 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 | ||
| 59 | it.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 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import net from "node:net"; | |
| 3 | import os from "node:os"; | |
| 4 | import path from "node:path"; | |
| 5 | import { fileURLToPath } from "node:url"; | |
| 6 | ||
| 7 | import * as Data from "effect/Data"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Config from "effect/Config"; | |
| 12 | import { ServiceMap } from "effect"; | |
| 13 | import * as Command from "effect/unstable/cli/Command"; | |
| 14 | import * as Flag from "effect/unstable/cli/Flag"; | |
| 15 | ||
| 16 | import { fixPath } from "./fixPath"; | |
| 17 | import { Open } from "./open"; | |
| 18 | import { Server } from "./wsServer"; | |
| 19 | ||
| 20 | const DEFAULT_PORT = 3773; | |
| 21 | ||
| 22 | type RuntimeMode = "web" | "desktop"; | |
| 23 | ||
| 24 | export class StartupError extends Data.TaggedError("StartupError")<{ | |
| 25 | readonly message: string; | |
| 26 | readonly cause?: unknown; | |
| 27 | }> {} | |
| 28 | ||
| 29 | interface CliInput { | |
| 30 | readonly port: Option.Option<number>; | |
| 31 | readonly token: Option.Option<string>; | |
| 32 | } | |
| 33 | ||
| 34 | interface 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 | ||
| 45 | export 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 | ||
| 52 | export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( | |
| 53 | "server/CliConfig", | |
| 54 | ) {} | |
| 55 | ||
| 56 | const 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 | ||
| 82 | function 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 | ||
| 93 | function 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 | ||
| 101 | function 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 | ||
| 108 | async 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 | ||
| 129 | function 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 | ||
| 151 | function 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 | ||
| 193 | const 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 | ||
| 252 | export 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 | ||
| 271 | export 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 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | ||
| 3 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | |
| 4 | import { ServiceMap, Schema, Effect, Layer } from "effect"; | |
| 5 | ||
| 6 | export class OpenError extends Schema.TaggedErrorClass<OpenError>()("OpenError", { | |
| 7 | message: Schema.String, | |
| 8 | cause: Schema.optional(Schema.Defect), | |
| 9 | }) {} | |
| 10 | ||
| 11 | export interface OpenInEditorInput { | |
| 12 | readonly cwd: string; | |
| 13 | readonly editor: EditorId; | |
| 14 | } | |
| 15 | ||
| 16 | interface EditorLaunch { | |
| 17 | readonly command: string; | |
| 18 | readonly args: ReadonlyArray<string>; | |
| 19 | } | |
| 20 | ||
| 21 | export interface OpenShape { | |
| 22 | readonly openBrowser: (target: string) => Effect.Effect<void, OpenError>; | |
| 23 | readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect<void, OpenError>; | |
| 24 | } | |
| 25 | ||
| 26 | export class Open extends ServiceMap.Service<Open, OpenShape>()("server/Open") {} | |
| 27 | ||
| 28 | export 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 | ||
| 55 | const 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 | ||
| 91 | export const OpenLive = Layer.succeed(Open, OpenDefault); | |
| 92 | ||
| 93 | export const OpenDefaultService = OpenDefault; |
apps/server/src/orchestration/Layer.test.ts+75-56| ... | ... | @@ -1,4 +1,12 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | CommandId, | |
| 4 | MessageId, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | TurnId, | |
| 8 | type OrchestrationEvent, | |
| 9 | } from "@t3tools/contracts"; | |
| 2 | 10 | import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; |
| 3 | 11 | import { describe, expect, it } from "vitest"; |
| 4 | 12 | |
| ... | ... | @@ -14,6 +22,13 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; |
| 14 | 22 | import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; |
| 15 | 23 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; |
| 16 | 24 | |
| 25 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 26 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 27 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 28 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 29 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 30 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | |
| 31 | ||
| 17 | 32 | async function createOrchestrationSystem() { |
| 18 | 33 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 19 | 34 | Layer.provide(OrchestrationProjectionPipelineLive), |
| ... | ... | @@ -43,8 +58,8 @@ describe("OrchestrationEngine", () => { |
| 43 | 58 | await system.run( |
| 44 | 59 | engine.dispatch({ |
| 45 | 60 | 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"), | |
| 48 | 63 | title: "Project 1", |
| 49 | 64 | workspaceRoot: "/tmp/project-1", |
| 50 | 65 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -54,9 +69,9 @@ describe("OrchestrationEngine", () => { |
| 54 | 69 | await system.run( |
| 55 | 70 | engine.dispatch({ |
| 56 | 71 | 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"), | |
| 60 | 75 | title: "Thread", |
| 61 | 76 | model: "gpt-5-codex", |
| 62 | 77 | branch: null, |
| ... | ... | @@ -67,10 +82,10 @@ describe("OrchestrationEngine", () => { |
| 67 | 82 | await system.run( |
| 68 | 83 | engine.dispatch({ |
| 69 | 84 | 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"), | |
| 72 | 87 | message: { |
| 73 | messageId: "msg-1", | |
| 88 | messageId: asMessageId("msg-1"), | |
| 74 | 89 | role: "user", |
| 75 | 90 | text: "hello", |
| 76 | 91 | attachments: [], |
| ... | ... | @@ -93,8 +108,8 @@ describe("OrchestrationEngine", () => { |
| 93 | 108 | await system.run( |
| 94 | 109 | engine.dispatch({ |
| 95 | 110 | 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"), | |
| 98 | 113 | title: "Replay Project", |
| 99 | 114 | workspaceRoot: "/tmp/project-replay", |
| 100 | 115 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -104,9 +119,9 @@ describe("OrchestrationEngine", () => { |
| 104 | 119 | await system.run( |
| 105 | 120 | engine.dispatch({ |
| 106 | 121 | 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"), | |
| 110 | 125 | title: "replay", |
| 111 | 126 | model: "gpt-5-codex", |
| 112 | 127 | branch: null, |
| ... | ... | @@ -117,8 +132,8 @@ describe("OrchestrationEngine", () => { |
| 117 | 132 | await system.run( |
| 118 | 133 | engine.dispatch({ |
| 119 | 134 | 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"), | |
| 122 | 137 | }), |
| 123 | 138 | ); |
| 124 | 139 | |
| ... | ... | @@ -143,8 +158,8 @@ describe("OrchestrationEngine", () => { |
| 143 | 158 | await system.run( |
| 144 | 159 | engine.dispatch({ |
| 145 | 160 | 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"), | |
| 148 | 163 | title: "Stream Project", |
| 149 | 164 | workspaceRoot: "/tmp/project-stream", |
| 150 | 165 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -164,9 +179,9 @@ describe("OrchestrationEngine", () => { |
| 164 | 179 | yield* Effect.sleep("10 millis"); |
| 165 | 180 | yield* engine.dispatch({ |
| 166 | 181 | 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"), | |
| 170 | 185 | title: "domain-stream", |
| 171 | 186 | model: "gpt-5-codex", |
| 172 | 187 | branch: null, |
| ... | ... | @@ -175,8 +190,8 @@ describe("OrchestrationEngine", () => { |
| 175 | 190 | }); |
| 176 | 191 | yield* engine.dispatch({ |
| 177 | 192 | 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"), | |
| 180 | 195 | title: "domain-stream-updated", |
| 181 | 196 | }); |
| 182 | 197 | eventTypes.push((yield* Queue.take(eventQueue)).type); |
| ... | ... | @@ -196,8 +211,8 @@ describe("OrchestrationEngine", () => { |
| 196 | 211 | await system.run( |
| 197 | 212 | engine.dispatch({ |
| 198 | 213 | 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"), | |
| 201 | 216 | title: "Turn Diff Project", |
| 202 | 217 | workspaceRoot: "/tmp/project-turn-diff", |
| 203 | 218 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -207,9 +222,9 @@ describe("OrchestrationEngine", () => { |
| 207 | 222 | await system.run( |
| 208 | 223 | engine.dispatch({ |
| 209 | 224 | 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"), | |
| 213 | 228 | title: "Turn diff thread", |
| 214 | 229 | model: "gpt-5-codex", |
| 215 | 230 | branch: null, |
| ... | ... | @@ -220,11 +235,11 @@ describe("OrchestrationEngine", () => { |
| 220 | 235 | await system.run( |
| 221 | 236 | engine.dispatch({ |
| 222 | 237 | 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"), | |
| 226 | 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 | 243 | status: "ready", |
| 229 | 244 | files: [], |
| 230 | 245 | checkpointTurnCount: 1, |
| ... | ... | @@ -237,9 +252,9 @@ describe("OrchestrationEngine", () => { |
| 237 | 252 | ); |
| 238 | 253 | expect(thread?.checkpoints).toEqual([ |
| 239 | 254 | { |
| 240 | turnId: "turn-1", | |
| 255 | turnId: asTurnId("turn-1"), | |
| 241 | 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 | 258 | status: "ready", |
| 244 | 259 | files: [], |
| 245 | 260 | assistantMessageId: null, |
| ... | ... | @@ -250,13 +265,17 @@ describe("OrchestrationEngine", () => { |
| 250 | 265 | }); |
| 251 | 266 | |
| 252 | 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 | 273 | let nextSequence = 1; |
| 255 | 274 | let shouldFailFirstAppend = true; |
| 256 | 275 | |
| 257 | 276 | const flakyStore: OrchestrationEventStoreShape = { |
| 258 | 277 | append(event) { |
| 259 | if (shouldFailFirstAppend && event.commandId === "cmd-flaky-1") { | |
| 278 | if (shouldFailFirstAppend && event.commandId === asCommandId("cmd-flaky-1")) { | |
| 260 | 279 | shouldFailFirstAppend = false; |
| 261 | 280 | return Effect.fail( |
| 262 | 281 | new PersistenceSqlError({ |
| ... | ... | @@ -268,7 +287,7 @@ describe("OrchestrationEngine", () => { |
| 268 | 287 | const savedEvent = { |
| 269 | 288 | ...event, |
| 270 | 289 | sequence: nextSequence, |
| 271 | } satisfies OrchestrationEvent; | |
| 290 | } as StoredEvent; | |
| 272 | 291 | nextSequence += 1; |
| 273 | 292 | events.push(savedEvent); |
| 274 | 293 | return Effect.succeed(savedEvent); |
| ... | ... | @@ -295,8 +314,8 @@ describe("OrchestrationEngine", () => { |
| 295 | 314 | await runtime.runPromise( |
| 296 | 315 | engine.dispatch({ |
| 297 | 316 | 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"), | |
| 300 | 319 | title: "Flaky Project", |
| 301 | 320 | workspaceRoot: "/tmp/project-flaky", |
| 302 | 321 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -308,9 +327,9 @@ describe("OrchestrationEngine", () => { |
| 308 | 327 | runtime.runPromise( |
| 309 | 328 | engine.dispatch({ |
| 310 | 329 | 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"), | |
| 314 | 333 | title: "flaky-fail", |
| 315 | 334 | model: "gpt-5-codex", |
| 316 | 335 | branch: null, |
| ... | ... | @@ -323,9 +342,9 @@ describe("OrchestrationEngine", () => { |
| 323 | 342 | const result = await runtime.runPromise( |
| 324 | 343 | engine.dispatch({ |
| 325 | 344 | 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"), | |
| 329 | 348 | title: "flaky-ok", |
| 330 | 349 | model: "gpt-5-codex", |
| 331 | 350 | branch: null, |
| ... | ... | @@ -347,10 +366,10 @@ describe("OrchestrationEngine", () => { |
| 347 | 366 | system.run( |
| 348 | 367 | engine.dispatch({ |
| 349 | 368 | 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"), | |
| 352 | 371 | message: { |
| 353 | messageId: "msg-missing", | |
| 372 | messageId: asMessageId("msg-missing"), | |
| 354 | 373 | role: "user", |
| 355 | 374 | text: "hello", |
| 356 | 375 | attachments: [], |
| ... | ... | @@ -371,8 +390,8 @@ describe("OrchestrationEngine", () => { |
| 371 | 390 | await system.run( |
| 372 | 391 | engine.dispatch({ |
| 373 | 392 | 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"), | |
| 376 | 395 | title: "Duplicate Project", |
| 377 | 396 | workspaceRoot: "/tmp/project-duplicate", |
| 378 | 397 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -383,9 +402,9 @@ describe("OrchestrationEngine", () => { |
| 383 | 402 | await system.run( |
| 384 | 403 | engine.dispatch({ |
| 385 | 404 | 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"), | |
| 389 | 408 | title: "duplicate", |
| 390 | 409 | model: "gpt-5-codex", |
| 391 | 410 | branch: null, |
| ... | ... | @@ -398,9 +417,9 @@ describe("OrchestrationEngine", () => { |
| 398 | 417 | system.run( |
| 399 | 418 | engine.dispatch({ |
| 400 | 419 | 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"), | |
| 404 | 423 | title: "duplicate", |
| 405 | 424 | model: "gpt-5-codex", |
| 406 | 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 | 41 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); |
| 42 | 42 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); |
| 43 | 43 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 44 | const asProviderThreadId = (value: string): ProviderThreadId => | |
| 45 | ProviderThreadId.makeUnsafe(value); | |
| 44 | 46 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 45 | 47 | |
| 46 | 48 | function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef { |
| ... | ... | @@ -62,7 +64,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true) { |
| 62 | 64 | sessionId: asSessionId("sess-1"), |
| 63 | 65 | provider: "codex", |
| 64 | 66 | status: "ready", |
| 65 | threadId: asThreadId("thread-1"), | |
| 67 | threadId: asProviderThreadId("provider-thread-1"), | |
| 66 | 68 | cwd, |
| 67 | 69 | createdAt: now, |
| 68 | 70 | updatedAt: now, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+34-15| ... | ... | @@ -1,3 +1,13 @@ |
| 1 | import { | |
| 2 | CheckpointRef, | |
| 3 | EventId, | |
| 4 | MessageId, | |
| 5 | ProjectId, | |
| 6 | ProviderSessionId, | |
| 7 | ProviderThreadId, | |
| 8 | ThreadId, | |
| 9 | TurnId, | |
| 10 | } from "@t3tools/contracts"; | |
| 1 | 11 | import { assert, it } from "@effect/vitest"; |
| 2 | 12 | import { Effect, Layer } from "effect"; |
| 3 | 13 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | ... | @@ -7,6 +17,15 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 7 | 17 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 8 | 18 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 9 | 19 | |
| 20 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 21 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 22 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 23 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 24 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 25 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | |
| 26 | const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 27 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 28 | ||
| 10 | 29 | const projectionSnapshotLayer = it.layer( |
| 11 | 30 | OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), |
| 12 | 31 | ); |
| ... | ... | @@ -185,7 +204,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 185 | 204 | assert.equal(snapshot.updatedAt, "2026-02-24T00:00:09.000Z"); |
| 186 | 205 | assert.deepEqual(snapshot.projects, [ |
| 187 | 206 | { |
| 188 | id: "project-1", | |
| 207 | id: asProjectId("project-1"), | |
| 189 | 208 | title: "Project 1", |
| 190 | 209 | workspaceRoot: "/tmp/project-1", |
| 191 | 210 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -205,22 +224,22 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 205 | 224 | ]); |
| 206 | 225 | assert.deepEqual(snapshot.threads, [ |
| 207 | 226 | { |
| 208 | id: "thread-1", | |
| 209 | projectId: "project-1", | |
| 227 | id: asThreadId("thread-1"), | |
| 228 | projectId: asProjectId("project-1"), | |
| 210 | 229 | title: "Thread 1", |
| 211 | 230 | model: "gpt-5-codex", |
| 212 | 231 | branch: null, |
| 213 | 232 | worktreePath: null, |
| 214 | latestTurnId: "turn-1", | |
| 233 | latestTurnId: asTurnId("turn-1"), | |
| 215 | 234 | createdAt: "2026-02-24T00:00:02.000Z", |
| 216 | 235 | updatedAt: "2026-02-24T00:00:03.000Z", |
| 217 | 236 | deletedAt: null, |
| 218 | 237 | messages: [ |
| 219 | 238 | { |
| 220 | id: "message-1", | |
| 239 | id: asMessageId("message-1"), | |
| 221 | 240 | role: "assistant", |
| 222 | 241 | text: "hello from projection", |
| 223 | turnId: "turn-1", | |
| 242 | turnId: asTurnId("turn-1"), | |
| 224 | 243 | streaming: false, |
| 225 | 244 | createdAt: "2026-02-24T00:00:04.000Z", |
| 226 | 245 | updatedAt: "2026-02-24T00:00:05.000Z", |
| ... | ... | @@ -228,33 +247,33 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 228 | 247 | ], |
| 229 | 248 | activities: [ |
| 230 | 249 | { |
| 231 | id: "activity-1", | |
| 250 | id: asEventId("activity-1"), | |
| 232 | 251 | tone: "info", |
| 233 | 252 | kind: "runtime.note", |
| 234 | 253 | summary: "provider started", |
| 235 | 254 | payload: { stage: "start" }, |
| 236 | turnId: "turn-1", | |
| 255 | turnId: asTurnId("turn-1"), | |
| 237 | 256 | createdAt: "2026-02-24T00:00:06.000Z", |
| 238 | 257 | }, |
| 239 | 258 | ], |
| 240 | 259 | checkpoints: [ |
| 241 | 260 | { |
| 242 | turnId: "turn-1", | |
| 261 | turnId: asTurnId("turn-1"), | |
| 243 | 262 | checkpointTurnCount: 1, |
| 244 | checkpointRef: "checkpoint-1", | |
| 263 | checkpointRef: asCheckpointRef("checkpoint-1"), | |
| 245 | 264 | status: "ready", |
| 246 | 265 | files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }], |
| 247 | assistantMessageId: "message-1", | |
| 266 | assistantMessageId: asMessageId("message-1"), | |
| 248 | 267 | completedAt: "2026-02-24T00:00:08.000Z", |
| 249 | 268 | }, |
| 250 | 269 | ], |
| 251 | 270 | session: { |
| 252 | threadId: "thread-1", | |
| 271 | threadId: asThreadId("thread-1"), | |
| 253 | 272 | status: "running", |
| 254 | 273 | 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"), | |
| 258 | 277 | lastError: null, |
| 259 | 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 | 2 | import { |
| 3 | 3 | ApprovalRequestId, |
| 4 | 4 | CommandId, |
| 5 | MessageId, | |
| 5 | 6 | ProjectId, |
| 6 | 7 | ProviderSessionId, |
| 7 | 8 | ProviderThreadId, |
| 8 | 9 | ProviderTurnId, |
| 9 | 10 | ThreadId, |
| 11 | TurnId, | |
| 10 | 12 | } from "@t3tools/contracts"; |
| 11 | 13 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; |
| 12 | 14 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| ... | ... | @@ -30,6 +32,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); |
| 30 | 32 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 31 | 33 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| 32 | 34 | const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); |
| 35 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 36 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 33 | 37 | |
| 34 | 38 | async function waitFor( |
| 35 | 39 | predicate: () => boolean, |
| ... | ... | @@ -166,7 +170,7 @@ describe("ProviderCommandReactor", () => { |
| 166 | 170 | commandId: asCommandId("cmd-turn-start-1"), |
| 167 | 171 | threadId: asThreadId("thread-1"), |
| 168 | 172 | message: { |
| 169 | messageId: "user-message-1", | |
| 173 | messageId: asMessageId("user-message-1"), | |
| 170 | 174 | role: "user", |
| 171 | 175 | text: "hello reactor", |
| 172 | 176 | attachments: [], |
| ... | ... | @@ -198,7 +202,7 @@ describe("ProviderCommandReactor", () => { |
| 198 | 202 | providerName: "codex", |
| 199 | 203 | providerSessionId: asSessionId("sess-1"), |
| 200 | 204 | providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), |
| 201 | activeTurnId: "turn-1", | |
| 205 | activeTurnId: asTurnId("turn-1"), | |
| 202 | 206 | lastError: null, |
| 203 | 207 | updatedAt: now, |
| 204 | 208 | }, |
| ... | ... | @@ -211,7 +215,7 @@ describe("ProviderCommandReactor", () => { |
| 211 | 215 | type: "thread.turn.interrupt", |
| 212 | 216 | commandId: asCommandId("cmd-turn-interrupt"), |
| 213 | 217 | threadId: asThreadId("thread-1"), |
| 214 | turnId: "turn-1", | |
| 218 | turnId: asTurnId("turn-1"), | |
| 215 | 219 | createdAt: now, |
| 216 | 220 | }), |
| 217 | 221 | ); |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1-1| ... | ... | @@ -313,7 +313,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 313 | 313 | sessionId: asSessionId("sess-1"), |
| 314 | 314 | createdAt: now, |
| 315 | 315 | turnId: asProviderTurnId("turn-9"), |
| 316 | toolKind: "read", | |
| 316 | toolKind: "other", | |
| 317 | 317 | title: "Read file", |
| 318 | 318 | detail: "/tmp/file.ts", |
| 319 | 319 | }); |
apps/server/src/orchestration/commandInvariants.test.ts+39-28| ... | ... | @@ -1,5 +1,12 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { OrchestrationCommand, OrchestrationReadModel } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | MessageId, | |
| 4 | CommandId, | |
| 5 | ProjectId, | |
| 6 | ThreadId, | |
| 7 | type OrchestrationCommand, | |
| 8 | type OrchestrationReadModel, | |
| 9 | } from "@t3tools/contracts"; | |
| 3 | 10 | import { Effect } from "effect"; |
| 4 | 11 | |
| 5 | 12 | import { |
| ... | ... | @@ -17,7 +24,7 @@ const readModel: OrchestrationReadModel = { |
| 17 | 24 | updatedAt: now, |
| 18 | 25 | projects: [ |
| 19 | 26 | { |
| 20 | id: "project-a", | |
| 27 | id: ProjectId.makeUnsafe("project-a"), | |
| 21 | 28 | title: "Project A", |
| 22 | 29 | workspaceRoot: "/tmp/project-a", |
| 23 | 30 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -27,7 +34,7 @@ const readModel: OrchestrationReadModel = { |
| 27 | 34 | deletedAt: null, |
| 28 | 35 | }, |
| 29 | 36 | { |
| 30 | id: "project-b", | |
| 37 | id: ProjectId.makeUnsafe("project-b"), | |
| 31 | 38 | title: "Project B", |
| 32 | 39 | workspaceRoot: "/tmp/project-b", |
| 33 | 40 | defaultModel: "gpt-5-codex", |
| ... | ... | @@ -39,8 +46,8 @@ const readModel: OrchestrationReadModel = { |
| 39 | 46 | ], |
| 40 | 47 | threads: [ |
| 41 | 48 | { |
| 42 | id: "thread-1", | |
| 43 | projectId: "project-a", | |
| 49 | id: ThreadId.makeUnsafe("thread-1"), | |
| 50 | projectId: ProjectId.makeUnsafe("project-a"), | |
| 44 | 51 | title: "Thread A", |
| 45 | 52 | model: "gpt-5-codex", |
| 46 | 53 | branch: null, |
| ... | ... | @@ -52,10 +59,11 @@ const readModel: OrchestrationReadModel = { |
| 52 | 59 | session: null, |
| 53 | 60 | activities: [], |
| 54 | 61 | checkpoints: [], |
| 62 | deletedAt: null, | |
| 55 | 63 | }, |
| 56 | 64 | { |
| 57 | id: "thread-2", | |
| 58 | projectId: "project-b", | |
| 65 | id: ThreadId.makeUnsafe("thread-2"), | |
| 66 | projectId: ProjectId.makeUnsafe("project-b"), | |
| 59 | 67 | title: "Thread B", |
| 60 | 68 | model: "gpt-5-codex", |
| 61 | 69 | branch: null, |
| ... | ... | @@ -67,16 +75,17 @@ const readModel: OrchestrationReadModel = { |
| 67 | 75 | session: null, |
| 68 | 76 | activities: [], |
| 69 | 77 | checkpoints: [], |
| 78 | deletedAt: null, | |
| 70 | 79 | }, |
| 71 | 80 | ], |
| 72 | 81 | }; |
| 73 | 82 | |
| 74 | 83 | const messageSendCommand: OrchestrationCommand = { |
| 75 | 84 | type: "thread.turn.start", |
| 76 | commandId: "cmd-1", | |
| 77 | threadId: "thread-1", | |
| 85 | commandId: CommandId.makeUnsafe("cmd-1"), | |
| 86 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 78 | 87 | message: { |
| 79 | messageId: "msg-1", | |
| 88 | messageId: MessageId.makeUnsafe("msg-1"), | |
| 80 | 89 | role: "user", |
| 81 | 90 | text: "hello", |
| 82 | 91 | attachments: [], |
| ... | ... | @@ -86,11 +95,13 @@ const messageSendCommand: OrchestrationCommand = { |
| 86 | 95 | |
| 87 | 96 | describe("commandInvariants", () => { |
| 88 | 97 | 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")]); | |
| 94 | 105 | }); |
| 95 | 106 | |
| 96 | 107 | it("requires existing thread", async () => { |
| ... | ... | @@ -98,17 +109,17 @@ describe("commandInvariants", () => { |
| 98 | 109 | requireThread({ |
| 99 | 110 | readModel, |
| 100 | 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")); | |
| 105 | 116 | |
| 106 | 117 | await expect( |
| 107 | 118 | Effect.runPromise( |
| 108 | 119 | requireThread({ |
| 109 | 120 | readModel, |
| 110 | 121 | command: messageSendCommand, |
| 111 | threadId: "missing", | |
| 122 | threadId: ThreadId.makeUnsafe("missing"), | |
| 112 | 123 | }), |
| 113 | 124 | ), |
| 114 | 125 | ).rejects.toThrow("does not exist"); |
| ... | ... | @@ -120,16 +131,16 @@ describe("commandInvariants", () => { |
| 120 | 131 | readModel, |
| 121 | 132 | command: { |
| 122 | 133 | 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"), | |
| 126 | 137 | title: "new", |
| 127 | 138 | model: "gpt-5-codex", |
| 128 | 139 | branch: null, |
| 129 | 140 | worktreePath: null, |
| 130 | 141 | createdAt: now, |
| 131 | 142 | }, |
| 132 | threadId: "thread-3", | |
| 143 | threadId: ThreadId.makeUnsafe("thread-3"), | |
| 133 | 144 | }), |
| 134 | 145 | ); |
| 135 | 146 | |
| ... | ... | @@ -139,16 +150,16 @@ describe("commandInvariants", () => { |
| 139 | 150 | readModel, |
| 140 | 151 | command: { |
| 141 | 152 | 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"), | |
| 145 | 156 | title: "dup", |
| 146 | 157 | model: "gpt-5-codex", |
| 147 | 158 | branch: null, |
| 148 | 159 | worktreePath: null, |
| 149 | 160 | createdAt: now, |
| 150 | 161 | }, |
| 151 | threadId: "thread-1", | |
| 162 | threadId: ThreadId.makeUnsafe("thread-1"), | |
| 152 | 163 | }), |
| 153 | 164 | ), |
| 154 | 165 | ).rejects.toThrow("already exists"); |
| ... | ... | @@ -157,7 +168,7 @@ describe("commandInvariants", () => { |
| 157 | 168 | it("requires non-negative integers", async () => { |
| 158 | 169 | await Effect.runPromise( |
| 159 | 170 | requireNonNegativeInteger({ |
| 160 | commandType: "thread.revert", | |
| 171 | commandType: "thread.checkpoint.revert", | |
| 161 | 172 | field: "turnCount", |
| 162 | 173 | value: 0, |
| 163 | 174 | }), |
| ... | ... | @@ -166,7 +177,7 @@ describe("commandInvariants", () => { |
| 166 | 177 | await expect( |
| 167 | 178 | Effect.runPromise( |
| 168 | 179 | requireNonNegativeInteger({ |
| 169 | commandType: "thread.revert", | |
| 180 | commandType: "thread.checkpoint.revert", | |
| 170 | 181 | field: "turnCount", |
| 171 | 182 | value: -1, |
| 172 | 183 | }), |
apps/server/src/orchestration/decider.projectScripts.test.ts+33-26| ... | ... | @@ -1,9 +1,16 @@ |
| 1 | import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | import { Effect } from "effect"; |
| 3 | 4 | |
| 4 | 5 | import { decideOrchestrationCommand } from "./decider.ts"; |
| 5 | 6 | import { createEmptyReadModel, projectEvent } from "./projector.ts"; |
| 6 | 7 | |
| 8 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 9 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 10 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 11 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 12 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 13 | ||
| 7 | 14 | describe("decider project scripts", () => { |
| 8 | 15 | it("emits empty scripts on project.create", async () => { |
| 9 | 16 | const now = new Date().toISOString(); |
| ... | ... | @@ -13,8 +20,8 @@ describe("decider project scripts", () => { |
| 13 | 20 | decideOrchestrationCommand({ |
| 14 | 21 | command: { |
| 15 | 22 | 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"), | |
| 18 | 25 | title: "Scripts", |
| 19 | 26 | workspaceRoot: "/tmp/scripts", |
| 20 | 27 | createdAt: now, |
| ... | ... | @@ -34,17 +41,17 @@ describe("decider project scripts", () => { |
| 34 | 41 | const readModel = await Effect.runPromise( |
| 35 | 42 | projectEvent(initial, { |
| 36 | 43 | 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"), | |
| 40 | 47 | type: "project.created", |
| 41 | 48 | occurredAt: now, |
| 42 | commandId: "cmd-project-create-scripts", | |
| 49 | commandId: asCommandId("cmd-project-create-scripts"), | |
| 43 | 50 | causationEventId: null, |
| 44 | correlationId: "cmd-project-create-scripts", | |
| 51 | correlationId: asCommandId("cmd-project-create-scripts"), | |
| 45 | 52 | metadata: {}, |
| 46 | 53 | payload: { |
| 47 | projectId: "project-scripts", | |
| 54 | projectId: asProjectId("project-scripts"), | |
| 48 | 55 | title: "Scripts", |
| 49 | 56 | workspaceRoot: "/tmp/scripts", |
| 50 | 57 | defaultModel: null, |
| ... | ... | @@ -69,8 +76,8 @@ describe("decider project scripts", () => { |
| 69 | 76 | decideOrchestrationCommand({ |
| 70 | 77 | command: { |
| 71 | 78 | 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"), | |
| 74 | 81 | scripts: Array.from(scripts), |
| 75 | 82 | }, |
| 76 | 83 | readModel, |
| ... | ... | @@ -88,17 +95,17 @@ describe("decider project scripts", () => { |
| 88 | 95 | const withProject = await Effect.runPromise( |
| 89 | 96 | projectEvent(initial, { |
| 90 | 97 | 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"), | |
| 94 | 101 | type: "project.created", |
| 95 | 102 | occurredAt: now, |
| 96 | commandId: "cmd-project-create", | |
| 103 | commandId: asCommandId("cmd-project-create"), | |
| 97 | 104 | causationEventId: null, |
| 98 | correlationId: "cmd-project-create", | |
| 105 | correlationId: asCommandId("cmd-project-create"), | |
| 99 | 106 | metadata: {}, |
| 100 | 107 | payload: { |
| 101 | projectId: "project-1", | |
| 108 | projectId: asProjectId("project-1"), | |
| 102 | 109 | title: "Project", |
| 103 | 110 | workspaceRoot: "/tmp/project", |
| 104 | 111 | defaultModel: null, |
| ... | ... | @@ -111,18 +118,18 @@ describe("decider project scripts", () => { |
| 111 | 118 | const readModel = await Effect.runPromise( |
| 112 | 119 | projectEvent(withProject, { |
| 113 | 120 | 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"), | |
| 117 | 124 | type: "thread.created", |
| 118 | 125 | occurredAt: now, |
| 119 | commandId: "cmd-thread-create", | |
| 126 | commandId: asCommandId("cmd-thread-create"), | |
| 120 | 127 | causationEventId: null, |
| 121 | correlationId: "cmd-thread-create", | |
| 128 | correlationId: asCommandId("cmd-thread-create"), | |
| 122 | 129 | metadata: {}, |
| 123 | 130 | payload: { |
| 124 | threadId: "thread-1", | |
| 125 | projectId: "project-1", | |
| 131 | threadId: asThreadId("thread-1"), | |
| 132 | projectId: asProjectId("project-1"), | |
| 126 | 133 | title: "Thread", |
| 127 | 134 | model: "gpt-5-codex", |
| 128 | 135 | branch: null, |
| ... | ... | @@ -137,10 +144,10 @@ describe("decider project scripts", () => { |
| 137 | 144 | decideOrchestrationCommand({ |
| 138 | 145 | command: { |
| 139 | 146 | type: "thread.turn.start", |
| 140 | commandId: "cmd-turn-start", | |
| 141 | threadId: "thread-1", | |
| 147 | commandId: asCommandId("cmd-turn-start"), | |
| 148 | threadId: asThreadId("thread-1"), | |
| 142 | 149 | message: { |
| 143 | messageId: "message-user-1", | |
| 150 | messageId: asMessageId("message-user-1"), | |
| 144 | 151 | role: "user", |
| 145 | 152 | text: "hello", |
| 146 | 153 | attachments: [], |
apps/server/src/orchestration/projector.test.ts+29-26| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | |
| 1 | import { CommandId, EventId, ProjectId, ThreadId, type OrchestrationEvent } from "@t3tools/contracts"; | |
| 2 | 2 | import { Effect } from "effect"; |
| 3 | 3 | import { describe, expect, it } from "vitest"; |
| 4 | 4 | |
| ... | ... | @@ -8,24 +8,27 @@ function makeEvent(input: { |
| 8 | 8 | sequence: number; |
| 9 | 9 | type: OrchestrationEvent["type"]; |
| 10 | 10 | occurredAt: string; |
| 11 | aggregateType: OrchestrationEvent["aggregateType"]; | |
| 11 | aggregateKind: OrchestrationEvent["aggregateKind"]; | |
| 12 | 12 | aggregateId: string; |
| 13 | 13 | commandId: string | null; |
| 14 | 14 | payload: unknown; |
| 15 | 15 | }): OrchestrationEvent { |
| 16 | 16 | return { |
| 17 | 17 | sequence: input.sequence, |
| 18 | eventId: `event-${input.sequence}`, | |
| 18 | eventId: EventId.makeUnsafe(`event-${input.sequence}`), | |
| 19 | 19 | 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), | |
| 22 | 25 | occurredAt: input.occurredAt, |
| 23 | commandId: input.commandId as OrchestrationEvent["commandId"], | |
| 26 | commandId: input.commandId === null ? null : CommandId.makeUnsafe(input.commandId), | |
| 24 | 27 | causationEventId: null, |
| 25 | 28 | correlationId: null, |
| 26 | 29 | metadata: {}, |
| 27 | payload: input.payload, | |
| 28 | }; | |
| 30 | payload: input.payload as never, | |
| 31 | } as OrchestrationEvent; | |
| 29 | 32 | } |
| 30 | 33 | |
| 31 | 34 | describe("orchestration projector", () => { |
| ... | ... | @@ -39,7 +42,7 @@ describe("orchestration projector", () => { |
| 39 | 42 | makeEvent({ |
| 40 | 43 | sequence: 1, |
| 41 | 44 | type: "thread.created", |
| 42 | aggregateType: "thread", | |
| 45 | aggregateKind: "thread", | |
| 43 | 46 | aggregateId: "thread-1", |
| 44 | 47 | occurredAt: now, |
| 45 | 48 | commandId: "cmd-thread-create", |
| ... | ... | @@ -89,7 +92,7 @@ describe("orchestration projector", () => { |
| 89 | 92 | makeEvent({ |
| 90 | 93 | sequence: 1, |
| 91 | 94 | type: "thread.created", |
| 92 | aggregateType: "thread", | |
| 95 | aggregateKind: "thread", | |
| 93 | 96 | aggregateId: "thread-1", |
| 94 | 97 | occurredAt: now, |
| 95 | 98 | commandId: "cmd-invalid", |
| ... | ... | @@ -119,7 +122,7 @@ describe("orchestration projector", () => { |
| 119 | 122 | makeEvent({ |
| 120 | 123 | sequence: 7, |
| 121 | 124 | type: "thread.turn-start-requested", |
| 122 | aggregateType: "thread", | |
| 125 | aggregateKind: "thread", | |
| 123 | 126 | aggregateId: "thread-1", |
| 124 | 127 | occurredAt: "2026-01-01T00:00:00.000Z", |
| 125 | 128 | commandId: "cmd-unhandled", |
| ... | ... | @@ -148,7 +151,7 @@ describe("orchestration projector", () => { |
| 148 | 151 | makeEvent({ |
| 149 | 152 | sequence: 1, |
| 150 | 153 | type: "thread.created", |
| 151 | aggregateType: "thread", | |
| 154 | aggregateKind: "thread", | |
| 152 | 155 | aggregateId: "thread-1", |
| 153 | 156 | occurredAt: createdAt, |
| 154 | 157 | commandId: "cmd-create", |
| ... | ... | @@ -172,7 +175,7 @@ describe("orchestration projector", () => { |
| 172 | 175 | makeEvent({ |
| 173 | 176 | sequence: 2, |
| 174 | 177 | type: "thread.session-set", |
| 175 | aggregateType: "thread", | |
| 178 | aggregateKind: "thread", | |
| 176 | 179 | aggregateId: "thread-1", |
| 177 | 180 | occurredAt: startedAt, |
| 178 | 181 | commandId: "cmd-running", |
| ... | ... | @@ -210,7 +213,7 @@ describe("orchestration projector", () => { |
| 210 | 213 | makeEvent({ |
| 211 | 214 | sequence: 1, |
| 212 | 215 | type: "thread.created", |
| 213 | aggregateType: "thread", | |
| 216 | aggregateKind: "thread", | |
| 214 | 217 | aggregateId: "thread-1", |
| 215 | 218 | occurredAt: createdAt, |
| 216 | 219 | commandId: "cmd-create", |
| ... | ... | @@ -234,7 +237,7 @@ describe("orchestration projector", () => { |
| 234 | 237 | makeEvent({ |
| 235 | 238 | sequence: 2, |
| 236 | 239 | type: "thread.message-sent", |
| 237 | aggregateType: "thread", | |
| 240 | aggregateKind: "thread", | |
| 238 | 241 | aggregateId: "thread-1", |
| 239 | 242 | occurredAt: deltaAt, |
| 240 | 243 | commandId: "cmd-delta", |
| ... | ... | @@ -258,7 +261,7 @@ describe("orchestration projector", () => { |
| 258 | 261 | makeEvent({ |
| 259 | 262 | sequence: 3, |
| 260 | 263 | type: "thread.message-sent", |
| 261 | aggregateType: "thread", | |
| 264 | aggregateKind: "thread", | |
| 262 | 265 | aggregateId: "thread-1", |
| 263 | 266 | occurredAt: completeAt, |
| 264 | 267 | commandId: "cmd-complete", |
| ... | ... | @@ -293,7 +296,7 @@ describe("orchestration projector", () => { |
| 293 | 296 | makeEvent({ |
| 294 | 297 | sequence: 1, |
| 295 | 298 | type: "thread.created", |
| 296 | aggregateType: "thread", | |
| 299 | aggregateKind: "thread", | |
| 297 | 300 | aggregateId: "thread-1", |
| 298 | 301 | occurredAt: createdAt, |
| 299 | 302 | commandId: "cmd-create", |
| ... | ... | @@ -315,7 +318,7 @@ describe("orchestration projector", () => { |
| 315 | 318 | makeEvent({ |
| 316 | 319 | sequence: 2, |
| 317 | 320 | type: "thread.message-sent", |
| 318 | aggregateType: "thread", | |
| 321 | aggregateKind: "thread", | |
| 319 | 322 | aggregateId: "thread-1", |
| 320 | 323 | occurredAt: "2026-02-23T10:00:01.000Z", |
| 321 | 324 | commandId: "cmd-user-1", |
| ... | ... | @@ -333,7 +336,7 @@ describe("orchestration projector", () => { |
| 333 | 336 | makeEvent({ |
| 334 | 337 | sequence: 3, |
| 335 | 338 | type: "thread.message-sent", |
| 336 | aggregateType: "thread", | |
| 339 | aggregateKind: "thread", | |
| 337 | 340 | aggregateId: "thread-1", |
| 338 | 341 | occurredAt: "2026-02-23T10:00:02.000Z", |
| 339 | 342 | commandId: "cmd-assistant-1", |
| ... | ... | @@ -351,7 +354,7 @@ describe("orchestration projector", () => { |
| 351 | 354 | makeEvent({ |
| 352 | 355 | sequence: 4, |
| 353 | 356 | type: "thread.turn-diff-completed", |
| 354 | aggregateType: "thread", | |
| 357 | aggregateKind: "thread", | |
| 355 | 358 | aggregateId: "thread-1", |
| 356 | 359 | occurredAt: "2026-02-23T10:00:02.500Z", |
| 357 | 360 | commandId: "cmd-turn-1-complete", |
| ... | ... | @@ -369,7 +372,7 @@ describe("orchestration projector", () => { |
| 369 | 372 | makeEvent({ |
| 370 | 373 | sequence: 5, |
| 371 | 374 | type: "thread.activity-appended", |
| 372 | aggregateType: "thread", | |
| 375 | aggregateKind: "thread", | |
| 373 | 376 | aggregateId: "thread-1", |
| 374 | 377 | occurredAt: "2026-02-23T10:00:02.750Z", |
| 375 | 378 | commandId: "cmd-activity-1", |
| ... | ... | @@ -389,7 +392,7 @@ describe("orchestration projector", () => { |
| 389 | 392 | makeEvent({ |
| 390 | 393 | sequence: 6, |
| 391 | 394 | type: "thread.message-sent", |
| 392 | aggregateType: "thread", | |
| 395 | aggregateKind: "thread", | |
| 393 | 396 | aggregateId: "thread-1", |
| 394 | 397 | occurredAt: "2026-02-23T10:00:03.000Z", |
| 395 | 398 | commandId: "cmd-user-2", |
| ... | ... | @@ -407,7 +410,7 @@ describe("orchestration projector", () => { |
| 407 | 410 | makeEvent({ |
| 408 | 411 | sequence: 7, |
| 409 | 412 | type: "thread.message-sent", |
| 410 | aggregateType: "thread", | |
| 413 | aggregateKind: "thread", | |
| 411 | 414 | aggregateId: "thread-1", |
| 412 | 415 | occurredAt: "2026-02-23T10:00:04.000Z", |
| 413 | 416 | commandId: "cmd-assistant-2", |
| ... | ... | @@ -425,7 +428,7 @@ describe("orchestration projector", () => { |
| 425 | 428 | makeEvent({ |
| 426 | 429 | sequence: 8, |
| 427 | 430 | type: "thread.turn-diff-completed", |
| 428 | aggregateType: "thread", | |
| 431 | aggregateKind: "thread", | |
| 429 | 432 | aggregateId: "thread-1", |
| 430 | 433 | occurredAt: "2026-02-23T10:00:04.500Z", |
| 431 | 434 | commandId: "cmd-turn-2-complete", |
| ... | ... | @@ -443,7 +446,7 @@ describe("orchestration projector", () => { |
| 443 | 446 | makeEvent({ |
| 444 | 447 | sequence: 9, |
| 445 | 448 | type: "thread.activity-appended", |
| 446 | aggregateType: "thread", | |
| 449 | aggregateKind: "thread", | |
| 447 | 450 | aggregateId: "thread-1", |
| 448 | 451 | occurredAt: "2026-02-23T10:00:04.750Z", |
| 449 | 452 | commandId: "cmd-activity-2", |
| ... | ... | @@ -463,7 +466,7 @@ describe("orchestration projector", () => { |
| 463 | 466 | makeEvent({ |
| 464 | 467 | sequence: 10, |
| 465 | 468 | type: "thread.reverted", |
| 466 | aggregateType: "thread", | |
| 469 | aggregateKind: "thread", | |
| 467 | 470 | aggregateId: "thread-1", |
| 468 | 471 | occurredAt: "2026-02-23T10:00:05.000Z", |
| 469 | 472 | commandId: "cmd-revert", |
apps/server/src/provider/Layers/CodexAdapter.test.ts+46-34| ... | ... | @@ -1,10 +1,16 @@ |
| 1 | import type { | |
| 2 | ProviderApprovalDecision, | |
| 3 | ProviderEvent, | |
| 4 | ProviderSendTurnInput, | |
| 5 | ProviderSession, | |
| 6 | ProviderSessionStartInput, | |
| 7 | ProviderTurnStartResult, | |
| 1 | import { | |
| 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, | |
| 8 | 14 | } from "@t3tools/contracts"; |
| 9 | 15 | import { afterAll, assert, it, vi } from "@effect/vitest"; |
| 10 | 16 | import { assertFailure } from "@effect/vitest/utils"; |
| ... | ... | @@ -16,15 +22,21 @@ import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 16 | 22 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 17 | 23 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; |
| 18 | 24 | |
| 25 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 26 | const asThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 27 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 28 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 29 | const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | |
| 30 | ||
| 19 | 31 | class FakeCodexManager extends CodexAppServerManager { |
| 20 | 32 | public startSessionImpl = vi.fn( |
| 21 | 33 | async (input: ProviderSessionStartInput): Promise<ProviderSession> => { |
| 22 | 34 | const now = new Date().toISOString(); |
| 23 | 35 | return { |
| 24 | sessionId: "sess-1", | |
| 36 | sessionId: asSessionId("sess-1"), | |
| 25 | 37 | provider: "codex", |
| 26 | 38 | status: "ready", |
| 27 | threadId: "thread-1", | |
| 39 | threadId: asThreadId("thread-1"), | |
| 28 | 40 | cwd: input.cwd, |
| 29 | 41 | createdAt: now, |
| 30 | 42 | updatedAt: now, |
| ... | ... | @@ -34,29 +46,29 @@ class FakeCodexManager extends CodexAppServerManager { |
| 34 | 46 | |
| 35 | 47 | public sendTurnImpl = vi.fn( |
| 36 | 48 | async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({ |
| 37 | threadId: "thread-1", | |
| 38 | turnId: "turn-1", | |
| 49 | threadId: asThreadId("thread-1"), | |
| 50 | turnId: asTurnId("turn-1"), | |
| 39 | 51 | }), |
| 40 | 52 | ); |
| 41 | 53 | |
| 42 | 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 | ); |
| 45 | 57 | |
| 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"), | |
| 48 | 60 | turns: [], |
| 49 | 61 | })); |
| 50 | 62 | |
| 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"), | |
| 53 | 65 | turns: [], |
| 54 | 66 | })); |
| 55 | 67 | |
| 56 | 68 | public respondToRequestImpl = vi.fn( |
| 57 | 69 | async ( |
| 58 | _sessionId: string, | |
| 59 | _requestId: string, | |
| 70 | _sessionId: ProviderSessionId, | |
| 71 | _requestId: ApprovalRequestId, | |
| 60 | 72 | _decision: ProviderApprovalDecision, |
| 61 | 73 | ): Promise<void> => undefined, |
| 62 | 74 | ); |
| ... | ... | @@ -71,33 +83,33 @@ class FakeCodexManager extends CodexAppServerManager { |
| 71 | 83 | return this.sendTurnImpl(input); |
| 72 | 84 | } |
| 73 | 85 | |
| 74 | override interruptTurn(sessionId: string, turnId?: string): Promise<void> { | |
| 86 | override interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise<void> { | |
| 75 | 87 | return this.interruptTurnImpl(sessionId, turnId); |
| 76 | 88 | } |
| 77 | 89 | |
| 78 | override readThread(sessionId: string) { | |
| 90 | override readThread(sessionId: ProviderSessionId) { | |
| 79 | 91 | return this.readThreadImpl(sessionId); |
| 80 | 92 | } |
| 81 | 93 | |
| 82 | override rollbackThread(sessionId: string, numTurns: number) { | |
| 94 | override rollbackThread(sessionId: ProviderSessionId, numTurns: number) { | |
| 83 | 95 | return this.rollbackThreadImpl(sessionId, numTurns); |
| 84 | 96 | } |
| 85 | 97 | |
| 86 | 98 | override respondToRequest( |
| 87 | sessionId: string, | |
| 88 | requestId: string, | |
| 99 | sessionId: ProviderSessionId, | |
| 100 | requestId: ApprovalRequestId, | |
| 89 | 101 | decision: ProviderApprovalDecision, |
| 90 | 102 | ): Promise<void> { |
| 91 | 103 | return this.respondToRequestImpl(sessionId, requestId, decision); |
| 92 | 104 | } |
| 93 | 105 | |
| 94 | override stopSession(_sessionId: string): void {} | |
| 106 | override stopSession(_sessionId: ProviderSessionId): void {} | |
| 95 | 107 | |
| 96 | 108 | override listSessions(): ProviderSession[] { |
| 97 | 109 | return []; |
| 98 | 110 | } |
| 99 | 111 | |
| 100 | override hasSession(_sessionId: string): boolean { | |
| 112 | override hasSession(_sessionId: ProviderSessionId): boolean { | |
| 101 | 113 | return false; |
| 102 | 114 | } |
| 103 | 115 | |
| ... | ... | @@ -144,7 +156,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 144 | 156 | const adapter = yield* CodexAdapter; |
| 145 | 157 | const result = yield* adapter |
| 146 | 158 | .sendTurn({ |
| 147 | sessionId: "sess-missing", | |
| 159 | sessionId: asSessionId("sess-missing"), | |
| 148 | 160 | input: "hello", |
| 149 | 161 | attachments: [], |
| 150 | 162 | }) |
| ... | ... | @@ -176,15 +188,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 176 | 188 | const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); |
| 177 | 189 | |
| 178 | 190 | const event: ProviderEvent = { |
| 179 | id: "evt-msg-complete", | |
| 191 | id: asEventId("evt-msg-complete"), | |
| 180 | 192 | kind: "notification", |
| 181 | 193 | provider: "codex", |
| 182 | sessionId: "sess-1", | |
| 194 | sessionId: asSessionId("sess-1"), | |
| 183 | 195 | createdAt: new Date().toISOString(), |
| 184 | 196 | 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"), | |
| 188 | 200 | payload: { |
| 189 | 201 | item: { |
| 190 | 202 | type: "agentMessage", |
| ... | ... | @@ -220,13 +232,13 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 220 | 232 | ).pipe(Effect.forkChild); |
| 221 | 233 | |
| 222 | 234 | const event: ProviderEvent = { |
| 223 | id: "evt-1", | |
| 235 | id: asEventId("evt-1"), | |
| 224 | 236 | kind: "notification", |
| 225 | 237 | provider: "codex", |
| 226 | sessionId: "sess-1", | |
| 238 | sessionId: asSessionId("sess-1"), | |
| 227 | 239 | createdAt: new Date().toISOString(), |
| 228 | 240 | method: "turn/started", |
| 229 | turnId: "turn-1", | |
| 241 | turnId: asTurnId("turn-1"), | |
| 230 | 242 | }; |
| 231 | 243 | |
| 232 | 244 | lifecycleManager.emit("event", event); |
apps/server/src/provider/Layers/ProviderService.test.ts+22-36| ... | ... | @@ -45,17 +45,6 @@ import { |
| 45 | 45 | SqlitePersistenceMemory, |
| 46 | 46 | } from "../../persistence/Layers/Sqlite.ts"; |
| 47 | 47 | |
| 48 | function 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 | ||
| 59 | 48 | const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); |
| 60 | 49 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); |
| 61 | 50 | const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); |
| ... | ... | @@ -526,11 +515,18 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 526 | 515 | const payload = runningRuntime.value.runtimePayload; |
| 527 | 516 | assert.equal(payload !== null && typeof payload === "object", true); |
| 528 | 517 | 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"); | |
| 534 | 530 | } |
| 535 | 531 | } |
| 536 | 532 | |
| ... | ... | @@ -669,32 +665,22 @@ validation.layer("ProviderServiceLive validation", (it) => { |
| 669 | 665 | Effect.gen(function* () { |
| 670 | 666 | const provider = yield* ProviderService; |
| 671 | 667 | |
| 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 | 668 | const failure = yield* Effect.result( |
| 685 | 669 | provider.startSession({ |
| 686 | 670 | provider: "invalid-provider", |
| 687 | 671 | } as never), |
| 688 | 672 | ); |
| 689 | 673 | |
| 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); | |
| 698 | 684 | }), |
| 699 | 685 | ); |
| 700 | 686 |
apps/server/src/wsServer.test.ts+80-17| ... | ... | @@ -10,15 +10,20 @@ import WebSocket from "ws"; |
| 10 | 10 | |
| 11 | 11 | import { |
| 12 | 12 | DEFAULT_TERMINAL_ID, |
| 13 | EventId, | |
| 13 | 14 | ORCHESTRATION_WS_CHANNELS, |
| 14 | 15 | ORCHESTRATION_WS_METHODS, |
| 16 | ProviderItemId, | |
| 17 | ProviderSessionId, | |
| 18 | ProviderThreadId, | |
| 19 | ProviderTurnId, | |
| 15 | 20 | WS_CHANNELS, |
| 16 | 21 | WS_METHODS, |
| 22 | type WebSocketResponse, | |
| 17 | 23 | type ProviderRuntimeEvent, |
| 18 | 24 | type KeybindingsConfig, |
| 19 | 25 | type ResolvedKeybindingsConfig, |
| 20 | 26 | type WsPush, |
| 21 | type WsResponse, | |
| 22 | 27 | } from "@t3tools/contracts"; |
| 23 | 28 | import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; |
| 24 | 29 | import type { |
| ... | ... | @@ -34,6 +39,7 @@ import type { TerminalManager } from "./terminalManager"; |
| 34 | 39 | import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 35 | 40 | import { SqlClient } from "effect/unstable/sql"; |
| 36 | 41 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 42 | import type { OpenShape } from "./open"; | |
| 37 | 43 | |
| 38 | 44 | interface PendingMessages { |
| 39 | 45 | queue: unknown[]; |
| ... | ... | @@ -42,6 +48,12 @@ interface PendingMessages { |
| 42 | 48 | |
| 43 | 49 | const pendingBySocket = new WeakMap<WebSocket, PendingMessages>(); |
| 44 | 50 | |
| 51 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 52 | const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 53 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 54 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 55 | const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); | |
| 56 | ||
| 45 | 57 | class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { |
| 46 | 58 | private readonly sessions = new Map<string, TerminalSessionSnapshot>(); |
| 47 | 59 | |
| ... | ... | @@ -187,7 +199,19 @@ function waitForMessage(ws: WebSocket): Promise<unknown> { |
| 187 | 199 | }); |
| 188 | 200 | } |
| 189 | 201 | |
| 190 | async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> { | |
| 202 | function 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 | ||
| 210 | async function sendRequest( | |
| 211 | ws: WebSocket, | |
| 212 | method: string, | |
| 213 | params?: unknown, | |
| 214 | ): Promise<WebSocketResponse> { | |
| 191 | 215 | const id = crypto.randomUUID(); |
| 192 | 216 | const body = |
| 193 | 217 | method === ORCHESTRATION_WS_METHODS.dispatchCommand |
| ... | ... | @@ -200,12 +224,15 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro |
| 200 | 224 | |
| 201 | 225 | // Wait for response with matching id |
| 202 | 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 | 231 | if (parsed.id === id) { |
| 205 | return parsed as WsResponse; | |
| 232 | return parsed; | |
| 206 | 233 | } |
| 207 | 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 | 260 | } |
| 234 | 261 | |
| 235 | 262 | function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { |
| 236 | const resolved: ResolvedKeybindingsConfig = []; | |
| 263 | const resolved: Array<ResolvedKeybindingsConfig[number]> = []; | |
| 237 | 264 | for (const binding of bindings) { |
| 238 | 265 | const compiled = compileResolvedKeybindingRule(binding); |
| 239 | 266 | if (!compiled) { |
| ... | ... | @@ -278,6 +305,7 @@ describe("WebSocket Server", () => { |
| 278 | 305 | authToken?: string; |
| 279 | 306 | stateDir?: string; |
| 280 | 307 | providerLayer?: Layer.Layer<ProviderService, unknown>; |
| 308 | open?: OpenShape; | |
| 281 | 309 | gitManager?: { |
| 282 | 310 | status: (input: { cwd: string }) => Promise<unknown>; |
| 283 | 311 | runStackedAction: (input: { cwd: string; action: string }) => Promise<unknown>; |
| ... | ... | @@ -297,6 +325,7 @@ describe("WebSocket Server", () => { |
| 297 | 325 | ...(options.devUrl ? { devUrl: options.devUrl } : {}), |
| 298 | 326 | ...(options.authToken ? { authToken: options.authToken } : {}), |
| 299 | 327 | ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}), |
| 328 | ...(options.open ? { open: options.open } : {}), | |
| 300 | 329 | ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), |
| 301 | 330 | ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), |
| 302 | 331 | }); |
| ... | ... | @@ -420,6 +449,33 @@ describe("WebSocket Server", () => { |
| 420 | 449 | }); |
| 421 | 450 | }); |
| 422 | 451 | |
| 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 | 479 | it("reads keybindings from ~/.t3/keybindings.json", async () => { |
| 424 | 480 | const fakeHome = makeTempDir("t3code-home-"); |
| 425 | 481 | const configDir = path.join(fakeHome, ".t3"); |
| ... | ... | @@ -787,7 +843,7 @@ describe("WebSocket Server", () => { |
| 787 | 843 | |
| 788 | 844 | it("keeps orchestration domain push behavior for provider runtime events", async () => { |
| 789 | 845 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 790 | const sessionId = "sess-test"; | |
| 846 | const sessionId = asProviderSessionId("sess-test"); | |
| 791 | 847 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { |
| 792 | 848 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); |
| 793 | 849 | }; |
| ... | ... | @@ -798,11 +854,15 @@ describe("WebSocket Server", () => { |
| 798 | 854 | sessionId, |
| 799 | 855 | provider: "codex", |
| 800 | 856 | status: "ready", |
| 801 | threadId: "provider-thread-1", | |
| 857 | threadId: asProviderThreadId("provider-thread-1"), | |
| 802 | 858 | createdAt: new Date().toISOString(), |
| 803 | 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 | 866 | interruptTurn: () => unsupported(), |
| 807 | 867 | respondToRequest: () => unsupported(), |
| 808 | 868 | stopSession: () => unsupported(), |
| ... | ... | @@ -870,12 +930,12 @@ describe("WebSocket Server", () => { |
| 870 | 930 | |
| 871 | 931 | emitRuntimeEvent({ |
| 872 | 932 | type: "message.delta", |
| 873 | eventId: "evt-ws-runtime-message-delta", | |
| 933 | eventId: asEventId("evt-ws-runtime-message-delta"), | |
| 874 | 934 | provider: "codex", |
| 875 | 935 | sessionId, |
| 876 | 936 | createdAt: new Date().toISOString(), |
| 877 | turnId: "turn-1", | |
| 878 | itemId: "item-1", | |
| 937 | turnId: asProviderTurnId("turn-1"), | |
| 938 | itemId: asProviderItemId("item-1"), | |
| 879 | 939 | delta: "hello from runtime", |
| 880 | 940 | }); |
| 881 | 941 | |
| ... | ... | @@ -1015,15 +1075,18 @@ describe("WebSocket Server", () => { |
| 1015 | 1075 | // Send garbage |
| 1016 | 1076 | ws.send("not json at all"); |
| 1017 | 1077 | |
| 1018 | let response: WsResponse | null = null; | |
| 1078 | let response: WebSocketResponse | null = null; | |
| 1019 | 1079 | 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; | |
| 1023 | 1086 | break; |
| 1024 | 1087 | } |
| 1025 | 1088 | if (message.error) { |
| 1026 | response = message as WsResponse; | |
| 1089 | response = message; | |
| 1027 | 1090 | break; |
| 1028 | 1091 | } |
| 1029 | 1092 | } |
apps/server/src/wsServer.ts+28-48| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | 1 | import fs from "node:fs"; |
| 3 | 2 | import http from "node:http"; |
| 4 | 3 | import os from "node:os"; |
| ... | ... | @@ -7,7 +6,6 @@ import type { Duplex } from "node:stream"; |
| 7 | 6 | |
| 8 | 7 | import { |
| 9 | 8 | CheckpointRef, |
| 10 | EDITORS, | |
| 11 | 9 | ORCHESTRATION_WS_CHANNELS, |
| 12 | 10 | ORCHESTRATION_WS_METHODS, |
| 13 | 11 | TerminalEvent, |
| ... | ... | @@ -18,7 +16,7 @@ import { |
| 18 | 16 | WsResponse, |
| 19 | 17 | } from "@t3tools/contracts"; |
| 20 | 18 | import { NodeServices } from "@effect/platform-node"; |
| 21 | import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, Stream } from "effect"; | |
| 19 | import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, ServiceMap, Stream } from "effect"; | |
| 22 | 20 | import { WebSocketServer, type WebSocket } from "ws"; |
| 23 | 21 | |
| 24 | 22 | import { createLogger } from "./logger"; |
| ... | ... | @@ -62,12 +60,16 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD |
| 62 | 60 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 63 | 61 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 64 | 62 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; |
| 65 | import { CheckpointStore, type CheckpointStoreShape } from "./checkpointing/Services/CheckpointStore"; | |
| 63 | import { | |
| 64 | CheckpointStore, | |
| 65 | type CheckpointStoreShape, | |
| 66 | } from "./checkpointing/Services/CheckpointStore"; | |
| 66 | 67 | import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; |
| 67 | 68 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 68 | 69 | import * as SqlError from "effect/unstable/sql/SqlError"; |
| 69 | 70 | import * as Migrator from "effect/unstable/sql/Migrator"; |
| 70 | 71 | import { clamp } from "effect/Number"; |
| 72 | import { OpenDefaultService, type OpenShape } from "./open"; | |
| 71 | 73 | |
| 72 | 74 | const MIME_TYPES: Record<string, string> = { |
| 73 | 75 | ".html": "text/html; charset=utf-8", |
| ... | ... | @@ -100,8 +102,25 @@ export interface ServerOptions { |
| 100 | 102 | terminalManager?: TerminalManager | undefined; |
| 101 | 103 | providerLayer?: Layer.Layer<ProviderService, unknown> | undefined; |
| 102 | 104 | authToken?: string | undefined; |
| 105 | open?: OpenShape | undefined; | |
| 106 | } | |
| 107 | ||
| 108 | export interface ServerRuntime { | |
| 109 | readonly start: () => Promise<void>; | |
| 110 | readonly stop: () => Promise<void>; | |
| 111 | readonly httpServer: http.Server; | |
| 103 | 112 | } |
| 104 | 113 | |
| 114 | export interface ServerShape { | |
| 115 | readonly createServer: (options: ServerOptions) => ServerRuntime; | |
| 116 | } | |
| 117 | ||
| 118 | export class Server extends ServiceMap.Service<Server, ServerShape>()("server/Server") {} | |
| 119 | ||
| 120 | export const ServerLive = Layer.succeed(Server, { | |
| 121 | createServer, | |
| 122 | } satisfies ServerShape); | |
| 123 | ||
| 105 | 124 | const isServerNotRunningError = (error: unknown): boolean => { |
| 106 | 125 | if (!(error instanceof Error)) return false; |
| 107 | 126 | const maybeCode = (error as NodeJS.ErrnoException).code; |
| ... | ... | @@ -162,9 +181,7 @@ function websocketRawToString(raw: unknown): string | null { |
| 162 | 181 | return null; |
| 163 | 182 | } |
| 164 | 183 | |
| 165 | function stripRequestTag<T extends { _tag: string }>( | |
| 166 | body: T, | |
| 167 | ): Omit<T, "_tag"> { | |
| 184 | function stripRequestTag<T extends { _tag: string }>(body: T): Omit<T, "_tag"> { | |
| 168 | 185 | const { _tag: _ignoredTag, ...rest } = body; |
| 169 | 186 | return rest; |
| 170 | 187 | } |
| ... | ... | @@ -185,7 +202,7 @@ type EffectRuntime = ManagedRuntime.ManagedRuntime< |
| 185 | 202 | unknown |
| 186 | 203 | >; |
| 187 | 204 | |
| 188 | export function createServer(options: ServerOptions) { | |
| 205 | export function createServer(options: ServerOptions): ServerRuntime { | |
| 189 | 206 | const { |
| 190 | 207 | port, |
| 191 | 208 | host, |
| ... | ... | @@ -200,6 +217,7 @@ export function createServer(options: ServerOptions) { |
| 200 | 217 | terminalManager = new TerminalManager(), |
| 201 | 218 | providerLayer: customProviderLayer, |
| 202 | 219 | authToken, |
| 220 | open: openService = OpenDefaultService, | |
| 203 | 221 | } = options; |
| 204 | 222 | |
| 205 | 223 | const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); |
| ... | ... | @@ -626,42 +644,7 @@ export function createServer(options: ServerOptions) { |
| 626 | 644 | return searchWorkspaceEntries(stripRequestTag(request.body)); |
| 627 | 645 | |
| 628 | 646 | 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))); | |
| 665 | 648 | return undefined; |
| 666 | 649 | } |
| 667 | 650 | |
| ... | ... | @@ -763,10 +746,7 @@ export function createServer(options: ServerOptions) { |
| 763 | 746 | ); |
| 764 | 747 | return makeProviderServiceLive({ |
| 765 | 748 | 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)); | |
| 770 | 750 | })(); |
| 771 | 751 | |
| 772 | 752 | const runtimeServicesLayer = Layer.mergeAll( |
apps/server/tsconfig.json+1-2| ... | ... | @@ -12,6 +12,5 @@ |
| 12 | 12 | } |
| 13 | 13 | ] |
| 14 | 14 | }, |
| 15 | "include": ["src", "tsdown.config.ts", "scripts", "integration"], | |
| 16 | "exclude": ["src/**/*.test.ts"] | |
| 15 | "include": ["src", "tsdown.config.ts", "scripts", "integration"] | |
| 17 | 16 | } |
packages/contracts/src/git.ts+1-1| ... | ... | @@ -68,7 +68,7 @@ export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput> |
| 68 | 68 | )({ |
| 69 | 69 | cwd: TrimmedNonEmptyString, |
| 70 | 70 | path: TrimmedNonEmptyString, |
| 71 | force: Schema.Boolean.pipe(Schema.NullOr), | |
| 71 | force: Schema.optional(Schema.Boolean), | |
| 72 | 72 | }) {} |
| 73 | 73 | |
| 74 | 74 | export 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 | 12 | Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>; |
| 13 | 13 | |
| 14 | 14 | const decodeResolvedRuleStrict = (input: unknown) => |
| 15 | Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input, { | |
| 16 | onExcessProperty: "error", | |
| 17 | }); | |
| 15 | Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input); | |
| 18 | 16 | |
| 19 | 17 | describe("keybindings contracts", () => { |
| 20 | 18 | it("parses keybinding rules", () => { |
packages/contracts/src/keybindings.ts+8-5| ... | ... | @@ -70,11 +70,14 @@ export type KeybindingWhenNode = |
| 70 | 70 | |
| 71 | 71 | export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>( |
| 72 | 72 | "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 | ) {} | |
| 78 | 81 | |
| 79 | 82 | export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( |
| 80 | 83 | Schema.isMaxLength(256), |
scripts/dev-runner.mjs+9| ... | ... | @@ -84,6 +84,15 @@ function main() { |
| 84 | 84 | VITE_WS_URL: `ws://localhost:${serverPort}`, |
| 85 | 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 | 96 | if (mode === "dev" && !env.T3CODE_LOG_WS_EVENTS) { |
| 88 | 97 | env.T3CODE_LOG_WS_EVENTS = "1"; |
| 89 | 98 | } |