From 734ab399ebf030dd799a5744b3e780ac5e7c6a77 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Feb 2026 12:28:18 -0800 Subject: [PATCH] keybindings --- apps/desktop/scripts/dev-electron.mjs | 5 +- apps/desktop/scripts/electron-launcher.mjs | 53 +- apps/desktop/src/main.ts | 7 +- .../integration/fixtures/providerRuntime.ts | 8 +- .../orchestrationEngine.integration.test.ts | 81 +- .../providerService.integration.test.ts | 2 +- apps/server/src/checkpointing/Diffs.ts | 4 +- apps/server/src/codexAppServerManager.ts | 19 +- apps/server/src/config.ts | 1 + apps/server/src/keybindings.test.ts | 475 +- apps/server/src/keybindings.ts | 368 +- apps/server/src/main.ts | 3 + apps/server/src/open.test.ts | 28 +- .../Layers/CheckpointReactor.test.ts | 13 +- .../orchestration/Layers/CheckpointReactor.ts | 31 +- .../Layers/OrchestrationReactor.ts | 5 +- .../Layers/ProjectionPipeline.test.ts | 5 +- .../Layers/ProjectionPipeline.ts | 19 +- .../Layers/ProjectionSnapshotQuery.test.ts | 3 +- .../Layers/ProjectionSnapshotQuery.ts | 233 +- .../Layers/ProviderCommandReactor.test.ts | 8 +- .../Layers/ProviderCommandReactor.ts | 4 +- .../Layers/ProviderRuntimeIngestion.ts | 12 +- apps/server/src/orchestration/Schemas.ts | 3 +- .../Services/ProjectionPipeline.ts | 5 +- .../src/orchestration/commandInvariants.ts | 60 +- apps/server/src/orchestration/decider.ts | 3 +- .../src/orchestration/projector.test.ts | 24 +- apps/server/src/orchestration/projector.ts | 31 +- .../Layers/OrchestrationEventStore.ts | 5 +- .../Layers/ProjectionCheckpoints.ts | 5 +- .../Layers/ProjectionThreadActivities.ts | 4 +- .../persistence/Layers/ProviderSessions.ts | 4 +- .../provider/Layers/ProviderService.test.ts | 216 +- .../Layers/ProviderSessionDirectory.test.ts | 11 +- .../Services/ProviderSessionDirectory.ts | 1 - apps/server/src/serverLayers.ts | 8 +- .../src/workspaceEntries.chunking.test.ts | 15 +- apps/server/src/workspaceEntries.test.ts | 48 +- apps/server/src/wsServer.test.ts | 1 + apps/server/src/wsServer.ts | 18 +- apps/web/src/components/BranchToolbar.tsx | 10 +- apps/web/src/components/ChatView.tsx | 43 +- apps/web/src/components/DiffPanel.tsx | 5 +- .../src/components/DiffWorkerPoolProvider.tsx | 4 +- .../src/components/ThreadTerminalDrawer.tsx | 8 +- apps/web/src/components/ui/collapsible.tsx | 10 +- apps/web/src/components/ui/sidebar.tsx | 13 +- apps/web/src/keybindings.test.ts | 4 +- apps/web/src/lib/providerReactQuery.test.ts | 10 +- apps/web/src/markdown-links.test.ts | 6 +- apps/web/src/markdown-links.ts | 3 +- apps/web/src/projectScripts.ts | 28 +- apps/web/src/routeTree.gen.ts | 140 +- apps/web/src/session-logic.ts | 9 +- .../vscode-icons-language-associations.json | 482 +- apps/web/src/vscode-icons-manifest.json | 8352 ++++++++++++++++- bun.lock | 1 + packages/contracts/package.json | 1 + packages/contracts/src/keybindings.test.ts | 108 +- packages/contracts/src/keybindings.ts | 40 +- packages/contracts/src/providerRuntime.ts | 12 +- packages/contracts/src/terminal.ts | 7 +- packages/contracts/src/ws.ts | 1 - scripts/build-desktop-dmg.mjs | 9 +- 65 files changed, 10087 insertions(+), 1068 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 03ac96b5a070e5e3a12acb02693ef27bc678c672..c1dfad2a390ed326af541aacb757b6edbdf1d7c6 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -46,10 +46,7 @@ async function shutdown(exitCode) { shuttingDown = true; try { - await Promise.race([ - monitor.destroy(), - new Promise((resolve) => setTimeout(resolve, 1_500)), - ]); + await Promise.race([monitor.destroy(), new Promise((resolve) => setTimeout(resolve, 1_500))]); } catch { // Best effort only; fallback process kill below handles stubborn children. } diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 2a6d685dea2032cc4de322aa03ba9bb46fa5110a..9d7c522781a934bc7fbd4f19e1874941a3a241b7 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -25,34 +25,22 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export const desktopDir = resolve(__dirname, ".."); function setPlistString(plistPath, key, value) { - const replaceResult = spawnSync( - "plutil", - ["-replace", key, "-string", value, plistPath], - { - encoding: "utf8", - }, - ); + const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { + encoding: "utf8", + }); if (replaceResult.status === 0) { return; } - const insertResult = spawnSync( - "plutil", - ["-insert", key, "-string", value, plistPath], - { - encoding: "utf8", - }, - ); + const insertResult = spawnSync("plutil", ["-insert", key, "-string", value, plistPath], { + encoding: "utf8", + }); if (insertResult.status === 0) { return; } - const details = [replaceResult.stderr, insertResult.stderr] - .filter(Boolean) - .join("\n"); - throw new Error( - `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(), - ); + const details = [replaceResult.stderr, insertResult.stderr].filter(Boolean).join("\n"); + throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); } function patchMainBundleInfoPlist(appBundlePath, iconPath) { @@ -81,28 +69,16 @@ function patchHelperBundleInfoPlists(appBundlePath) { continue; } - const helperPlistPath = join( - frameworksDir, - entry.name, - "Contents", - "Info.plist", - ); + const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist"); if (!existsSync(helperPlistPath)) { continue; } - const suffix = entry.name - .replace("Electron Helper", "") - .replace(".app", "") - .trim(); + const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim(); const helperName = suffix ? `${APP_DISPLAY_NAME} Helper ${suffix}` : `${APP_DISPLAY_NAME} Helper`; - const helperIdSuffix = suffix - .replace(/[()]/g, "") - .trim() - .toLowerCase() - .replace(/\s+/g, "-"); + const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-"); const helperBundleId = helperIdSuffix ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` : `${APP_BUNDLE_ID}.helper`; @@ -125,12 +101,7 @@ function buildMacLauncher(electronBinaryPath) { const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); const runtimeDir = join(desktopDir, ".electron-runtime"); const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = join( - targetAppBundlePath, - "Contents", - "MacOS", - "Electron", - ); + const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); const iconPath = join(desktopDir, "resources", "icon.icns"); const metadataPath = join(runtimeDir, "metadata.json"); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 792e78811ecf87fb1c5915145ac75ea6d9b7aa73..c4af043591bce469c0cd040dfe39767ebf8c9fe2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -126,7 +126,9 @@ function registerDesktopProtocol(): void { const staticRoot = resolveDesktopStaticDir(); if (!staticRoot) { - throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."); + throw new Error( + "Desktop static bundle missing. Build apps/server (with bundled client) first.", + ); } const staticRootResolved = path.resolve(staticRoot); @@ -160,7 +162,8 @@ function registerDesktopProtocol(): void { } function dispatchMenuAction(action: string): void { - const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; + const existingWindow = + BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; const targetWindow = existingWindow ?? createWindow(); if (!existingWindow) { mainWindow = targetWindow; diff --git a/apps/server/integration/fixtures/providerRuntime.ts b/apps/server/integration/fixtures/providerRuntime.ts index e33e1336e840d8d85054d2757d9212c95ce98a2d..c846fcb704b298e65575de9e32d8d6685f8f1db9 100644 --- a/apps/server/integration/fixtures/providerRuntime.ts +++ b/apps/server/integration/fixtures/providerRuntime.ts @@ -13,10 +13,10 @@ const THREAD_ID = ProviderThreadId.makeUnsafe("fixture-thread"); const TURN_ID = ProviderTurnId.makeUnsafe("fixture-turn"); const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-1"); -function baseEvent(eventId: string, createdAt: string): Pick< - ProviderRuntimeEvent, - "eventId" | "provider" | "sessionId" | "createdAt" -> { +function baseEvent( + eventId: string, + createdAt: string, +): Pick { return { eventId: EventId.makeUnsafe(eventId), provider: PROVIDER, diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 75a2811ecbd9980c468da24e18f70b7e2de71d28..a2d8aeff4a629456c06f8735abe7e26b79c3433b 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -29,10 +29,12 @@ const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderSessionId = (value: string): ProviderSessionId => + ProviderSessionId.makeUnsafe(value); const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); -const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); +const asApprovalRequestId = (value: string): ApprovalRequestId => + ApprovalRequestId.makeUnsafe(value); const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = asThreadId("thread-1"); @@ -45,7 +47,8 @@ function nowIso() { return new Date().toISOString(); } -const sleep = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); +const sleep = (ms: number) => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); function waitForSync( read: () => A, @@ -78,9 +81,7 @@ function runtimeBase(eventId: string, createdAt: string) { }; } -function withHarness( - use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, -) { +function withHarness(use: (harness: OrchestrationIntegrationHarness) => Effect.Effect) { return Effect.acquireUseRelease( makeOrchestrationIntegrationHarness, use, @@ -176,7 +177,9 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + THREAD_ID, (entry) => entry.session?.status === "ready" && - entry.messages.some((message) => message.role === "assistant" && message.streaming === false) && + entry.messages.some( + (message) => message.role === "assistant" && message.streaming === false, + ) && entry.checkpoints.length === 1, ); assert.equal(thread.checkpoints[0]?.status, "ready"); @@ -317,7 +320,10 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => const secondCheckpoint = secondTurnThread.checkpoints.find( (checkpoint) => checkpoint.checkpointTurnCount === 2, ); - assert.equal(secondCheckpoint?.files.some((file) => file.path === "README.md"), true); + assert.equal( + secondCheckpoint?.files.some((file) => file.path === "README.md"), + true, + ); const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ threadId: THREAD_ID, @@ -394,9 +400,8 @@ it.live("tracks approval requests and resolves pending approvals on user respons text: "Run command needing approval", }); - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => entry.activities.some((activity) => activity.kind === "approval.requested"), + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested"), ); assert.equal( thread.activities.some((activity) => activity.kind === "approval.requested"), @@ -687,39 +692,39 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ), ); -it.live("appends checkpoint.revert.failed activity when revert is requested without an active session", () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); +it.live( + "appends checkpoint.revert.failed activity when revert is requested without an active session", + () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); - yield* harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: asCommandId("cmd-checkpoint-revert-no-session"), - threadId: THREAD_ID, - turnCount: 0, - createdAt: nowIso(), - }); + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: asCommandId("cmd-checkpoint-revert-no-session"), + threadId: THREAD_ID, + turnCount: 0, + createdAt: nowIso(), + }); - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => entry.activities.some( (activity) => activity.kind === "checkpoint.revert.failed" && typeof activity.payload === "object" && activity.payload !== null, ), - ); - const failureActivity = thread.activities.find( - (activity) => activity.kind === "checkpoint.revert.failed", - ); - assert.equal(failureActivity !== undefined, true); - assert.equal( - String((failureActivity?.payload as { readonly detail?: string } | undefined)?.detail).includes( - "No active provider session", - ), - true, - ); - }), - ), + ); + const failureActivity = thread.activities.find( + (activity) => activity.kind === "checkpoint.revert.failed", + ); + assert.equal(failureActivity !== undefined, true); + assert.equal( + String( + (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail, + ).includes("No active provider session"), + true, + ); + }), + ), ); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index e3337094e93ae67574834da0b84c2d41e8dbaf68..353b7d984ceca4036ceb159ec8f50b26448f73f7 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -127,7 +127,7 @@ it.effect("replays typed runtime fixture events", () => harness: fixture.harness, sessionId: session.sessionId, userText: "hello", - response: { events: codexTurnTextFixture }, + response: { events: codexTurnTextFixture }, }); assert.deepEqual( diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index 1db56411051bca100457a0ec52baaf84db820561..c2f867b96e0e64c9f6db155f4498a799578c0181 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -6,7 +6,9 @@ export interface TurnDiffFileSummary { readonly deletions: number; } -export function parseTurnDiffFilesFromUnifiedDiff(diff: string): ReadonlyArray { +export function parseTurnDiffFilesFromUnifiedDiff( + diff: string, +): ReadonlyArray { const normalized = diff.replace(/\r\n/g, "\n").trim(); if (normalized.length === 0) { return []; diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3bb317804562ef925972a4e8e205c9d21bbd0521..b2ab09d6186c5a4bf441e2e8cac2c72418a45909 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -383,7 +383,10 @@ export class CodexAppServerManager extends EventEmitter { + async rollbackThread( + sessionId: ProviderSessionId, + numTurns: number, + ): Promise { const context = this.requireSession(sessionId); const threadId = context.session.threadId; if (!threadId) { @@ -822,19 +825,18 @@ export class CodexAppServerManager extends EventEmitter { const turn = this.readObject(turnValue); - const turnIdRaw = - this.readString(turn, "id") ?? - `${threadIdRaw}:turn:${index + 1}`; + const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); const items = this.readArray(turn, "items") ?? []; return { @@ -900,8 +902,7 @@ export class CodexAppServerManager extends EventEmitter { - const tempDirs: string[] = []; - const logger = { - warn: vi.fn(), - }; - - function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; - } - - afterEach(() => { - vi.restoreAllMocks(); - logger.warn.mockReset(); - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } +const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); +const makeKeybindingsLayer = () => + KeybindingsLive.pipe( + Layer.provideMerge( + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-server-config-test-" }); + const configPath = join(dir, "keybindings.json"); + return { keybindingsConfigPath: configPath } as ServerConfigShape; + }), + ), + ), + ); + +const toDetailResult = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((error) => error.detail), + Effect.result, + ); + +const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRule[]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)( + rules.map((rule) => KeybindingRule.makeUnsafe(rule)), + ); + yield* fileSystem.writeFileString(configPath, encoded); }); - it("parses shortcuts including plus key", () => { - assert.deepEqual(parseKeybindingShortcut("mod+j"), { - key: "j", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }); - assert.deepEqual(parseKeybindingShortcut("mod++"), { - key: "+", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }); +const readKeybindingsConfig = (configPath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const rawConfig = yield* fileSystem.readFileString(configPath); + return yield* Schema.decodeUnknownEffect(KeybindingsConfigJson)(rawConfig); }); - it("compiles valid rule with parsed when AST", () => { - const compiled = compileResolvedKeybindingRule({ - key: "mod+d", - command: "terminal.split", - when: "terminalOpen && !terminalFocus", - }); - - assert.deepEqual(compiled, { - command: "terminal.split", - shortcut: { - key: "d", +it.layer(NodeServices.layer)("keybindings", (it) => { + it.effect("parses shortcuts including plus key", () => + Effect.sync(() => { + assert.deepEqual(parseKeybindingShortcut("mod+j"), { + key: "j", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, modKey: true, - }, - whenAst: { - type: "and", - left: { type: "identifier", name: "terminalOpen" }, - right: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - }); - }); - - it("rejects invalid rules", () => { - assert.isNull( - compileResolvedKeybindingRule({ - key: "mod+shift+d+o", - command: "terminal.new", - }), - ); + }); + assert.deepEqual(parseKeybindingShortcut("mod++"), { + key: "+", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }); + }), + ); - assert.isNull( - compileResolvedKeybindingRule({ + it.effect("compiles valid rule with parsed when AST", () => + Effect.sync(() => { + const compiled = compileResolvedKeybindingRule({ key: "mod+d", command: "terminal.split", - when: "terminalFocus && (", - }), - ); + when: "terminalOpen && !terminalFocus", + }); - assert.isNull( - compileResolvedKeybindingRule({ - key: "mod+d", + assert.deepEqual(compiled, { command: "terminal.split", - when: `${"!".repeat(300)}terminalFocus`, - }), - ); - }); + shortcut: { + key: "d", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "and", + left: { type: "identifier", name: "terminalOpen" }, + right: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + }); + }), + ); - it("upserts custom keybindings to ~/.t3/keybindings.json", () => { - const fakeHome = makeTempDir("t3code-keybindings-upsert-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync( - configPath, - JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), - "utf8", - ); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - - const resolved = upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }); - const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ - key: string; - command: string; - }>; - - expect(persisted).toEqual([ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+shift+r", command: "script.run-tests.run" }, - ]); - expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true); - }); + it.effect("rejects invalid rules", () => + Effect.sync(() => { + assert.isNull( + compileResolvedKeybindingRule({ + key: "mod+shift+d+o", + command: "terminal.new", + }), + ); - it("replaces existing custom keybinding for the same command", () => { - const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync( - configPath, - JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]), - "utf8", - ); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }); - - const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ - key: string; - command: string; - }>; - expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]); - }); + assert.isNull( + compileResolvedKeybindingRule({ + key: "mod+d", + command: "terminal.split", + when: "terminalFocus && (", + }), + ); - it("refuses to overwrite malformed keybindings config", () => { - const fakeHome = makeTempDir("t3code-keybindings-malformed-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync(configPath, "{ not-json", "utf8"); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - - expect(() => - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }), - ).toThrow(/Unable to parse keybindings config/); - - expect(fs.readFileSync(configPath, "utf8")).toBe("{ not-json"); - }); + assert.isNull( + compileResolvedKeybindingRule({ + key: "mod+d", + command: "terminal.split", + when: `${"!".repeat(300)}terminalFocus`, + }), + ); + }), + ); - it("reports non-array config parse errors without duplicate prefix", () => { - const fakeHome = makeTempDir("t3code-keybindings-non-array-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync(configPath, JSON.stringify({ key: "mod+j", command: "terminal.toggle" }), "utf8"); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - - expect(() => - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }), - ).toThrow(new RegExp(`Unable to parse keybindings config at ${configPath}: expected JSON array`)); - - expect(() => - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }), - ).not.toThrow( - new RegExp(`Unable to parse keybindings config at ${configPath}: Unable to parse keybindings config at`), - ); - }); + it.effect("upserts custom keybindings to configured path", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); - it("cleans up temp files when atomic keybinding write fails", () => { - const fakeHome = makeTempDir("t3code-keybindings-atomic-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync(configPath, JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), "utf8"); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - vi.spyOn(fs, "renameSync").mockImplementation(() => { - throw new Error("rename failed"); - }); - - expect(() => - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }), - ).toThrow(/rename failed/); - - expect(fs.readFileSync(configPath, "utf8")).toBe( - JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), - ); - const tempFiles = fs - .readdirSync(configDir) - .filter((file) => file.includes("keybindings.json.") && file.endsWith(".tmp")); - expect(tempFiles).toEqual([]); - }); + const resolved = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + + assert.deepEqual(persistedView, [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + assert.isTrue(resolved.some((entry) => entry.command === "script.run-tests.run")); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("replaces existing custom keybinding for the same command", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+shift+r", command: "script.run-tests.run" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("refuses to overwrite malformed keybindings config", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); + + const result = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }).pipe(toDetailResult); + assertFailure(result, "expected JSON array"); + + const persistedRaw = yield* fs.readFileString(keybindingsConfigPath); + assert.equal(persistedRaw, "{ not-json"); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("reports non-array config parse errors without duplicate prefix", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + yield* fs.writeFileString( + keybindingsConfigPath, + '{"key":"mod+j","command":"terminal.toggle"}', + ); + + const firstResult = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }).pipe(toDetailResult); + assertFailure(firstResult, "expected JSON array"); + + const secondResult = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }).pipe(toDetailResult); + assertFailure(secondResult, "expected JSON array"); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("fails when config directory is not writable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + const { dirname } = yield* Path.Path; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); + yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); + + const result = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }).pipe(toDetailResult); + assertFailure(result, "failed to write keybindings config"); + + yield* fs.chmod(dirname(keybindingsConfigPath), 0o700); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+j", command: "terminal.toggle" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("caches loaded resolved config across repeated reads", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); + + const [first, second] = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + const firstLoad = yield* keybindings.loadResolvedKeybindingsConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+x", command: "script.setup.run" }, + ]); + const secondLoad = yield* keybindings.loadResolvedKeybindingsConfig; + return [firstLoad, secondLoad] as const; + }); + + assert.deepEqual(first, second); + assert.isTrue(second.some((entry) => entry.command === "terminal.toggle")); + assert.isFalse(second.some((entry) => entry.command === "script.setup.run")); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("updates cached resolved config after upsert", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); + + const loadedAfterUpsert = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + yield* keybindings.loadResolvedKeybindingsConfig; + yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); + return yield* keybindings.loadResolvedKeybindingsConfig; + }); + + assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run")); + assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "terminal.toggle")); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); }); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 70f43dfeb277703a1bff3dc17e49e6c854f05939..9942b5c61274412f1ab45a970dc65610e67725c5 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -1,20 +1,42 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - import { KeybindingRule, KeybindingsConfig, KeybindingShortcut, KeybindingWhenNode, + MAX_KEYBINDINGS_COUNT, + MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { Mutable } from "effect/Types"; -import { Schema } from "effect"; +import { + Array, + Cause, + Effect, + FileSystem, + Path, + Layer, + Option, + Predicate, + Ref, + Schema, + SchemaIssue, + SchemaTransformation, + ServiceMap, +} from "effect"; +import { ServerConfig } from "./config"; -interface KeybindingsLogger { - warn(message: string, context?: Record): void; +export class KeybindingsConfigError extends Schema.TaggedErrorClass()( + "KeybindingsConfigParseError", + { + configPath: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`; + } } type WhenToken = @@ -25,8 +47,6 @@ type WhenToken = | { type: "lparen" } | { type: "rparen" }; -const MAX_WHEN_EXPRESSION_DEPTH = 256; - export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, @@ -44,6 +64,7 @@ function normalizeKeyToken(token: string): string { return token; } +/** @internal - Exported for testing */ export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { const rawTokens = value .toLowerCase() @@ -157,7 +178,7 @@ function tokenizeWhenExpression(expression: string): WhenToken[] | null { return tokens; } -export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { +function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { const tokens = tokenizeWhenExpression(expression); if (!tokens || tokens.length === 0) return null; let index = 0; @@ -242,6 +263,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe return ast; } +/** @internal - Exported for testing */ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { const key = rule.key.trim(); if (key.length === 0) return null; @@ -270,114 +292,86 @@ export function compileResolvedKeybindingsConfig( ): ResolvedKeybindingsConfig { const compiled: Mutable = []; for (const rule of config) { - const resolved = compileResolvedKeybindingRule(rule); - if (!resolved) continue; - compiled.push(resolved); + const result = Schema.decodeExit(ResolvedKeybindingFromConfig)(rule); + if (result._tag === "Success") { + compiled.push(result.value); + } } return compiled; } -function compileDefaultKeybindings(): ResolvedKeybindingsConfig { - const resolved: Mutable = []; - for (const rule of DEFAULT_KEYBINDINGS) { - const compiled = compileResolvedKeybindingRule(rule); - if (!compiled) { - throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); - } - resolved.push(compiled); - } - return resolved; -} +export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( + Schema.decodeTo( + Schema.toType(ResolvedKeybindingRule), + SchemaTransformation.transformOrFail({ + decode: (rule) => + Effect.succeed(compileResolvedKeybindingRule(rule)).pipe( + Effect.filterOrFail( + Predicate.isNotNull, + () => + new SchemaIssue.InvalidValue(Option.some(rule), { + title: "Invalid keybinding rule", + }), + ), + Effect.map((resolved) => ResolvedKeybindingRule.makeUnsafe(resolved)), + ), -const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); -const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json"); -const MAX_KEYBINDINGS = 256; + encode: (resolved) => + Effect.gen(function* () { + const key = encodeShortcut(resolved.shortcut); + if (!key) { + return yield* Effect.fail( + new SchemaIssue.InvalidValue(Option.some(resolved), { + title: "Resolved shortcut cannot be encoded to key string", + }), + ); + } -class KeybindingsConfigParseError extends Error { - constructor(configPath: string, detail: string, options?: { cause?: unknown }) { - super(`Unable to parse keybindings config at ${configPath}: ${detail}`, options); - } -} + const when = resolved.whenAst ? encodeWhenAst(resolved.whenAst) : undefined; + return { + key, + command: resolved.command, + when, + }; + }), + }), + ), +); -function resolveKeybindingsConfigPath(): string { - return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH); -} +export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFromConfig).check( + Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), +); -function loadCustomKeybindingsConfig( - logger: KeybindingsLogger, - options?: { - throwOnUnreadableConfig?: boolean; - }, -): KeybindingsConfig { - const configPath = resolveKeybindingsConfigPath(); - try { - const raw = fs.readFileSync(configPath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) { - if (options?.throwOnUnreadableConfig) { - throw new KeybindingsConfigParseError(configPath, "expected JSON array"); - } - logger.warn("ignoring keybindings config with unsupported format; expected array", { - path: configPath, - }); - return []; - } - - const sanitized: Mutable = []; - let invalidEntries = 0; - for (const entry of parsed) { - const result = Schema.decodeExit(KeybindingRule)(entry); - if (result._tag === "Success") { - if (!compileResolvedKeybindingRule(result.value)) { - invalidEntries += 1; - continue; - } - sanitized.push(result.value); - continue; - } - invalidEntries += 1; - } - if (invalidEntries > 0) { - logger.warn("ignoring invalid keybinding entries", { - path: configPath, - invalidEntries, - totalEntries: parsed.length, - }); - } - return sanitized; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return []; - } - if (options?.throwOnUnreadableConfig) { - if (error instanceof KeybindingsConfigParseError) { - throw error; - } - throw new KeybindingsConfigParseError( - configPath, - error instanceof Error ? error.message : String(error), - { cause: error }, - ); - } - logger.warn("ignoring malformed keybindings config", { - path: configPath, - error: error instanceof Error ? error.message : String(error), - }); - return []; - } +function encodeShortcut(shortcut: KeybindingShortcut): string | null { + const modifiers: string[] = []; + if (shortcut.modKey) modifiers.push("mod"); + if (shortcut.metaKey) modifiers.push("meta"); + if (shortcut.ctrlKey) modifiers.push("ctrl"); + if (shortcut.altKey) modifiers.push("alt"); + if (shortcut.shiftKey) modifiers.push("shift"); + if (!shortcut.key || shortcut.key.includes("+")) return null; + const key = shortcut.key === " " ? "space" : shortcut.key; + return [...modifiers, key].join("+"); } -function writeConfigAtomically(configPath: string, config: KeybindingsConfig): void { - const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`; - try { - fs.writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - fs.renameSync(tempPath, configPath); - } catch (error) { - fs.rmSync(tempPath, { force: true }); - throw error; +function encodeWhenAst(node: KeybindingWhenNode): string { + switch (node.type) { + case "identifier": + return node.name; + case "not": + return `!(${encodeWhenAst(node.node)})`; + case "and": + return `(${encodeWhenAst(node.left)} && ${encodeWhenAst(node.right)})`; + case "or": + return `(${encodeWhenAst(node.left)} || ${encodeWhenAst(node.right)})`; } } +const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); + +const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); +const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); + function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { if (custom.length === 0) { return [...DEFAULT_RESOLVED_KEYBINDINGS]; @@ -389,55 +383,133 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve ); const merged = [...retainedDefaults, ...custom]; - if (merged.length <= MAX_KEYBINDINGS) { + if (merged.length <= MAX_KEYBINDINGS_COUNT) { return merged; } // Keep the latest rules when the config exceeds max size; later rules have higher precedence. - return merged.slice(-MAX_KEYBINDINGS); + return merged.slice(-MAX_KEYBINDINGS_COUNT); } -export function loadResolvedKeybindingsConfig( - logger: KeybindingsLogger, -): ResolvedKeybindingsConfig { - const customConfig = loadCustomKeybindingsConfig(logger); - const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig); - const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command)); - const mergedBeforeCapLength = - DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) - .length + compiledCustomConfig.length; - const merged = mergeWithDefaultKeybindings(compiledCustomConfig); - if (mergedBeforeCapLength > MAX_KEYBINDINGS) { - logger.warn("truncating merged keybindings config to max entries", { - path: resolveKeybindingsConfigPath(), - maxEntries: MAX_KEYBINDINGS, - }); - } - return merged; +export interface KeybindingsShape { + readonly loadResolvedKeybindingsConfig: Effect.Effect< + readonly ResolvedKeybindingRule[], + KeybindingsConfigError + >; + readonly upsertKeybindingRule: ( + rule: KeybindingRule, + ) => Effect.Effect; } -export function upsertKeybindingRule( - logger: KeybindingsLogger, - rule: KeybindingRule, -): ResolvedKeybindingsConfig { - const configPath = resolveKeybindingsConfigPath(); - const customConfig = loadCustomKeybindingsConfig(logger, { - throwOnUnreadableConfig: true, +export class Keybindings extends ServiceMap.Service()( + "server/Keybindings", +) {} + +const makeKeybindings = Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cachedResolvedConfig = yield* Ref.make(null); + + const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< + readonly KeybindingRule[], + KeybindingsConfigError + > { + const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe( + Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)), + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: keybindingsConfigPath, + detail: "expected JSON array", + cause, + }), + ), + ); + + return yield* Effect.forEach(rawConfig, (entry) => + Effect.gen(function* () { + const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry); + if (decodedRule._tag === "Failure") { + yield* Effect.logWarning("ignoring invalid keybinding entry", { + path: keybindingsConfigPath, + entry, + error: Cause.pretty(decodedRule.cause), + }); + return null; + } + const resolved = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value); + if (resolved._tag === "Failure") { + yield* Effect.logWarning("ignoring invalid keybinding entry", { + path: keybindingsConfigPath, + entry, + error: Cause.pretty(resolved.cause), + }); + return null; + } + return decodedRule.value; + }), + ).pipe(Effect.map(Array.filter(Predicate.isNotNull))); }); - const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule]; - const cappedConfig = - nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig; - - if (nextConfig.length > MAX_KEYBINDINGS) { - logger.warn("truncating keybindings config to max entries", { - path: configPath, - maxEntries: MAX_KEYBINDINGS, - }); - } - - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - writeConfigAtomically(configPath, cappedConfig); - - const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig); - return mergeWithDefaultKeybindings(compiledCustomConfig); -} + + const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { + const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; + + return Schema.encodeEffect(KeybindingsConfigJson)( + rules.map((rule) => KeybindingRule.makeUnsafe(rule)), + ).pipe( + Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), + Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), + Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: keybindingsConfigPath, + detail: "failed to write keybindings config", + cause, + }), + ), + ); + }; + + const loadResolvedFromCacheOrDisk = Effect.gen(function* () { + const cached = yield* Ref.get(cachedResolvedConfig); + if (cached) return cached; + const resolved = yield* loadCustomKeybindingsConfig().pipe( + Effect.map(compileResolvedKeybindingsConfig), + Effect.map(mergeWithDefaultKeybindings), + ); + yield* Ref.set(cachedResolvedConfig, resolved); + return resolved; + }); + + return { + loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk, + upsertKeybindingRule: (rule) => + Effect.gen(function* () { + const customConfig = yield* loadCustomKeybindingsConfig(); + const nextConfig = [ + ...customConfig.filter((entry) => entry.command !== rule.command), + rule, + ]; + const cappedConfig = + nextConfig.length > MAX_KEYBINDINGS_COUNT + ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) + : nextConfig; + if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { + yield* Effect.logWarning("truncating keybindings config to max entries", { + path: keybindingsConfigPath, + maxEntries: MAX_KEYBINDINGS_COUNT, + }); + } + yield* writeConfigAtomically(cappedConfig); + const nextResolved = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig(cappedConfig), + ); + yield* Ref.set(cachedResolvedConfig, nextResolved); + return nextResolved; + }), + } satisfies KeybindingsShape; +}); + +export const KeybindingsLive = Layer.effect(Keybindings, makeKeybindings); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 4d0691ce05bee8b8358f6dcb9f4ee2c3d6f8502f..0dd164cb99478c4eb8bfa3fe93ce55f8b770e774 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -120,6 +120,8 @@ const ServerConfigLive = (input: CliInput) => }); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; + const { join } = yield* Path.Path; + const keybindingsConfigPath = join(stateDir, "keybindings.json"); const host = Option.getOrUndefined(input.host) ?? env.host ?? @@ -129,6 +131,7 @@ const ServerConfigLive = (input: CliInput) => mode, port, cwd: cliConfig.cwd, + keybindingsConfigPath, host, stateDir, staticDir, diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index f4b88d052a329ab9b7c48a4d2e85a35e277f1427..c3d521eb90a6ee5c970fd4cf2826ec2afce61942 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -11,18 +11,16 @@ describe("resolveEditorLaunch", () => { }); it("maps file-manager editor to OS open commands", () => { - expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "darwin")).toEqual( - { - command: "open", - args: ["/tmp/workspace"], - }, - ); - expect(resolveEditorLaunch({ cwd: "C:\\workspace", editor: "file-manager" }, "win32")).toEqual( - { - command: "explorer", - args: ["C:\\workspace"], - }, - ); + expect( + resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "darwin"), + ).toEqual({ + command: "open", + args: ["/tmp/workspace"], + }); + expect(resolveEditorLaunch({ cwd: "C:\\workspace", editor: "file-manager" }, "win32")).toEqual({ + command: "explorer", + args: ["C:\\workspace"], + }); expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "linux")).toEqual( { command: "xdg-open", @@ -40,8 +38,8 @@ describe("launchDetached", () => { }); it("rejects when command does not exist", async () => { - await expect( - launchDetached(`t3code-no-such-command-${Date.now()}`, []), - ).rejects.toBeInstanceOf(Error); + await expect(launchDetached(`t3code-no-such-command-${Date.now()}`, [])).rejects.toBeInstanceOf( + Error, + ); }); }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index caa4d1f8d4a6e6da8463c9ceca149b7186fcaa54..5985a2219c8347ac16aba60f2aa14f5f71b58149 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -41,8 +41,7 @@ const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => - ProviderThreadId.makeUnsafe(value); +const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef { @@ -474,11 +473,12 @@ describe("CheckpointReactor", () => { status: "completed", }); - const thread = await waitForThread( - harness.engine, - (entry) => entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3), + const thread = await waitForThread(harness.engine, (entry) => + entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3), + ); + expect(thread.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( + true, ); - expect(thread.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(true); }); it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { @@ -581,5 +581,4 @@ describe("CheckpointReactor", () => { ); expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); }); - }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index eb8b5d71342e8e55d0935dc20688ff4a32c7a7be..2be629d7c53927c44e4ccf176d7ba40fe4146b88 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -347,13 +347,10 @@ const make = Effect.gen(function* () { onSome: (runtime) => runtime.cwd, }); if (!checkpointCwd) { - yield* Effect.logWarning( - "checkpoint pre-turn capture skipped: no workspace cwd", - { - threadId: thread.id, - turnId, - }, - ); + yield* Effect.logWarning("checkpoint pre-turn capture skipped: no workspace cwd", { + threadId: thread.id, + turnId, + }); return; } @@ -377,10 +374,17 @@ const make = Effect.gen(function* () { }); const ensurePreTurnBaselineFromDomainTurnStart = Effect.fnUntraced(function* ( - event: Extract, + event: Extract< + OrchestrationEvent, + { type: "thread.turn-start-requested" | "thread.message-sent" } + >, ) { if (event.type === "thread.message-sent") { - if (event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null) { + if ( + event.payload.role !== "user" || + event.payload.streaming || + event.payload.turnId !== null + ) { return; } } @@ -403,12 +407,9 @@ const make = Effect.gen(function* () { onSome: (runtime) => runtime.cwd, }); if (!checkpointCwd) { - yield* Effect.logWarning( - "checkpoint pre-turn capture skipped: no workspace cwd", - { - threadId, - }, - ); + yield* Effect.logWarning("checkpoint pre-turn capture skipped: no workspace cwd", { + threadId, + }); return; } diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index d9719c428c521676264421603affbc6e4b0245de..1e498885a05f80052f16d17b20ba2f306e2660ed 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -24,4 +24,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { } satisfies OrchestrationReactorShape; }); -export const OrchestrationReactorLive = Layer.effect(OrchestrationReactor, makeOrchestrationReactor); +export const OrchestrationReactorLive = Layer.effect( + OrchestrationReactor, + makeOrchestrationReactor, +); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 03b2a6d2c69d9296bb5019622d1cc0cb15897af8..ce775b32fc2d6b003a979a76e983fdb2751f2e86 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -479,7 +479,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { defaultModel: "gpt-5", }); - const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModel: string }>` + const projectRows = yield* sql<{ + readonly scriptsJson: string; + readonly defaultModel: string; + }>` SELECT scripts_json AS "scriptsJson", default_model AS "defaultModel" diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 351ec9bba4ce2b79a9f5fe54994f2e2c114f8c12..b890adfbabd7fad51ae2236976fa0e260af28558 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,7 +1,4 @@ -import { - ApprovalRequestId, - type OrchestrationEvent, -} from "@t3tools/contracts"; +import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts"; import { Effect, Layer, Option, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -119,8 +116,7 @@ function retainProjectionMessagesAfterRevert( if (missingAssistantCount > 0) { const fallbackAssistantMessages = messages .filter( - (message) => - message.role === "assistant" && !retainedMessageIds.has(message.messageId), + (message) => message.role === "assistant" && !retainedMessageIds.has(message.messageId), ) .toSorted( (left, right) => @@ -142,9 +138,7 @@ function retainProjectionActivitiesAfterRevert( turnCount: number, ): ReadonlyArray { const retainedTurnIds = new Set( - turns - .filter((turn) => turn.turnCount <= turnCount) - .map((turn) => turn.turnId), + turns.filter((turn) => turn.turnCount <= turnCount).map((turn) => turn.turnId), ); return activities.filter( (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), @@ -354,7 +348,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ threadId: event.payload.threadId, }); - const existingMessage = existingRows.find((row) => row.messageId === event.payload.messageId); + const existingMessage = existingRows.find( + (row) => row.messageId === event.payload.messageId, + ); const nextText = existingMessage && event.payload.streaming ? `${existingMessage.text}${event.payload.text}` @@ -496,7 +492,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { yield* projectionThreadTurnRepository.upsert({ ...existingTurn.value, status: existingTurn.value.status === "completed" ? "completed" : "running", - userMessageId: existingTurn.value.userMessageId ?? (pendingTurnStart?.messageId ?? null), + userMessageId: + existingTurn.value.userMessageId ?? pendingTurnStart?.messageId ?? null, startedAt: existingTurn.value.startedAt, }); } else { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 867d932e3507241353dde6bcc2f03b86a16766d8..3e55887e20bdd87a26bf3b0e1031a71d14b0108a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -23,7 +23,8 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderSessionId = (value: string): ProviderSessionId => + ProviderSessionId.makeUnsafe(value); const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const projectionSnapshotLayer = it.layer( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d2817933f26043af35501d931a2a5ea32503a5b8..d9d09c30ffaf9b261e54010d311bbe4485424af7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -245,10 +245,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => - sql.withTransaction( - Effect.gen(function* () { - const [projectRows, threadRows, messageRows, activityRows, sessionRows, checkpointRows, stateRows] = - yield* Effect.all([ + sql + .withTransaction( + Effect.gen(function* () { + const [ + projectRows, + threadRows, + messageRows, + activityRows, + sessionRows, + checkpointRows, + stateRows, + ] = yield* Effect.all([ listProjectRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -307,122 +315,127 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ]); - const messagesByThread = new Map>(); - const activitiesByThread = new Map>(); - const checkpointsByThread = new Map>(); - const sessionsByThread = new Map(); + const messagesByThread = new Map>(); + const activitiesByThread = new Map>(); + const checkpointsByThread = new Map>(); + const sessionsByThread = new Map(); - let updatedAt: string | null = null; + let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } - for (const row of messageRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - const threadMessages = messagesByThread.get(row.threadId) ?? []; - threadMessages.push({ - id: row.messageId, - role: row.role, - text: row.text, - turnId: row.turnId, - streaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - messagesByThread.set(row.threadId, threadMessages); - } + for (const row of messageRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadMessages = messagesByThread.get(row.threadId) ?? []; + threadMessages.push({ + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, threadMessages); + } - for (const row of activityRows) { - updatedAt = maxIso(updatedAt, row.createdAt); - const threadActivities = activitiesByThread.get(row.threadId) ?? []; - threadActivities.push({ - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - createdAt: row.createdAt, - }); - activitiesByThread.set(row.threadId, threadActivities); - } + for (const row of activityRows) { + updatedAt = maxIso(updatedAt, row.createdAt); + const threadActivities = activitiesByThread.get(row.threadId) ?? []; + threadActivities.push({ + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }); + activitiesByThread.set(row.threadId, threadActivities); + } - for (const row of checkpointRows) { - updatedAt = maxIso(updatedAt, row.completedAt); - const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; - threadCheckpoints.push({ - turnId: row.turnId, - checkpointTurnCount: row.checkpointTurnCount, - checkpointRef: row.checkpointRef, - status: row.status, - files: row.files, - assistantMessageId: row.assistantMessageId, - completedAt: row.completedAt, - }); - checkpointsByThread.set(row.threadId, threadCheckpoints); - } + for (const row of checkpointRows) { + updatedAt = maxIso(updatedAt, row.completedAt); + const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; + threadCheckpoints.push({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + }); + checkpointsByThread.set(row.threadId, threadCheckpoints); + } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - sessionsByThread.set(row.threadId, { - threadId: row.threadId, - status: row.status, - providerName: row.providerName, - providerSessionId: row.providerSessionId, - providerThreadId: row.providerThreadId, - activeTurnId: row.activeTurnId, - lastError: row.lastError, - updatedAt: row.updatedAt, - }); - } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + sessionsByThread.set(row.threadId, { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + providerSessionId: row.providerSessionId, + providerThreadId: row.providerThreadId, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }); + } - const projects: Array = projectRows.map((row) => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModel: row.defaultModel, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })); + const projects: Array = projectRows.map((row) => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModel: row.defaultModel, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })); - const threads: Array = threadRows.map((row) => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - model: row.model, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurnId: row.latestTurnId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - })); + const threads: Array = threadRows.map((row) => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + model: row.model, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurnId: row.latestTurnId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + })); - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects, - threads, - updatedAt: updatedAt ?? new Date(0).toISOString(), - }; + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects, + threads, + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; - return yield* decodeReadModel(snapshot).pipe( - Effect.mapError(toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel")), - ); - }), - ).pipe(Effect.mapError(toProjectionRepositoryError("ProjectionSnapshotQuery.getSnapshot:query"))); + return yield* decodeReadModel(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + ), + ); + }), + ) + .pipe( + Effect.mapError(toProjectionRepositoryError("ProjectionSnapshotQuery.getSnapshot:query")), + ); return { getSnapshot, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d8c58df8531d3b40ef1f371a816e1fcd89d70488..0b65741a3f2cf15c8906ad09591426a80058e945 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -31,14 +31,12 @@ const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); -const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); +const asApprovalRequestId = (value: string): ApprovalRequestId => + ApprovalRequestId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -async function waitFor( - predicate: () => boolean, - timeoutMs = 2000, -): Promise { +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { if (predicate()) { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ae10b413798e2ff03a49b5c87369f0edbad269c3..90baa6c5f06c8dc32111a0f48bfdcf7dbc24aec9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -344,7 +344,9 @@ const make = Effect.gen(function* () { const queue = yield* Queue.unbounded(); yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); - yield* Effect.forkScoped(Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processDomainEvent)))); + yield* Effect.forkScoped( + Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processDomainEvent))), + ); yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 5b622b442e5cdd4989654bee7aeea80142875099..a3ab7e7c786874a3e6cbd93b7dbee189af3f21d2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -29,7 +29,9 @@ function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 1)}...` : value; } -function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray { +function runtimeEventToActivities( + event: ProviderRuntimeEvent, +): ReadonlyArray { switch (event.type) { case "approval.requested": { return [ @@ -184,14 +186,16 @@ const make = Effect.gen(function* () { event.type === "turn.started" || event.type === "turn.completed" ) { - const activeTurnId = event.type === "turn.started" ? (toTurnId(event.turnId) ?? null) : null; + const activeTurnId = + event.type === "turn.started" ? (toTurnId(event.turnId) ?? null) : null; const providerThreadIdFromEvent = event.type === "thread.started" ? ProviderThreadId.makeUnsafe(event.threadId) : event.threadId !== undefined ? ProviderThreadId.makeUnsafe(event.threadId) : null; - const providerThreadId = providerThreadIdFromEvent ?? (thread.session?.providerThreadId ?? null); + const providerThreadId = + providerThreadIdFromEvent ?? thread.session?.providerThreadId ?? null; const status = event.type === "turn.started" ? "running" @@ -199,7 +203,7 @@ const make = Effect.gen(function* () { ? "stopped" : event.type === "turn.completed" && event.status === "failed" ? "error" - : "ready"; + : "ready"; const lastError = event.type === "turn.completed" && event.status === "failed" ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed") diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 8b67722e026cdd58ce36fefaa683f619a7f8645f..03a5e35057f22925231be4e966520b6c71afe67d 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -33,7 +33,8 @@ export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema; export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema; -export const ThreadTurnInterruptRequestedPayload = ContractsThreadTurnInterruptRequestedPayloadSchema; +export const ThreadTurnInterruptRequestedPayload = + ContractsThreadTurnInterruptRequestedPayloadSchema; export const ThreadApprovalResponseRequestedPayload = ContractsThreadApprovalResponseRequestedPayloadSchema; export const ThreadCheckpointRevertRequestedPayload = diff --git a/apps/server/src/orchestration/Services/ProjectionPipeline.ts b/apps/server/src/orchestration/Services/ProjectionPipeline.ts index 8f8d649c1354fac07f64d244b7d39df3d6d1bfe3..9457aaf8d9b3cf684a76da26e8dd43cb70f97ad1 100644 --- a/apps/server/src/orchestration/Services/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Services/ProjectionPipeline.ts @@ -2,7 +2,10 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { OrchestrationEventStoreError, ProjectionRepositoryError } from "../../persistence/Errors.ts"; +import type { + OrchestrationEventStoreError, + ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; export type OrchestrationProjectionPipelineError = | OrchestrationEventStoreError diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index ca615b8cdb6d8174f91677fb4cb5fb452b867654..6acb9c82ba54b9d56ad8d66cf59eae975eb8aed8 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -38,13 +38,11 @@ export function listThreadsByProjectId( return readModel.threads.filter((thread) => thread.projectId === projectId); } -export function requireProject( - input: { - readonly readModel: OrchestrationReadModel; - readonly command: OrchestrationCommand; - readonly projectId: ProjectId; - }, -): Effect.Effect { +export function requireProject(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly projectId: ProjectId; +}): Effect.Effect { const project = findProjectById(input.readModel, input.projectId); if (project) { return Effect.succeed(project); @@ -57,13 +55,11 @@ export function requireProject( ); } -export function requireProjectAbsent( - input: { - readonly readModel: OrchestrationReadModel; - readonly command: OrchestrationCommand; - readonly projectId: ProjectId; - }, -): Effect.Effect { +export function requireProjectAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly projectId: ProjectId; +}): Effect.Effect { if (!findProjectById(input.readModel, input.projectId)) { return Effect.void; } @@ -75,13 +71,11 @@ export function requireProjectAbsent( ); } -export function requireThread( - input: { - readonly readModel: OrchestrationReadModel; - readonly command: OrchestrationCommand; - readonly threadId: ThreadId; - }, -): Effect.Effect { +export function requireThread(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly threadId: ThreadId; +}): Effect.Effect { const thread = findThreadById(input.readModel, input.threadId); if (thread) { return Effect.succeed(thread); @@ -94,13 +88,11 @@ export function requireThread( ); } -export function requireThreadAbsent( - input: { - readonly readModel: OrchestrationReadModel; - readonly command: OrchestrationCommand; - readonly threadId: ThreadId; - }, -): Effect.Effect { +export function requireThreadAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly threadId: ThreadId; +}): Effect.Effect { if (!findThreadById(input.readModel, input.threadId)) { return Effect.void; } @@ -112,13 +104,11 @@ export function requireThreadAbsent( ); } -export function requireNonNegativeInteger( - input: { - readonly commandType: OrchestrationCommand["type"]; - readonly field: string; - readonly value: number; - }, -): Effect.Effect { +export function requireNonNegativeInteger(input: { + readonly commandType: OrchestrationCommand["type"]; + readonly field: string; + readonly value: number; +}): Effect.Effect { if (Number.isInteger(input.value) && input.value >= 0) { return Effect.void; } diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d29258c153d95d3b1f86755e43a63189d2464cef..b716543d3d51b2b3134a572ba0f0f9649fa3a3f0 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -491,7 +491,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command.activity.payload !== null && "requestId" in command.activity.payload && typeof (command.activity.payload as { requestId?: unknown }).requestId === "string" - ? ((command.activity.payload as { requestId: string }).requestId as OrchestrationEvent["metadata"]["requestId"]) + ? ((command.activity.payload as { requestId: string }) + .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; return { ...withEventBase({ diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 798aa710024042ed1c74d3dbfc1a89c872f09742..e03c260db817aa84b05031ed145572a41c3116db 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -1,4 +1,10 @@ -import { CommandId, EventId, ProjectId, ThreadId, type OrchestrationEvent } from "@t3tools/contracts"; +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; @@ -484,13 +490,15 @@ describe("orchestration projector", () => { ); const thread = afterRevert.threads[0]; - expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([ - { role: "user", text: "First edit" }, - { role: "assistant", text: "Updated README to v2.\n" }, - ]); - expect(thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId }))).toEqual([ - { id: "activity-1", turnId: "turn-1" }, - ]); + expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual( + [ + { role: "user", text: "First edit" }, + { role: "assistant", text: "Updated README to v2.\n" }, + ], + ); + expect( + thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), + ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); expect(thread?.latestTurnId).toBe("turn-1"); }); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 1b6d4d6cf75f91742788f215aaae0e1a3e3f72f7..6a3f8fe9c12baf3b88f49e9b15bf0ba776f13885 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,8 +1,4 @@ -import type { - OrchestrationEvent, - OrchestrationReadModel, - ThreadId, -} from "@t3tools/contracts"; +import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, @@ -147,19 +143,16 @@ export function projectEvent( return { ...nextBase, projects: existing - ? nextBase.projects.map((entry) => (entry.id === payload.projectId ? nextProject : entry)) + ? nextBase.projects.map((entry) => + entry.id === payload.projectId ? nextProject : entry, + ) : [...nextBase.projects, nextProject], }; }), ); case "project.meta-updated": - return decodeForEvent( - ProjectMetaUpdatedPayload, - event.payload, - event.type, - "payload", - ).pipe( + return decodeForEvent(ProjectMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ ...nextBase, projects: nextBase.projects.map((project) => @@ -247,12 +240,7 @@ export function projectEvent( ); case "thread.meta-updated": - return decodeForEvent( - ThreadMetaUpdatedPayload, - event.payload, - event.type, - "payload", - ).pipe( + return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { @@ -415,13 +403,10 @@ export function projectEvent( retainedTurnIds, payload.turnCount, ); - const activities = retainThreadActivitiesAfterRevert( - thread.activities, - retainedTurnIds, - ); + const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); const latestTurnId = - checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null; + checkpoints.length > 0 ? (checkpoints[checkpoints.length - 1]?.turnId ?? null) : null; return { ...nextBase, diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index ad36142afbf9b59f91e9f7155169f5053d78cd48..6f166417b5c9b288831961c9ab1d6e2bfa16743b 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -88,10 +88,7 @@ function inferActorKind( return "client"; } -function toPersistenceSqlOrDecodeError( - sqlOperation: string, - decodeOperation: string, -) { +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): OrchestrationEventStoreError => Schema.isSchemaError(cause) ? toPersistenceDecodeError(decodeOperation)(cause) diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts index a8d65914bb7b8347bf9cfee9cf880b342b94861e..0a36aba76caf552d57a4ee5dbd2419b01df580aa 100644 --- a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts @@ -3,10 +3,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Option, Schema, Struct } from "effect"; -import { - toPersistenceDecodeError, - toPersistenceSqlError, -} from "../Errors.ts"; +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { DeleteByThreadIdInput, GetByThreadAndTurnCountInput, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 21cf24cd88140e16b328ec216dbda7594ad1c9c5..c2285f95de7679058f72418e762165f2495936df 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -111,7 +111,9 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map((rows) => rows as ReadonlyArray>), + Effect.map( + (rows) => rows as ReadonlyArray>, + ), ); const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => diff --git a/apps/server/src/persistence/Layers/ProviderSessions.ts b/apps/server/src/persistence/Layers/ProviderSessions.ts index c31a6a53fa5f9dad2fce2f876c0883ba1a777031..46f72da57454c53d56917691309413bbc864065c 100644 --- a/apps/server/src/persistence/Layers/ProviderSessions.ts +++ b/apps/server/src/persistence/Layers/ProviderSessions.ts @@ -72,9 +72,7 @@ function toPersistenceError( }); } -function toEntry( - row: Schema.Schema.Type, -): ProviderSessionEntry { +function toEntry(row: Schema.Schema.Type): ProviderSessionEntry { return { sessionId: row.sessionId, provider: row.provider, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 8b63284967544e299a6f34073a13d8e6e8ba4859..5d47828922c64e2fa3bee2e1ef761fe1c7e3d553 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -129,7 +129,10 @@ function makeFakeCodexAdapter() { ( _sessionId: ProviderSessionId, ): Effect.Effect< - { threadId: ProviderThreadId; turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }> }, + { + threadId: ProviderThreadId; + turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }>; + }, ProviderAdapterError > => Effect.succeed({ @@ -290,101 +293,103 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }).pipe(Effect.provide(NodeServices.layer)), ); -it.effect("ProviderServiceLive restores rollback routing after restart using persisted thread mapping", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); - const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( - Layer.provide(persistenceLayer), - ); - - const firstCodex = makeFakeCodexAdapter(); - const firstRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(firstCodex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; - - const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(runtimeRepositoryLayer), - ); - const firstProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), - Layer.provide(firstDirectoryLayer), - ); - - const startedSession = yield* Effect.gen(function* () { - const provider = yield* ProviderService; - return yield* provider.startSession({ - provider: "codex", - cwd: "/tmp/project", - }); - }).pipe(Effect.provide(firstProviderLayer)); - - yield* Effect.gen(function* () { - const provider = yield* ProviderService; - yield* provider.stopAll(); - }).pipe(Effect.provide(firstProviderLayer)); - - const persistedAfterStopAll = yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getBySessionId({ - providerSessionId: startedSession.sessionId, - }); - }).pipe(Effect.provide(runtimeRepositoryLayer)); - assert.equal(Option.isSome(persistedAfterStopAll), true); - if (Option.isSome(persistedAfterStopAll)) { - assert.equal(persistedAfterStopAll.value.status, "stopped"); - assert.deepEqual(persistedAfterStopAll.value.resumeCursor, { - resumeThreadId: startedSession.threadId, - }); - } - - const secondCodex = makeFakeCodexAdapter(); - const secondRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(secondCodex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; - const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(runtimeRepositoryLayer), - ); - const secondProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), - Layer.provide(secondDirectoryLayer), - ); - - secondCodex.startSession.mockClear(); - secondCodex.rollbackThread.mockClear(); - - yield* Effect.gen(function* () { - const provider = yield* ProviderService; - yield* provider.rollbackConversation({ - sessionId: startedSession.sessionId, - numTurns: 1, - }); - }).pipe(Effect.provide(secondProviderLayer)); - - assert.deepEqual(secondCodex.startSession.mock.calls, [ - [ - { +it.effect( + "ProviderServiceLive restores rollback routing after restart using persisted thread mapping", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + + const firstCodex = makeFakeCodexAdapter(); + const firstRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(firstCodex.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + + const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide(firstDirectoryLayer), + ); + + const startedSession = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession({ provider: "codex", + cwd: "/tmp/project", + }); + }).pipe(Effect.provide(firstProviderLayer)); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.stopAll(); + }).pipe(Effect.provide(firstProviderLayer)); + + const persistedAfterStopAll = yield* Effect.gen(function* () { + const repository = yield* ProviderSessionRuntimeRepository; + return yield* repository.getBySessionId({ + providerSessionId: startedSession.sessionId, + }); + }).pipe(Effect.provide(runtimeRepositoryLayer)); + assert.equal(Option.isSome(persistedAfterStopAll), true); + if (Option.isSome(persistedAfterStopAll)) { + assert.equal(persistedAfterStopAll.value.status, "stopped"); + assert.deepEqual(persistedAfterStopAll.value.resumeCursor, { resumeThreadId: startedSession.threadId, - }, - ], - ]); - assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); - const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; - assert.equal(typeof rollbackCall?.[0], "string"); - assert.equal(rollbackCall?.[1], 1); - - fs.rmSync(tempDir, { recursive: true, force: true }); - }).pipe(Effect.provide(NodeServices.layer)), + }); + } + + const secondCodex = makeFakeCodexAdapter(); + const secondRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(secondCodex.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide(secondDirectoryLayer), + ); + + secondCodex.startSession.mockClear(); + secondCodex.rollbackThread.mockClear(); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.rollbackConversation({ + sessionId: startedSession.sessionId, + numTurns: 1, + }); + }).pipe(Effect.provide(secondProviderLayer)); + + assert.deepEqual(secondCodex.startSession.mock.calls, [ + [ + { + provider: "codex", + resumeThreadId: startedSession.threadId, + }, + ], + ]); + assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); + const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; + assert.equal(typeof rollbackCall?.[0], "string"); + assert.equal(rollbackCall?.[1], 1); + + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), ); routing.layer("ProviderServiceLive routing", (it) => { @@ -574,7 +579,10 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const events = yield* Ref.get(eventsRef); yield* Fiber.interrupt(consumer); - assert.equal(events.some((entry) => entry.type === "turn.completed"), true); + assert.equal( + events.some((entry) => entry.type === "turn.completed"), + true, + ); }), ); @@ -586,11 +594,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }); const receivedByHealthy: string[] = []; - const expectedEventIds = new Set([ - "evt-ordered-1", - "evt-ordered-2", - "evt-ordered-3", - ]); + const expectedEventIds = new Set(["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"]); const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( Stream.runForEach((event) => Effect.sync(() => { @@ -649,11 +653,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { assert.deepEqual( receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3), - [ - "evt-ordered-1", - "evt-ordered-2", - "evt-ordered-3", - ], + ["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"], ); }), ); @@ -712,9 +712,9 @@ validation.layer("ProviderServiceLive validation", (it) => { assertFailure( failure, new ProviderValidationError({ - operation: "ProviderService.startSession", - issue: "Provider 'codex' returned a session without threadId.", - }), + operation: "ProviderService.startSession", + issue: "Provider 'codex' returned a session without threadId.", + }), ); }), ); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1df3f050312f991f1c8412e1ec4b56b20c764a52..fc807f84af92e03250c0accf191b526452df4eb5 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -3,17 +3,16 @@ import os from "node:os"; import path from "node:path"; import { NodeServices } from "@effect/platform-node"; -import { - ProviderSessionId, - ProviderThreadId, - ThreadId, -} from "@t3tools/contracts"; +import { ProviderSessionId, ProviderThreadId, ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; import { assertFailure, assertSome } from "@effect/vitest/utils"; import { Effect, Layer, Option } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../../persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 6d2a36c5f6e6889ee3ab4267939766be68538404..b3b68f48971a18abe2edc72b3c2b8d8825d1a5f6 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -78,7 +78,6 @@ export interface ProviderSessionDirectoryShape { ReadonlyArray, ProviderSessionDirectoryPersistenceError >; - } /** diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index e3e5e60b8543133d76910bfcfc6e09e1934b1d6c..314da061a0f9447e6420cecef6d148e5b6083019 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -28,6 +28,7 @@ import { ProviderService } from "./provider/Services/ProviderService"; import { makeTerminalManagerLive, TerminalManager } from "./terminalManager"; import { PtyAdapter } from "./ptyAdapter"; +import { Keybindings, KeybindingsLive } from "./keybindings"; export function makeServerProviderLayer(): Layer.Layer< ProviderService, @@ -55,7 +56,8 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor - | TerminalManager, + | TerminalManager + | Keybindings, unknown, SqlClient.SqlClient | ProviderService | ServerConfig > { @@ -93,5 +95,7 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< }).pipe(Layer.provide(PtyAdapter.layer())); }).pipe(Layer.unwrap); - return Layer.merge(orchestrationReactorLayer, terminalLayer); + const keybindingsLayer = KeybindingsLive.pipe(Layer.provide(NodeServices.layer)); + + return Layer.mergeAll(orchestrationReactorLayer, terminalLayer, keybindingsLayer); } diff --git a/apps/server/src/workspaceEntries.chunking.test.ts b/apps/server/src/workspaceEntries.chunking.test.ts index 6644d608015d2d3cac619a7936eaf7b30ddf0054..f978c5233ecca5a4bdd3ecc116a0d76c7aa8c629 100644 --- a/apps/server/src/workspaceEntries.chunking.test.ts +++ b/apps/server/src/workspaceEntries.chunking.test.ts @@ -2,13 +2,14 @@ import { assert, beforeEach, describe, it, vi } from "vitest"; import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; const { runProcessMock } = vi.hoisted(() => ({ - runProcessMock: vi.fn< - ( - command: string, - args: readonly string[], - options?: ProcessRunOptions, - ) => Promise - >(), + runProcessMock: + vi.fn< + ( + command: string, + args: readonly string[], + options?: ProcessRunOptions, + ) => Promise + >(), })); vi.mock("./processRunner", () => ({ diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index e3660609dfedac710decffe58022416fbd0a0690..ca8435336f0d28dc7d2903abe2476947cd9f734f 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -124,15 +124,15 @@ describe("searchWorkspaceEntries", () => { let rootReadCount = 0; const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation( - (async (...args: Parameters) => { - if (args[0] === cwd) { - rootReadCount += 1; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir, - ); + vi.spyOn(fsPromises, "readdir").mockImplementation((async ( + ...args: Parameters + ) => { + if (args[0] === cwd) { + rootReadCount += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir); await Promise.all([ searchWorkspaceEntries({ cwd, query: "", limit: 100 }), @@ -152,22 +152,22 @@ describe("searchWorkspaceEntries", () => { let activeReads = 0; let peakReads = 0; const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation( - (async (...args: Parameters) => { - const target = args[0]; - if (typeof target === "string" && target.startsWith(cwd)) { - activeReads += 1; - peakReads = Math.max(peakReads, activeReads); - await new Promise((resolve) => setTimeout(resolve, 4)); - try { - return await originalReaddir(...args); - } finally { - activeReads -= 1; - } + vi.spyOn(fsPromises, "readdir").mockImplementation((async ( + ...args: Parameters + ) => { + const target = args[0]; + if (typeof target === "string" && target.startsWith(cwd)) { + activeReads += 1; + peakReads = Math.max(peakReads, activeReads); + await new Promise((resolve) => setTimeout(resolve, 4)); + try { + return await originalReaddir(...args); + } finally { + activeReads -= 1; } - return originalReaddir(...args); - }) as typeof fsPromises.readdir, - ); + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir); await searchWorkspaceEntries({ cwd, query: "", limit: 200 }); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 4a4a2bee1e42694006bcae2bc24765b3ac7abde1..43e2284470e4bea6a8b12c9a3b22a7e2709caa1b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -360,6 +360,7 @@ describe("WebSocket Server", () => { port: 0, host: undefined, cwd: options.cwd ?? "/test/project", + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), stateDir, staticDir: undefined, devUrl: options.devUrl ? new URL(options.devUrl) : undefined, diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 9ff5d0c84a7e73f8b4f54c367c82cb8e6c17754c..c9b3f1795b25bfe1414d12452932da6f0465d992 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -31,7 +31,7 @@ import { removeGitWorktree, } from "./git"; import { TerminalManager, type TerminalManagerShape } from "./terminalManager"; -import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; +import { Keybindings } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; @@ -150,10 +150,14 @@ export type ServerCoreRuntimeServices = | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor + | ProviderService; + +export type ServerRuntimeServices = + | ServerCoreRuntimeServices + | TerminalManager + | Keybindings | Open; -export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService | TerminalManager; - export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", { @@ -171,8 +175,10 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E unknown, // TODO: This should not be unknown Scope.Scope | ServerRuntimeServices | ServerConfig > { - const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig; + const serverConfig = yield* ServerConfig; + const { port, cwd, staticDir, devUrl, authToken, mode, host } = serverConfig; const defaultTerminalManager = yield* TerminalManager; + const keybindingsManager = yield* Keybindings; const { logWebSocketEvents: explicitLogWsEvents, gitManager = new GitManager(), @@ -184,7 +190,6 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E const logger = createLogger("ws"); const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); - let keybindingsConfig = loadResolvedKeybindingsConfig(logger); function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -571,11 +576,12 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E } case WS_METHODS.serverGetConfig: + const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig; return { cwd, keybindings: keybindingsConfig }; case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); - keybindingsConfig = upsertKeybindingRule(logger, body); + const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); return { keybindings: keybindingsConfig }; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 60415140eb818e28e9e8d3c4fb88f98b0a174070..5306ef1cc9b5bf251dc13b2dad0bc26ad9896e31 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -108,7 +108,15 @@ export default function BranchToolbar({ branch: syncedBranch, worktreePath: null, }); - }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch, api]); + }, [ + activeThreadId, + activeWorktreePath, + activeThreadBranch, + queryBranches, + envMode, + dispatch, + api, + ]); useEffect(() => { if (isBranchMenuOpen) return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab3248639b0f76c3bf66ddd554b9e550d5e66383..e1ba97830181e28199661b9eb895088886b91a9c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,6 +1,7 @@ import { EDITORS, type EditorId, + type KeybindingCommand, type NativeApi, type ProjectEntry, type ProjectScript, @@ -10,20 +11,7 @@ import { type ResolvedKeybindingsConfig, type ProviderApprovalDecision, } from "@t3tools/contracts"; -import { - type ClipboardEvent, - type DragEvent, - type FormEvent, - type KeyboardEvent, - memo, - type RefObject, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -298,7 +286,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { triggerKind: ComposerTriggerKind | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; - commandInputRef: RefObject; + commandInputRef: React.RefObject; }) { return ( { if (!api) return; @@ -1250,7 +1238,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }; - const onComposerPaste = (event: ClipboardEvent) => { + const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) { return; @@ -1263,7 +1251,7 @@ export default function ChatView({ threadId }: ChatViewProps) { addComposerImages(imageFiles); }; - const onComposerDragEnter = (event: DragEvent) => { + const onComposerDragEnter = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } @@ -1272,7 +1260,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setIsDragOverComposer(true); }; - const onComposerDragOver = (event: DragEvent) => { + const onComposerDragOver = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } @@ -1281,7 +1269,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setIsDragOverComposer(true); }; - const onComposerDragLeave = (event: DragEvent) => { + const onComposerDragLeave = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } @@ -1296,7 +1284,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; - const onComposerDrop = (event: DragEvent) => { + const onComposerDrop = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } @@ -1350,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError], ); - const onSend = async (e: FormEvent) => { + const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => { e.preventDefault(); if (!api || !activeThread || isSending || isConnecting) return; const trimmed = prompt.trim(); @@ -1594,7 +1582,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(nextCursor); }, []); - const onKeyDown = (e: KeyboardEvent) => { + const onKeyDown = (e: React.KeyboardEvent) => { if (composerMenuOpen && composerMenuItems.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); @@ -1618,7 +1606,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - void onSend(e as unknown as FormEvent); + void onSend(e); } }; const onToggleWorkGroup = useCallback((groupId: string) => { @@ -2086,7 +2074,10 @@ const ChatHeader = memo(function ChatHeader({
-

+

{activeThreadTitle}

{activeProjectName && ( @@ -2216,7 +2207,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; - scrollContainerRef: RefObject; + scrollContainerRef: React.RefObject; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c20de6f2c7e24b431ff5d64cbf8be9cfc9ef2f7c..cc5bd4a6e1310e93f3081abe3f1bcf416e30f951 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -335,10 +335,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { > -
+