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