diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index a8a8ce4607b3..2bcf2d13a5ee 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -687,9 +687,14 @@ export class CodexAppServerManager extends EventEmitter ({ + spawnMock: vi.fn(), +})); + +vi.mock("../../codexAppServerManager.ts", () => ({ + assertSupportedCodexCliVersion: vi.fn(), + buildCodexInitializeParams: vi.fn(() => ({ clientInfo: { name: "test" } })), +})); +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +import { ServerConfig } from "../../config.ts"; +import { CodexImportBrowser } from "../Services/CodexImportBrowser.ts"; +import { CodexImportBrowserLive } from "./CodexImportBrowser.ts"; + +type FakeChildMode = "success" | "exit-on-initialize" | "error-on-initialize"; +type ThreadListPage = { + readonly cursor: string | null; + readonly result: { + readonly data: ReadonlyArray>; + readonly nextCursor: string | null; + }; +}; + +class FakeChild extends EventEmitter { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly stdin: { write: (chunk: string) => boolean }; + killed = false; + constructor( + private readonly mode: FakeChildMode, + private readonly threadListPages: ReadonlyArray = [ + { cursor: null, result: { data: [], nextCursor: null } }, + ], + private readonly options: { + readonly errorMethods?: Readonly>; + readonly ignoredMethods?: ReadonlyArray; + } = {}, + ) { + super(); + this.stdin = { + write: (chunk: string) => { + const message = JSON.parse(chunk.trim()) as { + id?: string; + method?: string; + params?: Record; + }; + if (this.options.ignoredMethods?.includes(message.method ?? "")) { + return true; + } + const errorMessage = + message.method ? this.options.errorMethods?.[message.method] : undefined; + if (errorMessage && message.id) { + queueMicrotask(() => { + this.stdout.write( + `${JSON.stringify({ id: message.id, error: { message: errorMessage } })}\n`, + ); + }); + return true; + } + if (message.method === "initialize" && message.id) { + queueMicrotask(() => { + if (this.mode === "success") { + this.stdout.write(`${JSON.stringify({ id: message.id, result: {} })}\n`); + } else if (this.mode === "exit-on-initialize") { + this.emit("exit", 1); + } else { + this.emit("error", new Error("spawn failed")); + } + }); + return true; + } + if (message.method === "thread/list" && message.id) { + queueMicrotask(() => { + const requestedCursor = + typeof message.params?.cursor === "string" ? message.params.cursor : null; + const page = + this.threadListPages.find((candidate) => candidate.cursor === requestedCursor) ?? + this.threadListPages[this.threadListPages.length - 1]!; + this.stdout.write( + `${JSON.stringify({ + id: message.id, + result: page.result, + })}\n`, + ); + }); + } + if (message.method === "thread/read" && message.id) { + queueMicrotask(() => { + this.stdout.write( + `${JSON.stringify({ + id: message.id, + result: { + thread: { + id: String(message.params?.threadId ?? "thread-1"), + cwd: "/tmp/repo", + source: "cli", + preview: "", + createdAt: 0, + updatedAt: 0, + turns: [], + }, + }, + })}\n`, + ); + }); + } + if (message.method === "thread/archive" && message.id) { + queueMicrotask(() => { + this.stdout.write(`${JSON.stringify({ id: message.id, result: {} })}\n`); + }); + } + return true; + }, + }; + } + + kill() { + this.killed = true; + return true; + } +} + +describe("CodexImportBrowser", () => { + let runtime: ManagedRuntime.ManagedRuntime | null = null; + + afterEach(async () => { + if (runtime) { + await runtime.dispose(); + } + runtime = null; + spawnMock.mockReset(); + }); + + async function makeBrowser() { + const layer = Layer.provide( + CodexImportBrowserLive, + Layer.succeed(ServerConfig, { + cwd: "/tmp", + stateDir: "/tmp/state", + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + port: 0, + host: undefined, + authToken: undefined, + keybindingsConfigPath: "/tmp/state/keybindings.json", + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + }), + ); + runtime = ManagedRuntime.make(layer); + return runtime.runPromise(Effect.service(CodexImportBrowser)); + } + + it("reuses a single app-server process for a withSession operation", async () => { + const child = new FakeChild("success"); + spawnMock.mockReturnValue(child); + const browser = await makeBrowser(); + + await runtime!.runPromise( + browser.withSession({ binaryPath: undefined, homePath: undefined }, (session) => + Effect.gen(function* () { + yield* session.listThreads(); + yield* session.readThread("thread-1"); + yield* session.archiveThread("thread-1"); + }), + ), + ); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(child.killed).toBe(true); + }); + + it("paginates through all thread/list pages", async () => { + const child = new FakeChild("success", [ + { + cursor: null, + result: { + data: [ + { + id: "thread-1", + cwd: "/tmp/repo-a", + source: "cli", + preview: "one", + createdAt: 1, + updatedAt: 2, + }, + ], + nextCursor: "cursor-2", + }, + }, + { + cursor: "cursor-2", + result: { + data: [ + { + id: "thread-2", + cwd: "/tmp/repo-b", + source: "vscode", + preview: "two", + createdAt: 3, + updatedAt: 4, + }, + ], + nextCursor: null, + }, + }, + ]); + spawnMock.mockReturnValue(child); + const browser = await makeBrowser(); + + const threads = await runtime!.runPromise( + browser.listThreads({ binaryPath: undefined, homePath: undefined }), + ); + + expect(threads.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]); + expect(child.killed).toBe(true); + }); + + it("cleans up the child when initialize fails after spawn", async () => { + const child = new FakeChild("exit-on-initialize"); + spawnMock.mockReturnValue(child); + const browser = await makeBrowser(); + + await expect( + runtime!.runPromise( + browser.listThreads({ binaryPath: undefined, homePath: undefined }), + ), + ).rejects.toThrow("Codex import client exited unexpectedly."); + expect(child.killed).toBe(true); + }); + + it("surfaces JSON-RPC request errors from the app-server", async () => { + const child = new FakeChild("success", undefined, { + errorMethods: { + "thread/archive": "archive rejected", + }, + }); + spawnMock.mockReturnValue(child); + const browser = await makeBrowser(); + + await expect( + runtime!.runPromise( + browser.archiveThread({ binaryPath: undefined, homePath: undefined }, "thread-1"), + ), + ).rejects.toThrow("archive rejected"); + expect(child.killed).toBe(true); + }); + + it("times out requests that never receive a response", async () => { + vi.useFakeTimers(); + try { + const child = new FakeChild("success", undefined, { + ignoredMethods: ["thread/list"], + }); + spawnMock.mockReturnValue(child); + const browser = await makeBrowser(); + + const observedError = runtime! + .runPromise( + browser.listThreads({ binaryPath: undefined, homePath: undefined }), + ) + .catch((error) => error); + await vi.advanceTimersByTimeAsync(30_000); + + const error = await observedError; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Timed out waiting for Codex import response to thread/list.", + ); + expect(child.killed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("surfaces child process errors as browser errors instead of unhandled failures", async () => { + const child = new FakeChild("error-on-initialize"); + spawnMock.mockReturnValue(child); + const browser = await makeBrowser(); + + await expect( + runtime!.runPromise( + browser.listThreads({ binaryPath: undefined, homePath: undefined }), + ), + ).rejects.toThrow("spawn failed"); + expect(child.killed).toBe(true); + }); +}); diff --git a/apps/server/src/codexImport/Layers/CodexImportBrowser.ts b/apps/server/src/codexImport/Layers/CodexImportBrowser.ts new file mode 100644 index 000000000000..a16446cedfba --- /dev/null +++ b/apps/server/src/codexImport/Layers/CodexImportBrowser.ts @@ -0,0 +1,356 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import readline from "node:readline"; + +import { Effect, Layer } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { + assertSupportedCodexCliVersion, + buildCodexInitializeParams, +} from "../../codexAppServerManager.ts"; +import { + CodexImportBrowser, + CodexImportBrowserError, + type CodexImportBrowserOverrides, + type CodexImportBrowserShape, + type CodexThreadRead, + type CodexThreadSummary, +} from "../Services/CodexImportBrowser.ts"; + +type JsonRpcResponse = { + readonly id: string | number; + readonly result?: unknown; + readonly error?: { readonly message?: string }; +}; + +const CODEX_IMPORT_TIMEOUT_MS = 30_000; + +function toBrowserError(cause: unknown): CodexImportBrowserError { + return new CodexImportBrowserError({ + message: cause instanceof Error ? cause.message : String(cause), + }); +} + +function readObject(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readString(value: unknown, key: string): string | undefined { + const record = readObject(value); + const candidate = record?.[key]; + return typeof candidate === "string" ? candidate : undefined; +} + +function readNumber(value: unknown, key: string): number | undefined { + const record = readObject(value); + const candidate = record?.[key]; + return typeof candidate === "number" ? candidate : undefined; +} + +function readArray(value: unknown, key: string): unknown[] { + const record = readObject(value); + const candidate = record?.[key]; + return Array.isArray(candidate) ? candidate : []; +} + +function normalizeCodexSourceKind(value: unknown): "cli" | "vscode" | null { + if (value === "cli" || value === "vscode") { + return value; + } + return null; +} + +function normalizeOptionalText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function unixSecondsToIso(value: unknown): string { + const seconds = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(seconds)) { + return new Date(0).toISOString(); + } + return new Date(seconds * 1000).toISOString(); +} + +class CodexImportClient { + private readonly pending = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: ReturnType; + } + >(); + private readonly child: ChildProcessWithoutNullStreams; + private readonly output: readline.Interface; + private nextId = 1; + private closed = false; + private unexpectedFailure: Error | null = null; + + private constructor(child: ChildProcessWithoutNullStreams) { + this.child = child; + this.output = readline.createInterface({ input: this.child.stdout }); + this.output.on("line", (line) => this.handleLine(line)); + // Drain stderr so app-server warnings cannot fill the pipe buffer and block requests. + this.child.stderr.on("data", () => {}); + this.child.on("error", (error) => { + this.handleUnexpectedFailure(error instanceof Error ? error : new Error(String(error))); + }); + this.child.on("exit", () => { + this.handleUnexpectedFailure(new Error("Codex import client exited unexpectedly.")); + }); + } + + static async create( + cwd: string, + overrides: CodexImportBrowserOverrides, + ): Promise { + const binaryPath = overrides.binaryPath ?? "codex"; + assertSupportedCodexCliVersion({ + binaryPath, + cwd, + ...(overrides.homePath ? { homePath: overrides.homePath } : {}), + }); + const child = spawn(binaryPath, ["app-server"], { + cwd, + env: { + ...process.env, + ...(overrides.homePath ? { CODEX_HOME: overrides.homePath } : {}), + }, + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", + }); + const client = new CodexImportClient(child); + try { + await client.initialize(); + return client; + } catch (error) { + await client.close(); + throw error; + } + } + + async initialize(): Promise { + await this.sendRequest("initialize", buildCodexInitializeParams()); + this.writeMessage({ method: "initialized" }); + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + this.rejectPending(new Error("Codex import client closed.")); + this.output.close(); + if (!this.child.killed) { + this.child.kill(); + } + } + + async listThreads(): Promise { + const threads: CodexThreadSummary[] = []; + let cursor: string | null = null; + do { + const response = (await this.sendRequest("thread/list", { + cursor, + archived: false, + modelProviders: null, + sourceKinds: ["cli", "vscode"], + sortKey: "updated_at", + })) as { data?: unknown[]; nextCursor?: string | null }; + for (const raw of Array.isArray(response?.data) ? response.data : []) { + const summary = this.decodeThreadSummary(raw); + if (summary) { + threads.push(summary); + } + } + cursor = typeof response?.nextCursor === "string" ? response.nextCursor : null; + } while (cursor !== null); + return threads; + } + + async readThread(threadId: string): Promise { + const response = await this.sendRequest("thread/read", { threadId, includeTurns: true }); + const record = readObject(response); + const thread = readObject(record?.thread) ?? record; + const summary = this.decodeThreadSummary(thread); + if (!summary) { + throw new Error(`Codex thread '${threadId}' could not be decoded.`); + } + const turns = readArray(thread, "turns").map((turnValue, index) => { + const turn = readObject(turnValue) ?? {}; + const items = readArray(turn, "items").map((item) => readObject(item) ?? {}); + return { + id: readString(turn, "id") ?? `${threadId}:turn:${index + 1}`, + items, + }; + }); + return { + ...summary, + turns, + }; + } + + async archiveThread(threadId: string): Promise { + await this.sendRequest("thread/archive", { threadId }); + } + + private decodeThreadSummary(raw: unknown): CodexThreadSummary | null { + const record = readObject(raw); + if (!record) return null; + const sourceKind = normalizeCodexSourceKind(record.source); + const id = readString(record, "id"); + const cwd = readString(record, "cwd"); + if (!id || !cwd || !sourceKind) return null; + return { + id, + preview: readString(record, "preview") ?? "", + modelProvider: normalizeOptionalText(readString(record, "modelProvider")) ?? null, + createdAt: unixSecondsToIso(readNumber(record, "createdAt")), + updatedAt: unixSecondsToIso(readNumber(record, "updatedAt")), + cwd, + sourceKind, + name: normalizeOptionalText(readString(record, "name")) ?? null, + branch: normalizeOptionalText(readString(readObject(record.gitInfo), "branch")) ?? null, + }; + } + + private handleLine(line: string): void { + let payload: unknown; + try { + payload = JSON.parse(line); + } catch { + return; + } + const response = payload as JsonRpcResponse; + const id = + response && (typeof response.id === "string" || typeof response.id === "number") + ? String(response.id) + : null; + if (!id) { + return; + } + const pending = this.pending.get(id); + if (!pending) { + return; + } + clearTimeout(pending.timeout); + this.pending.delete(id); + if (response.error?.message) { + pending.reject(new Error(`${response.error.message}`)); + return; + } + pending.resolve(response.result); + } + + private writeMessage(message: Record): void { + if (this.closed) { + return; + } + this.child.stdin.write(`${JSON.stringify(message)}\n`); + } + + private sendRequest(method: string, params: unknown): Promise { + if (this.closed) { + return Promise.reject( + this.unexpectedFailure ?? new Error("Codex import client is not available."), + ); + } + const id = String(this.nextId++); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Timed out waiting for Codex import response to ${method}.`)); + }, CODEX_IMPORT_TIMEOUT_MS); + this.pending.set(id, { resolve, reject, timeout }); + this.writeMessage({ id, method, params }); + }); + } + + private rejectPending(error: Error): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(error); + } + this.pending.clear(); + } + + private handleUnexpectedFailure(error: Error): void { + if (this.closed) { + return; + } + this.unexpectedFailure = error; + this.closed = true; + this.rejectPending(error); + this.output.close(); + if (!this.child.killed) { + this.child.kill(); + } + } +} + +const makeCodexImportBrowser = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + + const withClient = ( + overrides: CodexImportBrowserOverrides, + effect: (client: CodexImportClient) => Effect.Effect, + ) => + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => CodexImportClient.create(serverConfig.cwd, overrides), + catch: toBrowserError, + }), + (client) => effect(client), + (client) => Effect.promise(() => client.close()).pipe(Effect.orDie), + ); + + return { + withSession: (overrides, effect) => + withClient(overrides, (client) => + effect({ + listThreads: () => + Effect.tryPromise({ + try: () => client.listThreads(), + catch: toBrowserError, + }), + readThread: (threadId) => + Effect.tryPromise({ + try: () => client.readThread(threadId), + catch: toBrowserError, + }), + archiveThread: (threadId) => + Effect.tryPromise({ + try: () => client.archiveThread(threadId), + catch: toBrowserError, + }), + }), + ), + listThreads: (overrides) => + withClient(overrides, (client) => + Effect.tryPromise({ + try: () => client.listThreads(), + catch: toBrowserError, + }), + ), + readThread: (overrides, threadId) => + withClient(overrides, (client) => + Effect.tryPromise({ + try: () => client.readThread(threadId), + catch: toBrowserError, + }), + ), + archiveThread: (overrides, threadId) => + withClient(overrides, (client) => + Effect.tryPromise({ + try: () => client.archiveThread(threadId), + catch: toBrowserError, + }), + ), + } satisfies CodexImportBrowserShape; +}); + +export const CodexImportBrowserLive = Layer.effect(CodexImportBrowser, makeCodexImportBrowser); diff --git a/apps/server/src/codexImport/Layers/CodexImportService.test.ts b/apps/server/src/codexImport/Layers/CodexImportService.test.ts new file mode 100644 index 000000000000..02db229fae01 --- /dev/null +++ b/apps/server/src/codexImport/Layers/CodexImportService.test.ts @@ -0,0 +1,1429 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + DEFAULT_MODEL_BY_PROVIDER, + ProjectId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import { Effect, Layer, ManagedRuntime, Option, Stream } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + CodexImportBrowser, + type CodexImportBrowserShape, + type CodexThreadRead, + type CodexThreadSummary, +} from "../Services/CodexImportBrowser.ts"; +import { makeCodexImportService } from "./CodexImportService.ts"; +import { CodexImportService } from "../Services/CodexImportService.ts"; +import { GitCore, type GitCoreShape, type GitWorktreeLayout } from "../../git/Services/GitCore.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../orchestration/Services/OrchestrationEngine.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ProjectionExternalThreadRepository, + type ProjectionExternalThread, +} from "../../persistence/Services/ProjectionExternalThreads.ts"; +import { ProjectionImportedThreadActivityRepository } from "../../persistence/Services/ProjectionImportedThreadActivities.ts"; +import { ProjectionImportedThreadMessageRepository } from "../../persistence/Services/ProjectionImportedThreadMessages.ts"; +import { ProjectionImportedThreadProposedPlanRepository } from "../../persistence/Services/ProjectionImportedThreadProposedPlans.ts"; +import { ProviderService, type ProviderServiceShape } from "../../provider/Services/ProviderService.ts"; +import { + ProviderSessionDirectory, + type ProviderRuntimeBinding, + type ProviderSessionDirectoryShape, +} from "../../provider/Services/ProviderSessionDirectory.ts"; +import { GitCommandError } from "../../git/Errors.ts"; + +type MutableSnapshot = { + snapshotSequence: number; + updatedAt: string; + projects: Array; + threads: Array; +}; + +function makeSnapshot(): MutableSnapshot { + return { + snapshotSequence: 0, + updatedAt: "2026-03-08T00:00:00.000Z", + projects: [], + threads: [], + }; +} + +function makeThread(input: { + id: ThreadId; + projectId: ProjectId; + title?: string; + branch?: string | null; + worktreePath?: string | null; + model?: string; + external?: OrchestrationReadModel["threads"][number]["external"]; +}): OrchestrationReadModel["threads"][number] { + return { + id: input.id, + projectId: input.projectId, + title: input.title ?? "Thread", + model: input.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, + runtimeMode: "full-access", + interactionMode: "default", + branch: input.branch ?? null, + worktreePath: input.worktreePath ?? null, + latestTurn: null, + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:00.000Z", + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...(input.external ? { external: input.external } : {}), + }; +} + +function makeProject(input: { + id: ProjectId; + title: string; + workspaceRoot: string; +}): OrchestrationReadModel["projects"][number] { + return { + id: input.id, + title: input.title, + workspaceRoot: input.workspaceRoot, + defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + scripts: [], + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:00.000Z", + deletedAt: null, + }; +} + +describe("CodexImportService", () => { + let runtime: ManagedRuntime.ManagedRuntime | null = null; + const cleanupPaths = new Set(); + + afterEach(async () => { + if (runtime) { + await runtime.dispose(); + } + runtime = null; + for (const cleanupPath of cleanupPaths) { + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } + cleanupPaths.clear(); + }); + + async function createHarness(input: { + threads?: ReadonlyArray; + threadReads?: Readonly>; + layouts?: Readonly>; + layoutErrors?: Readonly>; + snapshot?: OrchestrationReadModel; + externalRows?: ReadonlyArray; + initialImportedMessages?: ReadonlyArray>; + initialImportedPlans?: ReadonlyArray>; + initialImportedActivities?: ReadonlyArray>; + archiveThreadError?: Error; + stopSessionError?: Error; + providerBindingUpsertError?: Error; + providerBindingUpsertFailureCount?: number; + dispatchErrorsByType?: Readonly>>; + } = {}) { + const snapshot = (input.snapshot ? structuredClone(input.snapshot) : makeSnapshot()) as MutableSnapshot; + const threadReads = new Map(Object.entries(input.threadReads ?? {})); + const layoutMap = new Map(Object.entries(input.layouts ?? {})); + const layoutErrorMap = new Map(Object.entries(input.layoutErrors ?? {})); + const externalRows = new Map( + (input.externalRows ?? []).map((row) => [row.threadId, structuredClone(row)] as const), + ); + const messageRows: Array> = [ + ...(input.initialImportedMessages ?? []).map((row) => structuredClone(row)), + ]; + const planRows: Array> = [ + ...(input.initialImportedPlans ?? []).map((row) => structuredClone(row)), + ]; + const activityRows: Array> = [ + ...(input.initialImportedActivities ?? []).map((row) => structuredClone(row)), + ]; + const bindings = new Map(); + const archiveCalls: string[] = []; + const stopSessionCalls: ThreadId[] = []; + const stopSessionDeletedAts: Array = []; + let remainingProviderBindingUpsertFailures = input.providerBindingUpsertFailureCount ?? 0; + + const snapshotQuery: ProjectionSnapshotQueryShape = { + getSnapshot: () => + Effect.sync(() => ({ + ...snapshot, + projects: [...snapshot.projects], + threads: snapshot.threads.map((thread) => ({ ...thread })), + }) as OrchestrationReadModel), + }; + + const orchestration: OrchestrationEngineShape = { + getReadModel: () => Effect.sync(() => snapshot), + readEvents: () => Stream.empty, + streamDomainEvents: Stream.empty, + dispatch: (command) => + Effect.sync(() => { + const typed = command as OrchestrationCommand; + const dispatchError = input.dispatchErrorsByType?.[typed.type]; + if (dispatchError) { + throw dispatchError; + } + if (typed.type === "project.create") { + snapshot.projects.push( + makeProject({ + id: typed.projectId, + title: typed.title, + workspaceRoot: typed.workspaceRoot, + }), + ); + } else if (typed.type === "thread.create") { + snapshot.threads.push( + makeThread({ + id: typed.threadId, + projectId: typed.projectId, + title: typed.title, + branch: typed.branch, + worktreePath: typed.worktreePath, + model: typed.model, + }), + ); + } else if (typed.type === "thread.meta.update") { + const thread = snapshot.threads.find((entry) => entry.id === typed.threadId); + if (thread) { + if (typed.title !== undefined) thread.title = typed.title; + if (typed.branch !== undefined) thread.branch = typed.branch; + if (typed.worktreePath !== undefined) thread.worktreePath = typed.worktreePath; + } + } else if (typed.type === "thread.delete") { + const thread = snapshot.threads.find((entry) => entry.id === typed.threadId); + if (thread) { + thread.deletedAt = "2026-03-08T00:01:00.000Z"; + } + } + + for (const thread of snapshot.threads) { + const external = externalRows.get(thread.id); + if (external) { + thread.external = { + provider: "codex", + providerThreadId: external.providerThreadId, + sourceKind: external.sourceKind, + cwd: external.cwd, + modelProvider: external.modelProvider, + remoteUpdatedAt: external.remoteUpdatedAt, + importedAt: external.importedAt, + adoptedAt: external.adoptedAt, + }; + } else { + delete thread.external; + } + } + + snapshot.snapshotSequence += 1; + return { sequence: snapshot.snapshotSequence }; + }), + }; + + const browser: CodexImportBrowserShape = { + withSession: (_overrides, effect) => + effect({ + listThreads: () => Effect.succeed(input.threads ?? []), + readThread: (threadId: string) => + Effect.sync(() => { + const thread = threadReads.get(threadId); + if (!thread) { + throw new Error(`Unknown remote thread: ${threadId}`); + } + return structuredClone(thread); + }), + archiveThread: (threadId: string) => + Effect.sync(() => { + archiveCalls.push(threadId); + if (input.archiveThreadError) { + throw input.archiveThreadError; + } + }), + }), + listThreads: () => Effect.succeed(input.threads ?? []), + readThread: (_overrides: unknown, threadId: string) => + Effect.sync(() => { + const thread = threadReads.get(threadId); + if (!thread) { + throw new Error(`Unknown remote thread: ${threadId}`); + } + return structuredClone(thread); + }), + archiveThread: (_overrides: unknown, threadId: string) => + Effect.sync(() => { + archiveCalls.push(threadId); + if (input.archiveThreadError) { + throw input.archiveThreadError; + } + }), + }; + + const gitCore = { + inspectWorktreeLayout: (cwd: string) => + Effect.sync(() => { + const layoutError = layoutErrorMap.get(cwd); + if (layoutError) { + throw layoutError; + } + const layout = layoutMap.get(cwd); + if (!layout) { + throw new Error(`Unknown worktree layout for ${cwd}`); + } + return structuredClone(layout); + }), + } as unknown as GitCoreShape; + + const providerService: ProviderServiceShape = { + startSession: () => Effect.die(new Error("Not used in test")), + sendTurn: () => Effect.die(new Error("Not used in test")), + interruptTurn: () => Effect.die(new Error("Not used in test")), + respondToRequest: () => Effect.die(new Error("Not used in test")), + respondToUserInput: () => Effect.die(new Error("Not used in test")), + stopSession: ({ threadId }) => + Effect.sync(() => { + stopSessionCalls.push(threadId); + stopSessionDeletedAts.push( + snapshot.threads.find((entry) => entry.id === threadId)?.deletedAt, + ); + if (input.stopSessionError) { + throw input.stopSessionError; + } + }), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die(new Error("Not used in test")), + rollbackConversation: () => Effect.die(new Error("Not used in test")), + streamEvents: Stream.empty, + }; + + const providerSessionDirectory: ProviderSessionDirectoryShape = { + upsert: (binding) => + Effect.sync(() => { + if ( + input.providerBindingUpsertError && + remainingProviderBindingUpsertFailures > 0 + ) { + remainingProviderBindingUpsertFailures -= 1; + throw input.providerBindingUpsertError; + } + bindings.set(binding.threadId, binding); + }), + getProvider: (threadId) => + Effect.sync(() => { + const binding = bindings.get(threadId); + if (!binding) throw new Error(`Missing binding for ${threadId}`); + return binding.provider; + }), + getBinding: (threadId) => + Effect.succeed(bindings.has(threadId) ? Option.some(bindings.get(threadId)!) : Option.none()), + remove: (threadId) => + Effect.sync(() => { + bindings.delete(threadId); + }), + listThreadIds: () => Effect.succeed([...bindings.keys()]), + }; + + const externalRepository = { + upsert: (row: ProjectionExternalThread) => + Effect.sync(() => { + externalRows.set(row.threadId, structuredClone(row)); + }), + getByThreadId: ({ threadId }: { threadId: ThreadId }) => + Effect.succeed( + externalRows.has(threadId) ? Option.some(externalRows.get(threadId)!) : Option.none(), + ), + getByProviderThreadId: ({ providerThreadId }: { providerThreadId: string }) => + Effect.succeed((() => { + const row = [...externalRows.values()].find( + (candidate) => candidate.providerThreadId === providerThreadId, + ); + return row ? Option.some(row) : Option.none(); + })()), + list: () => Effect.succeed([...externalRows.values()]), + markAdopted: ({ threadId, adoptedAt }: { threadId: ThreadId; adoptedAt: string }) => + Effect.sync(() => { + const row = externalRows.get(threadId); + if (row) row.adoptedAt = adoptedAt; + }), + deleteByThreadId: ({ threadId }: { threadId: ThreadId }) => + Effect.sync(() => { + externalRows.delete(threadId); + }), + }; + + const importedMessages = { + upsert: (row: Record) => + Effect.sync(() => { + messageRows.push(row); + }), + listByThreadId: ({ threadId }: { threadId: ThreadId }) => + Effect.succeed(messageRows.filter((row) => row.threadId === threadId)), + deleteByThreadId: ({ threadId }: { threadId: ThreadId }) => + Effect.sync(() => { + for (let index = messageRows.length - 1; index >= 0; index -= 1) { + if (messageRows[index]?.threadId === threadId) { + messageRows.splice(index, 1); + } + } + }), + } as unknown; + + const importedPlans = { + upsert: (row: Record) => + Effect.sync(() => { + planRows.push(row); + }), + listByThreadId: ({ threadId }: { threadId: ThreadId }) => + Effect.succeed(planRows.filter((row) => row.threadId === threadId)), + deleteByThreadId: ({ threadId }: { threadId: ThreadId }) => + Effect.sync(() => { + for (let index = planRows.length - 1; index >= 0; index -= 1) { + if (planRows[index]?.threadId === threadId) { + planRows.splice(index, 1); + } + } + }), + } as unknown; + + const importedActivities = { + upsert: (row: Record) => + Effect.sync(() => { + activityRows.push(row); + }), + listByThreadId: ({ threadId }: { threadId: ThreadId }) => + Effect.succeed(activityRows.filter((row) => row.threadId === threadId)), + deleteByThreadId: ({ threadId }: { threadId: ThreadId }) => + Effect.sync(() => { + for (let index = activityRows.length - 1; index >= 0; index -= 1) { + if (activityRows[index]?.threadId === threadId) { + activityRows.splice(index, 1); + } + } + }), + } as unknown; + + const serviceLayer = Layer.effect(CodexImportService, makeCodexImportService).pipe( + Layer.provideMerge(Layer.succeed(CodexImportBrowser, browser)), + Layer.provideMerge(Layer.succeed(GitCore, gitCore)), + Layer.provideMerge(Layer.succeed(ProjectionSnapshotQuery, snapshotQuery)), + Layer.provideMerge(Layer.succeed(OrchestrationEngineService, orchestration)), + Layer.provideMerge(Layer.succeed(ProviderService, providerService)), + Layer.provideMerge(Layer.succeed(ProviderSessionDirectory, providerSessionDirectory)), + Layer.provideMerge( + Layer.succeed(ProjectionExternalThreadRepository, externalRepository as any), + ), + Layer.provideMerge( + Layer.succeed(ProjectionImportedThreadMessageRepository, importedMessages as any), + ), + Layer.provideMerge( + Layer.succeed(ProjectionImportedThreadProposedPlanRepository, importedPlans as any), + ), + Layer.provideMerge( + Layer.succeed(ProjectionImportedThreadActivityRepository, importedActivities as any), + ), + Layer.provideMerge(NodeServices.layer), + ); + + runtime = ManagedRuntime.make(serviceLayer); + const service = await runtime.runPromise(Effect.service(CodexImportService)); + + return { + service, + snapshot, + externalRows, + messageRows, + planRows, + activityRows, + bindings, + archiveCalls, + stopSessionCalls, + stopSessionDeletedAts, + }; + } + + it("groups linked worktree sessions under the owning workspace root", async () => { + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-root-")); + const worktreeCwd = path.join(rootCwd, "worktrees", "feature-a"); + fs.mkdirSync(worktreeCwd, { recursive: true }); + cleanupPaths.add(rootCwd); + + const projectId = ProjectId.makeUnsafe("project-root"); + const harness = await createHarness({ + snapshot: { + ...makeSnapshot(), + projects: [makeProject({ id: projectId, title: "Root", workspaceRoot: rootCwd })], + }, + threads: [ + { + id: "remote-main", + preview: "main preview", + modelProvider: "openai", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + cwd: rootCwd, + sourceKind: "cli", + name: "Main thread", + branch: "main", + }, + { + id: "remote-worktree", + preview: "wt preview", + modelProvider: "openai", + createdAt: "2026-03-08T00:00:02.000Z", + updatedAt: "2026-03-08T00:00:03.000Z", + cwd: worktreeCwd, + sourceKind: "cli", + name: "Worktree thread", + branch: "feature-a", + }, + ], + layouts: { + [rootCwd]: { + isRepo: true, + workspaceRoot: rootCwd, + currentTopLevel: rootCwd, + currentWorktreePath: null, + worktrees: [{ path: rootCwd }, { path: worktreeCwd }], + }, + [worktreeCwd]: { + isRepo: true, + workspaceRoot: rootCwd, + currentTopLevel: worktreeCwd, + currentWorktreePath: worktreeCwd, + worktrees: [{ path: rootCwd }, { path: worktreeCwd }], + }, + }, + }); + + const result = await runtime!.runPromise( + harness.service.previewCodexImport({ codexBinaryPath: "", codexHomePath: "" }), + ); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0]).toMatchObject({ + cwd: rootCwd, + existingProjectId: projectId, + mainSessions: [{ providerThreadId: "remote-main" }], + worktrees: [ + { + cwd: worktreeCwd, + sessions: [{ providerThreadId: "remote-worktree" }], + }, + ], + }); + }); + + it("falls back to the raw cwd as a standalone main group when git layout cannot be resolved", async () => { + const standaloneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-standalone-")); + cleanupPaths.add(standaloneCwd); + + const harness = await createHarness({ + threads: [ + { + id: "remote-standalone", + preview: "standalone preview", + modelProvider: null, + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + cwd: standaloneCwd, + sourceKind: "vscode", + name: null, + branch: null, + }, + ], + layouts: { + [standaloneCwd]: { + isRepo: false, + workspaceRoot: standaloneCwd, + currentTopLevel: null, + currentWorktreePath: null, + worktrees: [], + }, + }, + }); + + const result = await runtime!.runPromise( + harness.service.previewCodexImport({ codexBinaryPath: "", codexHomePath: "" }), + ); + + expect(result.groups).toEqual([ + { + cwd: standaloneCwd, + displayCwd: standaloneCwd, + cwdExists: true, + existingProjectId: null, + existingProjectTitle: null, + suggestedProjectTitle: path.basename(standaloneCwd), + mainSessions: [ + expect.objectContaining({ + providerThreadId: "remote-standalone", + sourceKind: "vscode", + }), + ], + worktrees: [], + }, + ]); + }); + + it("keeps missing cwd sessions as unavailable fallback groups without invoking git inspection", async () => { + const missingRootCwd = path.join(os.tmpdir(), "t3code-codex-missing-root"); + + const harness = await createHarness({ + threads: [ + { + id: "remote-missing", + preview: "missing preview", + modelProvider: "openai", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + cwd: missingRootCwd, + sourceKind: "cli", + name: "Missing thread", + branch: null, + }, + ], + }); + + const result = await runtime!.runPromise( + harness.service.previewCodexImport({ + codexBinaryPath: undefined, + codexHomePath: undefined, + }), + ); + + expect(result.groups).toMatchObject([ + { + cwd: missingRootCwd, + displayCwd: missingRootCwd, + cwdExists: false, + existingProjectId: null, + existingProjectTitle: null, + suggestedProjectTitle: path.basename(missingRootCwd), + mainSessions: [ + { + providerThreadId: "remote-missing", + importState: "unavailable", + disabledReason: "Workspace path is not available on this machine.", + }, + ], + worktrees: [], + }, + ]); + }); + + it("surfaces unexpected git inspection failures instead of silently falling back to the raw cwd", async () => { + const standaloneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-git-error-")); + cleanupPaths.add(standaloneCwd); + + const harness = await createHarness({ + threads: [ + { + id: "remote-git-error", + preview: "git error preview", + modelProvider: null, + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + cwd: standaloneCwd, + sourceKind: "cli", + name: "Git error", + branch: null, + }, + ], + layoutErrors: { + [standaloneCwd]: new GitCommandError({ + operation: "GitCore.inspectWorktreeLayout", + command: "git worktree list --porcelain", + cwd: standaloneCwd, + detail: "git worktree list failed", + }), + }, + }); + + await expect( + runtime!.runPromise(harness.service.previewCodexImport({ codexBinaryPath: "", codexHomePath: "" })), + ).rejects.toThrow("git worktree list failed"); + }); + + it("imports main and worktree sessions with T3's worktree precedence and preserves the raw runtime cwd", async () => { + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-import-root-")); + const worktreeCwd = path.join(rootCwd, "worktrees", "feature-import"); + fs.mkdirSync(worktreeCwd, { recursive: true }); + cleanupPaths.add(rootCwd); + + const harness = await createHarness({ + threadReads: { + "remote-main": { + id: "remote-main", + preview: "main preview", + modelProvider: "openai", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + cwd: rootCwd, + sourceKind: "cli", + name: "Main import", + branch: "main", + turns: [ + { + id: "turn-main", + items: [ + { + id: "item-main-user", + type: "userMessage", + content: [{ type: "text", text: "hello from user" }], + }, + { + id: "item-main-assistant", + type: "agentMessage", + text: "hi", + }, + { + id: "item-main-plan", + type: "plan", + text: "1. do the thing", + }, + { + id: "item-main-command", + type: "commandExecution", + command: "pwd", + }, + ], + }, + ], + }, + "remote-worktree": { + id: "remote-worktree", + preview: "wt preview", + modelProvider: "openai", + createdAt: "2026-03-08T00:00:02.000Z", + updatedAt: "2026-03-08T00:00:03.000Z", + cwd: worktreeCwd, + sourceKind: "cli", + name: "Worktree import", + branch: "feature-import", + turns: [{ id: "turn-wt", items: [{ id: "item-wt", type: "agentMessage", text: "hello" }] }], + }, + }, + layouts: { + [rootCwd]: { + isRepo: true, + workspaceRoot: rootCwd, + currentTopLevel: rootCwd, + currentWorktreePath: null, + worktrees: [{ path: rootCwd }, { path: worktreeCwd }], + }, + [worktreeCwd]: { + isRepo: true, + workspaceRoot: rootCwd, + currentTopLevel: worktreeCwd, + currentWorktreePath: worktreeCwd, + worktrees: [{ path: rootCwd }, { path: worktreeCwd }], + }, + }, + }); + + const result = await runtime!.runPromise( + harness.service.importCodexSessions({ + codexBinaryPath: "", + codexHomePath: "", + selections: [ + { + cwd: rootCwd, + projectId: null, + createProject: true, + projectTitle: "Imported Root", + providerThreadIds: ["remote-main", "remote-worktree"], + }, + ], + }), + ); + + expect(result.createdProjectIds).toHaveLength(1); + expect(result.importedThreadIds).toHaveLength(2); + + const importedMain = harness.snapshot.threads.find((thread) => thread.title === "Main import"); + const importedWorktree = harness.snapshot.threads.find( + (thread) => thread.title === "Worktree import", + ); + expect(importedMain?.worktreePath).toBeNull(); + expect(importedWorktree?.worktreePath).toBe(worktreeCwd); + + const worktreeBinding = importedWorktree + ? harness.bindings.get(importedWorktree.id) + : undefined; + expect(worktreeBinding?.runtimePayload).toMatchObject({ + cwd: worktreeCwd, + }); + expect( + harness.messageRows.filter((row) => row.threadId === importedMain?.id).map((row) => row.text), + ).toEqual(["hello from user", "hi"]); + expect( + harness.planRows.filter((row) => row.threadId === importedMain?.id).map((row) => row.planMarkdown), + ).toEqual(["1. do the thing"]); + expect( + harness.activityRows.filter((row) => row.threadId === importedMain?.id).map((row) => row.kind), + ).toEqual(["codex.commandExecution"]); + }); + + it("refreshes an existing imported thread by updating branch and worktree path", async () => { + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-refresh-root-")); + const worktreeCwd = path.join(rootCwd, "worktrees", "feature-refresh"); + fs.mkdirSync(worktreeCwd, { recursive: true }); + cleanupPaths.add(rootCwd); + + const projectId = ProjectId.makeUnsafe("project-refresh"); + const threadId = ThreadId.makeUnsafe("thread-refresh"); + const harness = await createHarness({ + snapshot: { + ...makeSnapshot(), + projects: [makeProject({ id: projectId, title: "Refresh", workspaceRoot: rootCwd })], + threads: [ + makeThread({ + id: threadId, + projectId, + title: "Old title", + branch: "main", + worktreePath: null, + external: { + provider: "codex", + providerThreadId: "remote-refresh", + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: null, + }, + }), + ], + }, + externalRows: [ + { + threadId, + projectId, + provider: "codex", + providerThreadId: "remote-refresh", + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: null, + }, + ], + threadReads: { + "remote-refresh": { + id: "remote-refresh", + preview: "refresh preview", + modelProvider: "openai", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:02.000Z", + cwd: worktreeCwd, + sourceKind: "cli", + name: "Updated title", + branch: "feature-refresh", + turns: [ + { + id: "turn-refresh", + items: [ + { + id: "item-refresh-assistant", + type: "agentMessage", + text: "fresh assistant reply", + }, + ], + }, + ], + }, + }, + initialImportedMessages: [ + { + messageId: "stale-message", + threadId, + turnId: null, + role: "assistant", + text: "stale assistant reply", + isStreaming: false, + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:00.000Z", + }, + ], + initialImportedPlans: [ + { + planId: "stale-plan", + threadId, + turnId: null, + planMarkdown: "stale plan", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:00.000Z", + }, + ], + initialImportedActivities: [ + { + activityId: "stale-activity", + threadId, + turnId: null, + tone: "info", + kind: "codex.reasoning", + summary: "stale activity", + payload: {}, + createdAt: "2026-03-08T00:00:00.000Z", + }, + ], + layouts: { + [worktreeCwd]: { + isRepo: true, + workspaceRoot: rootCwd, + currentTopLevel: worktreeCwd, + currentWorktreePath: worktreeCwd, + worktrees: [{ path: rootCwd }, { path: worktreeCwd }], + }, + }, + }); + + const result = await runtime!.runPromise( + harness.service.importCodexSessions({ + codexBinaryPath: "", + codexHomePath: "", + selections: [ + { + cwd: rootCwd, + projectId, + createProject: false, + projectTitle: "Refresh", + providerThreadIds: ["remote-refresh"], + }, + ], + }), + ); + + expect(result.refreshedThreadIds).toEqual([threadId]); + expect(harness.snapshot.threads[0]).toMatchObject({ + title: "Updated title", + branch: "feature-refresh", + worktreePath: worktreeCwd, + }); + expect(harness.messageRows.filter((row) => row.threadId === threadId).map((row) => row.text)).toEqual([ + "fresh assistant reply", + ]); + expect(harness.planRows.filter((row) => row.threadId === threadId)).toEqual([]); + expect(harness.activityRows.filter((row) => row.threadId === threadId)).toEqual([]); + }); + + it("reuses the same local thread id when a retry follows a partial import failure", async () => { + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-retry-root-")); + cleanupPaths.add(rootCwd); + + const harness = await createHarness({ + threadReads: { + "remote-retry": { + id: "remote-retry", + preview: "retry preview", + modelProvider: "openai", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + cwd: rootCwd, + sourceKind: "cli", + name: "Retry import", + branch: "main", + turns: [], + }, + }, + layouts: { + [rootCwd]: { + isRepo: true, + workspaceRoot: rootCwd, + currentTopLevel: rootCwd, + currentWorktreePath: null, + worktrees: [{ path: rootCwd }], + }, + }, + providerBindingUpsertError: new Error("binding failed"), + providerBindingUpsertFailureCount: 1, + }); + + const firstAttempt = await runtime!.runPromise( + harness.service.importCodexSessions({ + codexBinaryPath: "", + codexHomePath: "", + selections: [ + { + cwd: rootCwd, + projectId: null, + createProject: true, + projectTitle: "Retry Root", + providerThreadIds: ["remote-retry"], + }, + ], + }), + ); + + expect(firstAttempt.importedThreadIds).toEqual([]); + expect(firstAttempt.failures).toEqual([ + { + providerThreadId: "remote-retry", + message: "binding failed", + }, + ]); + expect(harness.snapshot.threads).toHaveLength(1); + + const partialThreadId = harness.snapshot.threads[0]!.id; + const secondAttempt = await runtime!.runPromise( + harness.service.importCodexSessions({ + codexBinaryPath: "", + codexHomePath: "", + selections: [ + { + cwd: rootCwd, + projectId: harness.snapshot.projects[0]!.id, + createProject: false, + projectTitle: "Retry Root", + providerThreadIds: ["remote-retry"], + }, + ], + }), + ); + + expect(secondAttempt.failures).toEqual([]); + expect(secondAttempt.refreshedThreadIds).toEqual([partialThreadId]); + expect(harness.snapshot.threads).toHaveLength(1); + expect(harness.snapshot.threads[0]!.id).toBe(partialThreadId); + }); + + it("skips sessions that became adopted between preview and import", async () => { + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-adopted-root-")); + cleanupPaths.add(rootCwd); + + const projectId = ProjectId.makeUnsafe("project-adopted"); + const threadId = ThreadId.makeUnsafe("thread-adopted"); + const harness = await createHarness({ + snapshot: { + ...makeSnapshot(), + projects: [makeProject({ id: projectId, title: "Adopted", workspaceRoot: rootCwd })], + threads: [ + makeThread({ + id: threadId, + projectId, + title: "Already continued", + external: { + provider: "codex", + providerThreadId: "remote-adopted", + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: "2026-03-08T00:05:00.000Z", + }, + }), + ], + }, + externalRows: [ + { + threadId, + projectId, + provider: "codex", + providerThreadId: "remote-adopted", + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: "2026-03-08T00:05:00.000Z", + }, + ], + threadReads: { + "remote-adopted": { + id: "remote-adopted", + preview: "adopted preview", + modelProvider: "openai", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + cwd: rootCwd, + sourceKind: "cli", + name: "Already continued", + branch: "main", + turns: [], + }, + }, + layouts: { + [rootCwd]: { + isRepo: true, + workspaceRoot: rootCwd, + currentTopLevel: rootCwd, + currentWorktreePath: null, + worktrees: [{ path: rootCwd }], + }, + }, + }); + + const result = await runtime!.runPromise( + harness.service.importCodexSessions({ + codexBinaryPath: "", + codexHomePath: "", + selections: [ + { + cwd: rootCwd, + projectId, + createProject: false, + projectTitle: "Adopted", + providerThreadIds: ["remote-adopted"], + }, + ], + }), + ); + + expect(result.importedThreadIds).toEqual([]); + expect(result.refreshedThreadIds).toEqual([]); + expect(result.skippedProviderThreadIds).toEqual(["remote-adopted"]); + expect(result.failures).toEqual([]); + }); + + it("rejects importing a session when its resolved root no longer matches the selected project root", async () => { + const selectedRootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-selected-root-")); + const actualRootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-actual-root-")); + cleanupPaths.add(selectedRootCwd); + cleanupPaths.add(actualRootCwd); + + const harness = await createHarness({ + threadReads: { + "remote-mismatch": { + id: "remote-mismatch", + preview: "mismatch preview", + modelProvider: "openai", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + cwd: actualRootCwd, + sourceKind: "cli", + name: "Mismatch", + branch: "main", + turns: [], + }, + }, + layouts: { + [actualRootCwd]: { + isRepo: true, + workspaceRoot: actualRootCwd, + currentTopLevel: actualRootCwd, + currentWorktreePath: null, + worktrees: [{ path: actualRootCwd }], + }, + }, + }); + + const result = await runtime!.runPromise( + harness.service.importCodexSessions({ + codexBinaryPath: "", + codexHomePath: "", + selections: [ + { + cwd: selectedRootCwd, + projectId: null, + createProject: true, + projectTitle: "Selected Root", + providerThreadIds: ["remote-mismatch"], + }, + ], + }), + ); + + expect(result.importedThreadIds).toEqual([]); + expect(result.failures).toEqual([ + { + providerThreadId: "remote-mismatch", + message: `Session 'remote-mismatch' belongs to '${actualRootCwd}', not '${selectedRootCwd}'. Refresh the import list and try again.`, + }, + ]); + }); + + it("returns a partial-success error when Codex archive succeeds but local delete fails", async () => { + const projectId = ProjectId.makeUnsafe("project-delete-fail"); + const threadId = ThreadId.makeUnsafe("thread-delete-fail"); + const remoteThreadId = "remote-delete-fail"; + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-delete-fail-")); + cleanupPaths.add(rootCwd); + + const harness = await createHarness({ + snapshot: { + ...makeSnapshot(), + projects: [makeProject({ id: projectId, title: "Delete Fail", workspaceRoot: rootCwd })], + threads: [ + { + ...makeThread({ + id: threadId, + projectId, + title: "Delete fail", + external: { + provider: "codex", + providerThreadId: remoteThreadId, + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: null, + }, + }), + session: { + threadId, + providerName: "codex", + status: "running", + updatedAt: "2026-03-08T00:00:00.000Z", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + }, + }, + ], + }, + externalRows: [ + { + threadId, + projectId, + provider: "codex", + providerThreadId: remoteThreadId, + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: null, + }, + ], + dispatchErrorsByType: { + "thread.delete": new Error("local delete failed"), + }, + }); + + await expect( + runtime!.runPromise(harness.service.deleteThread({ threadId })), + ).rejects.toThrow("archived"); + expect(harness.stopSessionCalls).toEqual([threadId]); + expect(harness.stopSessionDeletedAts).toEqual([null]); + expect(harness.archiveCalls).toEqual([remoteThreadId]); + expect(harness.snapshot.threads[0]?.deletedAt).toBeNull(); + }); + + it("does not delete the local thread when remote archive fails", async () => { + const projectId = ProjectId.makeUnsafe("project-delete-archive"); + const threadId = ThreadId.makeUnsafe("thread-delete-archive"); + const remoteThreadId = "remote-delete-archive"; + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-delete-archive-")); + cleanupPaths.add(rootCwd); + + const harness = await createHarness({ + snapshot: { + ...makeSnapshot(), + projects: [makeProject({ id: projectId, title: "Delete Archive", workspaceRoot: rootCwd })], + threads: [ + makeThread({ + id: threadId, + projectId, + title: "Delete archive", + external: { + provider: "codex", + providerThreadId: remoteThreadId, + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: null, + }, + }), + ], + }, + externalRows: [ + { + threadId, + projectId, + provider: "codex", + providerThreadId: remoteThreadId, + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: null, + }, + ], + archiveThreadError: new Error("archive failed"), + }); + + await expect( + runtime!.runPromise(harness.service.deleteThread({ threadId })), + ).rejects.toThrow("was not deleted in T3"); + expect(harness.archiveCalls).toEqual([remoteThreadId]); + expect(harness.snapshot.threads[0]?.deletedAt).toBeNull(); + }); + + it("deletes an imported running thread by stopping the session, archiving Codex, and then deleting locally", async () => { + const projectId = ProjectId.makeUnsafe("project-delete-imported-running"); + const threadId = ThreadId.makeUnsafe("thread-delete-imported-running"); + const remoteThreadId = "remote-delete-imported-running"; + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-codex-delete-running-")); + cleanupPaths.add(rootCwd); + + const harness = await createHarness({ + snapshot: { + ...makeSnapshot(), + projects: [makeProject({ id: projectId, title: "Delete Running", workspaceRoot: rootCwd })], + threads: [ + { + ...makeThread({ + id: threadId, + projectId, + title: "Delete running", + external: { + provider: "codex", + providerThreadId: remoteThreadId, + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: null, + }, + }), + session: { + threadId, + providerName: "codex", + status: "running", + updatedAt: "2026-03-08T00:00:00.000Z", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + }, + }, + ], + }, + externalRows: [ + { + threadId, + projectId, + provider: "codex", + providerThreadId: remoteThreadId, + sourceKind: "cli", + cwd: rootCwd, + modelProvider: "openai", + remoteUpdatedAt: "2026-03-08T00:00:00.000Z", + importedAt: "2026-03-08T00:00:00.000Z", + adoptedAt: null, + }, + ], + }); + + const result = await runtime!.runPromise(harness.service.deleteThread({ threadId })); + + expect(result.archivedExternal).toBe(true); + expect(harness.stopSessionCalls).toEqual([threadId]); + expect(harness.stopSessionDeletedAts).toEqual([null]); + expect(harness.archiveCalls).toEqual([remoteThreadId]); + }); + + it("stops an active session before local delete", async () => { + const projectId = ProjectId.makeUnsafe("project-delete-stop-order"); + const threadId = ThreadId.makeUnsafe("thread-delete-stop-order"); + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-delete-stop-order-")); + cleanupPaths.add(rootCwd); + + const harness = await createHarness({ + snapshot: { + ...makeSnapshot(), + projects: [makeProject({ id: projectId, title: "Stop Order", workspaceRoot: rootCwd })], + threads: [ + { + ...makeThread({ id: threadId, projectId, title: "Stop order" }), + session: { + threadId, + providerName: "codex", + status: "running", + updatedAt: "2026-03-08T00:00:00.000Z", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + }, + }, + ], + }, + }); + + const result = await runtime!.runPromise(harness.service.deleteThread({ threadId })); + + expect(result.archivedExternal).toBe(false); + expect(harness.stopSessionCalls).toEqual([threadId]); + expect(harness.stopSessionDeletedAts).toEqual([null]); + }); + + it("fails the delete before local removal when stopping the active session fails", async () => { + const projectId = ProjectId.makeUnsafe("project-delete-stop-fail"); + const threadId = ThreadId.makeUnsafe("thread-delete-stop-fail"); + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-delete-stop-fail-")); + cleanupPaths.add(rootCwd); + + const harness = await createHarness({ + snapshot: { + ...makeSnapshot(), + projects: [makeProject({ id: projectId, title: "Stop Fail", workspaceRoot: rootCwd })], + threads: [ + { + ...makeThread({ id: threadId, projectId, title: "Stop fail" }), + session: { + threadId, + providerName: "codex", + status: "running", + updatedAt: "2026-03-08T00:00:00.000Z", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + }, + }, + ], + }, + stopSessionError: new Error("stop failed"), + }); + + await expect( + runtime!.runPromise(harness.service.deleteThread({ threadId })), + ).rejects.toThrow("thread was not deleted"); + expect(harness.stopSessionCalls).toEqual([threadId]); + expect(harness.stopSessionDeletedAts).toEqual([null]); + }); + + it("does not call Codex archive for native thread delete", async () => { + const projectId = ProjectId.makeUnsafe("project-delete-native"); + const threadId = ThreadId.makeUnsafe("thread-delete-native"); + const rootCwd = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-native-delete-")); + cleanupPaths.add(rootCwd); + + const harness = await createHarness({ + snapshot: { + ...makeSnapshot(), + projects: [makeProject({ id: projectId, title: "Native Delete", workspaceRoot: rootCwd })], + threads: [makeThread({ id: threadId, projectId, title: "Native delete" })], + }, + }); + + const result = await runtime!.runPromise(harness.service.deleteThread({ threadId })); + + expect(result.archivedExternal).toBe(false); + expect(harness.archiveCalls).toEqual([]); + expect(harness.snapshot.threads[0]?.deletedAt).toBe("2026-03-08T00:01:00.000Z"); + }); +}); diff --git a/apps/server/src/codexImport/Layers/CodexImportService.ts b/apps/server/src/codexImport/Layers/CodexImportService.ts new file mode 100644 index 000000000000..006b755361bd --- /dev/null +++ b/apps/server/src/codexImport/Layers/CodexImportService.ts @@ -0,0 +1,920 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + DEFAULT_MODEL_BY_PROVIDER, + EventId, + OrchestrationProposedPlanId, + type OrchestrationReadModel, + type OrchestrationThreadActivityTone, + ProjectId, + MessageId, + type ServerImportCodexSessionsInput, + ThreadId, + type ServerDeleteThreadResult, + type ServerImportCodexSessionsResult, + type ServerPreviewCodexImportResult, +} from "@t3tools/contracts"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; + +import { + CodexImportBrowser, + type CodexImportBrowserSession, + type CodexImportBrowserOverrides, +} from "../Services/CodexImportBrowser.ts"; +import { + CodexImportService, + CodexImportServiceError, + type CodexImportServiceShape, +} from "../Services/CodexImportService.ts"; +import { CodexImportBrowserLive } from "./CodexImportBrowser.ts"; +import { expandHomePath } from "../../os-jank.ts"; +import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { GitServiceLive } from "../../git/Layers/GitService.ts"; +import { GitCore } from "../../git/Services/GitCore.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; +import { ProviderSessionDirectoryLive } from "../../provider/Layers/ProviderSessionDirectory.ts"; +import { + ProjectionExternalThreadRepositoryLive, +} from "../../persistence/Layers/ProjectionExternalThreads.ts"; +import { + ProjectionImportedThreadActivityRepositoryLive, +} from "../../persistence/Layers/ProjectionImportedThreadActivities.ts"; +import { + ProjectionImportedThreadMessageRepositoryLive, +} from "../../persistence/Layers/ProjectionImportedThreadMessages.ts"; +import { + ProjectionImportedThreadProposedPlanRepositoryLive, +} from "../../persistence/Layers/ProjectionImportedThreadProposedPlans.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; +import { ProjectionExternalThreadRepository } from "../../persistence/Services/ProjectionExternalThreads.ts"; +import { ProjectionImportedThreadActivityRepository } from "../../persistence/Services/ProjectionImportedThreadActivities.ts"; +import { ProjectionImportedThreadMessageRepository } from "../../persistence/Services/ProjectionImportedThreadMessages.ts"; +import { ProjectionImportedThreadProposedPlanRepository } from "../../persistence/Services/ProjectionImportedThreadProposedPlans.ts"; +type CodexImportOverrides = CodexImportBrowserOverrides; + +type PreviewSession = ServerPreviewCodexImportResult["groups"][number]["mainSessions"][number]; +type PreviewWorktree = { + cwd: string; + displayCwd: string; + cwdExists: boolean; + sessions: PreviewSession[]; +}; +type PreviewGroup = { + cwd: string; + displayCwd: string; + cwdExists: boolean; + existingProjectId: ServerPreviewCodexImportResult["groups"][number]["existingProjectId"]; + existingProjectTitle: ServerPreviewCodexImportResult["groups"][number]["existingProjectTitle"]; + suggestedProjectTitle: string; + mainSessions: PreviewSession[]; + worktrees: PreviewWorktree[]; +}; + +type ResolvedImportWorkspace = { + readonly workspaceRoot: string; + readonly currentWorktreePath: string | null; + readonly workspaceRootExists: boolean; + readonly currentCwdExists: boolean; +}; + +function toImportError(message: string): CodexImportServiceError { + return new CodexImportServiceError({ message }); +} + +function isCodexImportServiceError(error: unknown): error is CodexImportServiceError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "CodexImportServiceError" + ); +} + +function errorMessage(error: unknown, fallback: string): string { + if (Cause.isCause(error)) { + const squashed = Cause.squash(error); + const message = + squashed instanceof Error ? squashed.message.trim() : String(squashed).trim(); + return message.length > 0 ? message : Cause.pretty(error); + } + return error instanceof Error ? error.message : fallback; +} + +function normalizeOptionalText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function readObject(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readString(value: unknown, key: string): string | undefined { + const record = readObject(value); + const candidate = record?.[key]; + return typeof candidate === "string" ? candidate : undefined; +} + +function readArray(value: unknown, key: string): unknown[] { + const record = readObject(value); + const candidate = record?.[key]; + return Array.isArray(candidate) ? candidate : []; +} + +function threadDisplayTitle(summary: { name: string | null; preview: string }): string { + const named = summary.name?.trim(); + if (named) return named; + const preview = summary.preview.trim(); + return preview || "Imported Codex thread"; +} + +function readStoredCodexOverrides(runtimePayload: unknown): CodexImportOverrides { + const providerOptions = readObject(readObject(runtimePayload)?.providerOptions); + const codex = readObject(providerOptions?.codex); + const binaryPath = normalizeOptionalText(readString(codex, "binaryPath")); + const homePath = normalizeOptionalText(readString(codex, "homePath")); + return { + binaryPath, + homePath, + }; +} + +function makeProjectTitleFromCwd(cwd: string, path: Path.Path): string { + const basename = path.basename(cwd).trim(); + return basename.length > 0 ? basename : cwd; +} + +function compactHomePathForDisplay(input: string): string { + const homePath = os.homedir().trim(); + if (!homePath) { + return input; + } + if (input === homePath) { + return "~"; + } + const normalizedHomePath = homePath.replace(/[\\/]+$/, ""); + const unixPrefix = `${normalizedHomePath}/`; + if (input.startsWith(unixPrefix)) { + return `~/${input.slice(unixPrefix.length)}`; + } + const windowsPrefix = `${normalizedHomePath}\\`; + if (input.startsWith(windowsPrefix)) { + return `~\\${input.slice(windowsPrefix.length)}`; + } + return input; +} + +function addSessionToPreviewGroup( + group: PreviewGroup, + worktreePath: string | null, + worktreeExists: boolean, + session: PreviewSession, +): PreviewGroup { + if (!worktreePath) { + group.mainSessions.push(session); + return group; + } + let worktreeGroup = group.worktrees.find((candidate) => candidate.cwd === worktreePath); + if (!worktreeGroup) { + worktreeGroup = { + cwd: worktreePath, + displayCwd: compactHomePathForDisplay(worktreePath), + cwdExists: worktreeExists, + sessions: [], + }; + group.worktrees.push(worktreeGroup); + } else if (worktreeGroup.cwdExists !== worktreeExists) { + worktreeGroup.cwdExists = worktreeExists; + } + worktreeGroup.sessions.push(session); + return group; +} + +function messageTextFromUserInput(input: Record): string { + const type = readString(input, "type"); + if (type === "text") { + return readString(input, "text") ?? ""; + } + if (type === "image") { + return `[Image: ${readString(input, "url") ?? "unknown"}]`; + } + if (type === "localImage") { + return `[Local image: ${readString(input, "path") ?? "unknown"}]`; + } + if (type === "skill") { + return `[Skill: ${readString(input, "name") ?? "unknown"}]`; + } + if (type === "mention") { + return `[Mention: ${readString(input, "name") ?? "unknown"}]`; + } + return "[User input]"; +} + +function summarizeImportedActivity(kind: string, item: Record): string { + switch (kind) { + case "reasoning": { + const firstSummary = readArray(item, "summary").find( + (value): value is string => typeof value === "string", + ); + return firstSummary?.trim() || "Reasoning"; + } + case "commandExecution": + return readString(item, "command")?.trim() || "Command execution"; + case "fileChange": + return `File change (${readArray(item, "changes").length})`; + case "webSearch": + return `Web search: ${readString(item, "query") ?? "query"}`; + case "mcpToolCall": + return `MCP tool: ${readString(item, "tool") ?? "tool"}`; + case "dynamicToolCall": + return `Dynamic tool: ${readString(item, "tool") ?? "tool"}`; + case "collabAgentToolCall": + return `Collab tool: ${readString(item, "tool") ?? "tool"}`; + case "imageView": + return `Viewed image: ${readString(item, "path") ?? "path"}`; + case "imageGeneration": + return "Generated image"; + case "enteredReviewMode": + return "Entered review mode"; + case "exitedReviewMode": + return "Exited review mode"; + case "contextCompaction": + return "Context compaction"; + default: + return kind; + } +} + +function toneForImportedActivity(kind: string): OrchestrationThreadActivityTone { + if ( + kind === "commandExecution" || + kind === "fileChange" || + kind === "mcpToolCall" || + kind === "dynamicToolCall" || + kind === "collabAgentToolCall" || + kind === "webSearch" || + kind === "imageView" || + kind === "imageGeneration" + ) { + return "tool"; + } + return "info"; +} + +export const makeCodexImportService = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = yield* GitCore; + const codexBrowser = yield* CodexImportBrowser; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const orchestration = yield* OrchestrationEngineService; + const providerService = yield* ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory; + const externalThreads = yield* ProjectionExternalThreadRepository; + const importedMessages = yield* ProjectionImportedThreadMessageRepository; + const importedPlans = yield* ProjectionImportedThreadProposedPlanRepository; + const importedActivities = yield* ProjectionImportedThreadActivityRepository; + + const resolveOverrides = Effect.fn(function* (input: { + readonly codexBinaryPath?: string | undefined; + readonly codexHomePath?: string | undefined; + }) { + const binaryPath = normalizeOptionalText(input.codexBinaryPath); + const homePath = normalizeOptionalText(input.codexHomePath); + return { + binaryPath: binaryPath + ? yield* expandHomePath(binaryPath).pipe(Effect.provideService(Path.Path, path)) + : undefined, + homePath: homePath + ? yield* expandHomePath(homePath).pipe(Effect.provideService(Path.Path, path)) + : undefined, + } satisfies CodexImportOverrides; + }); + + const getSnapshot = () => projectionSnapshotQuery.getSnapshot(); + + const resolveImportWorkspace = Effect.fn(function* (cwd: string) { + const fallbackCwdStat = yield* fileSystem.stat(cwd).pipe(Effect.catch(() => Effect.succeed(null))); + const fallback: ResolvedImportWorkspace = { + workspaceRoot: cwd, + currentWorktreePath: null, + workspaceRootExists: fallbackCwdStat?.type === "Directory", + currentCwdExists: fallbackCwdStat?.type === "Directory", + }; + if (fallbackCwdStat?.type !== "Directory") { + return fallback; + } + + const layout = yield* git.inspectWorktreeLayout(cwd); + if (!layout.isRepo) { + return fallback; + } + + const workspaceRootStat = yield* fileSystem + .stat(layout.workspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + const workspaceRootExists = workspaceRootStat?.type === "Directory"; + if (!workspaceRootExists) { + return fallback; + } + + const currentCwdStat = layout.currentWorktreePath + ? yield* fileSystem + .stat(layout.currentWorktreePath) + .pipe(Effect.catch(() => Effect.succeed(null))) + : workspaceRootStat; + + const currentCwdExists = + layout.currentWorktreePath === null + ? workspaceRootExists + : currentCwdStat?.type === "Directory"; + + return { + workspaceRoot: layout.workspaceRoot, + currentWorktreePath: layout.currentWorktreePath, + workspaceRootExists, + currentCwdExists, + } satisfies ResolvedImportWorkspace; + }); + + const previewCodexImport: CodexImportServiceShape["previewCodexImport"] = (input) => + Effect.gen(function* () { + const overrides = yield* resolveOverrides(input); + return yield* codexBrowser.withSession(overrides, (browserSession) => + Effect.gen(function* () { + const [threads, snapshot] = yield* Effect.all( + [browserSession.listThreads(), getSnapshot()], + { concurrency: "unbounded" }, + ); + const existingProjectsByCwd = new Map( + snapshot.projects + .filter((project) => project.deletedAt === null) + .map((project) => [project.workspaceRoot, project] as const), + ); + const existingExternalByProviderThreadId = new Map( + snapshot.threads + .flatMap((thread) => + thread.external + ? [[thread.external.providerThreadId, { threadId: thread.id, external: thread.external }] as const] + : [], + ), + ); + const groups = new Map(); + + for (const thread of threads) { + const workspace = yield* resolveImportWorkspace(thread.cwd); + const existingProject = existingProjectsByCwd.get(workspace.workspaceRoot) ?? null; + const existingExternal = existingExternalByProviderThreadId.get(thread.id) ?? null; + const importState = !workspace.currentCwdExists + ? "unavailable" + : existingExternal?.external.adoptedAt + ? "continued-in-t3" + : existingExternal + ? "already-imported" + : "new"; + const disabledReason = + importState === "unavailable" + ? "Workspace path is not available on this machine." + : importState === "continued-in-t3" + ? "This session has already been continued in T3." + : null; + const group = + groups.get(workspace.workspaceRoot) ?? + { + cwd: workspace.workspaceRoot, + displayCwd: compactHomePathForDisplay(workspace.workspaceRoot), + cwdExists: workspace.workspaceRootExists, + existingProjectId: existingProject?.id ?? null, + existingProjectTitle: existingProject?.title ?? null, + suggestedProjectTitle: makeProjectTitleFromCwd(workspace.workspaceRoot, path), + mainSessions: [], + worktrees: [], + }; + addSessionToPreviewGroup(group, workspace.currentWorktreePath, workspace.currentCwdExists, { + providerThreadId: thread.id, + linkedThreadId: existingExternal?.threadId ?? null, + title: threadDisplayTitle(thread), + preview: thread.preview, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + sourceKind: thread.sourceKind, + modelProvider: thread.modelProvider, + importState, + disabledReason, + }); + groups.set(workspace.workspaceRoot, group); + } + + const previewResult = { + groups: Array.from(groups.values()) + .map((group) => { + const worktrees = group.worktrees + .map((worktree) => ({ + cwd: worktree.cwd, + displayCwd: worktree.displayCwd, + cwdExists: worktree.cwdExists, + sessions: [...worktree.sessions], + })) + .toSorted((left, right) => left.cwd.localeCompare(right.cwd)); + return { + cwd: group.cwd, + displayCwd: group.displayCwd, + cwdExists: group.cwdExists, + existingProjectId: group.existingProjectId, + existingProjectTitle: group.existingProjectTitle, + suggestedProjectTitle: group.suggestedProjectTitle, + mainSessions: [...group.mainSessions], + worktrees, + }; + }) + .toSorted((left, right) => left.cwd.localeCompare(right.cwd)), + } satisfies ServerPreviewCodexImportResult; + const previewSessions = previewResult.groups.flatMap((group) => [ + ...group.mainSessions, + ...group.worktrees.flatMap((worktree) => worktree.sessions), + ]); + yield* Effect.logInfo("codex import preview loaded", { + groupCount: previewResult.groups.length, + sessionCount: previewSessions.length, + unavailableCount: previewSessions.filter((session) => session.importState === "unavailable") + .length, + importedCount: previewSessions.filter((session) => session.importState === "already-imported") + .length, + continuedCount: previewSessions.filter((session) => session.importState === "continued-in-t3") + .length, + hasBinaryOverride: overrides.binaryPath !== undefined, + hasHomeOverride: overrides.homePath !== undefined, + }); + return previewResult; + }), + ); + }).pipe( + Effect.mapError((error) => + isCodexImportServiceError(error) + ? error + : toImportError(errorMessage(error, String(error))), + ), + ); + + const ensureProjectForSelection = Effect.fn(function* ( + snapshot: OrchestrationReadModel, + selection: ServerImportCodexSessionsInput["selections"][number], + createdProjectIds: ProjectId[], + ) { + const existingProject = + snapshot.projects.find( + (project) => project.deletedAt === null && project.workspaceRoot === selection.cwd, + ) ?? + (selection.projectId + ? snapshot.projects.find((project) => project.deletedAt === null && project.id === selection.projectId) + : undefined); + if (existingProject) { + if (existingProject.workspaceRoot !== selection.cwd) { + return yield* toImportError( + `Project '${existingProject.title}' no longer matches the selected root '${selection.cwd}'.`, + ); + } + return existingProject; + } + if (!selection.createProject) { + return yield* toImportError( + `No T3 project exists for '${selection.cwd}', and auto-create was not selected.`, + ); + } + + const cwdStat = yield* fileSystem + .stat(selection.cwd) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!cwdStat || cwdStat.type !== "Directory") { + return yield* toImportError(`Project path does not exist: ${selection.cwd}`); + } + + const projectId = ProjectId.makeUnsafe(randomUUID()); + const createdAt = new Date().toISOString(); + yield* orchestration.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe(randomUUID()), + projectId, + title: selection.projectTitle, + workspaceRoot: selection.cwd, + defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + createdAt, + }); + createdProjectIds.push(projectId); + + return { + id: projectId, + title: selection.projectTitle, + workspaceRoot: selection.cwd, + defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + scripts: [], + createdAt, + updatedAt: createdAt, + deletedAt: null, + }; + }); + + const importRemoteThread = Effect.fn(function* (input: { + readonly project: OrchestrationReadModel["projects"][number]; + readonly providerThreadId: string; + readonly overrides: CodexImportOverrides; + readonly browserSession: Pick; + readonly snapshot: OrchestrationReadModel; + readonly importedThreadIds: ThreadId[]; + readonly refreshedThreadIds: ThreadId[]; + readonly skippedProviderThreadIds: string[]; + }) { + const remoteThread = yield* input.browserSession.readThread(input.providerThreadId); + const workspace = yield* resolveImportWorkspace(remoteThread.cwd); + if (workspace.workspaceRoot !== input.project.workspaceRoot) { + return yield* toImportError( + `Session '${input.providerThreadId}' belongs to '${workspace.workspaceRoot}', not '${input.project.workspaceRoot}'. Refresh the import list and try again.`, + ); + } + const existingLink = yield* externalThreads + .getByProviderThreadId({ providerThreadId: input.providerThreadId }) + .pipe(Effect.map(Option.getOrUndefined)); + + if (existingLink?.adoptedAt) { + input.skippedProviderThreadIds.push(input.providerThreadId); + return; + } + + const localThreadId = existingLink?.threadId ?? ThreadId.makeUnsafe(randomUUID()); + const importedAt = existingLink?.importedAt ?? new Date().toISOString(); + yield* externalThreads.upsert({ + threadId: localThreadId, + projectId: input.project.id, + provider: "codex", + providerThreadId: input.providerThreadId, + sourceKind: remoteThread.sourceKind, + cwd: remoteThread.cwd, + modelProvider: remoteThread.modelProvider, + remoteUpdatedAt: remoteThread.updatedAt, + importedAt, + adoptedAt: existingLink?.adoptedAt ?? null, + }); + const existingThread = + input.snapshot.threads.find((thread) => thread.id === localThreadId) ?? null; + + if (!existingThread) { + yield* orchestration.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(randomUUID()), + threadId: localThreadId, + projectId: input.project.id, + title: threadDisplayTitle(remoteThread), + model: input.project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex, + runtimeMode: "full-access", + interactionMode: "default", + branch: remoteThread.branch, + worktreePath: workspace.currentWorktreePath, + createdAt: remoteThread.createdAt, + }); + } else { + yield* orchestration.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe(randomUUID()), + threadId: localThreadId, + title: threadDisplayTitle(remoteThread), + branch: remoteThread.branch, + worktreePath: workspace.currentWorktreePath, + }); + } + + yield* importedMessages.deleteByThreadId({ threadId: localThreadId }); + yield* importedPlans.deleteByThreadId({ threadId: localThreadId }); + yield* importedActivities.deleteByThreadId({ threadId: localThreadId }); + + let sequence = 0; + const anchorMs = Date.parse(remoteThread.createdAt); + for (const turn of remoteThread.turns) { + for (const item of turn.items) { + const itemType = readString(item, "type") ?? "unknown"; + const createdAt = new Date(anchorMs + sequence * 1000).toISOString(); + sequence += 1; + + if (itemType === "userMessage") { + const content = readArray(item, "content") + .map((entry) => messageTextFromUserInput(readObject(entry) ?? {})) + .join("\n") + .trim(); + yield* importedMessages.upsert({ + messageId: MessageId.makeUnsafe( + `import:user:${input.providerThreadId}:${readString(item, "id") ?? sequence}`, + ), + threadId: localThreadId, + turnId: null, + role: "user", + text: content || "[User input]", + isStreaming: false, + createdAt, + updatedAt: createdAt, + }); + continue; + } + + if (itemType === "agentMessage") { + yield* importedMessages.upsert({ + messageId: MessageId.makeUnsafe( + `import:assistant:${input.providerThreadId}:${readString(item, "id") ?? sequence}`, + ), + threadId: localThreadId, + turnId: null, + role: "assistant", + text: readString(item, "text") ?? "", + isStreaming: false, + createdAt, + updatedAt: createdAt, + }); + continue; + } + + if (itemType === "plan") { + const planMarkdown = normalizeOptionalText(readString(item, "text")); + if (planMarkdown) { + yield* importedPlans.upsert({ + planId: OrchestrationProposedPlanId.makeUnsafe( + `import:plan:${input.providerThreadId}:${readString(item, "id") ?? sequence}`, + ), + threadId: localThreadId, + turnId: null, + planMarkdown, + createdAt, + updatedAt: createdAt, + }); + } + continue; + } + + yield* importedActivities.upsert({ + activityId: EventId.makeUnsafe( + `import:activity:${input.providerThreadId}:${readString(item, "id") ?? sequence}`, + ), + threadId: localThreadId, + turnId: null, + tone: toneForImportedActivity(itemType), + kind: `codex.${itemType}`, + summary: summarizeImportedActivity(itemType, item), + payload: item, + createdAt, + }); + } + } + + yield* providerSessionDirectory.upsert({ + threadId: localThreadId, + provider: "codex", + runtimeMode: "full-access", + status: "stopped", + resumeCursor: { threadId: input.providerThreadId }, + runtimePayload: { + cwd: remoteThread.cwd, + model: input.project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex, + providerOptions: { + codex: { + ...(input.overrides.binaryPath ? { binaryPath: input.overrides.binaryPath } : {}), + ...(input.overrides.homePath ? { homePath: input.overrides.homePath } : {}), + }, + }, + }, + }); + + if (existingLink) { + input.refreshedThreadIds.push(localThreadId); + } else { + input.importedThreadIds.push(localThreadId); + } + }); + + const importCodexSessions: CodexImportServiceShape["importCodexSessions"] = (input) => + Effect.gen(function* () { + const overrides = yield* resolveOverrides(input); + return yield* codexBrowser.withSession(overrides, (browserSession) => + Effect.gen(function* () { + const createdProjectIds: ProjectId[] = []; + const importedThreadIds: ThreadId[] = []; + const refreshedThreadIds: ThreadId[] = []; + const skippedProviderThreadIds: string[] = []; + const failures: Array = []; + + let snapshot = yield* getSnapshot(); + for (const selection of input.selections) { + if (selection.providerThreadIds.length === 0) { + continue; + } + + const projectResult = yield* Effect.exit( + ensureProjectForSelection(snapshot, selection, createdProjectIds), + ); + if (Exit.isFailure(projectResult)) { + for (const providerThreadId of selection.providerThreadIds) { + failures.push({ + providerThreadId, + message: errorMessage( + projectResult.cause, + "Failed to prepare target project.", + ), + }); + } + continue; + } + const project = projectResult.value; + snapshot = yield* getSnapshot(); + + for (const providerThreadId of selection.providerThreadIds) { + const result = yield* Effect.exit( + importRemoteThread({ + project, + providerThreadId, + overrides, + browserSession, + snapshot, + importedThreadIds, + refreshedThreadIds, + skippedProviderThreadIds, + }), + ); + if (Exit.isFailure(result)) { + failures.push({ + providerThreadId, + message: errorMessage(result.cause, "Failed to import Codex session."), + }); + continue; + } + snapshot = yield* getSnapshot(); + } + } + + const importResult = { + createdProjectIds, + importedThreadIds, + refreshedThreadIds, + skippedProviderThreadIds, + failures, + } satisfies ServerImportCodexSessionsResult; + yield* ( + failures.length > 0 + ? Effect.logWarning("codex import completed with issues", { + selectionCount: input.selections.length, + createdProjectCount: createdProjectIds.length, + importedCount: importedThreadIds.length, + refreshedCount: refreshedThreadIds.length, + skippedCount: skippedProviderThreadIds.length, + failureCount: failures.length, + }) + : Effect.logInfo("codex import completed", { + selectionCount: input.selections.length, + createdProjectCount: createdProjectIds.length, + importedCount: importedThreadIds.length, + refreshedCount: refreshedThreadIds.length, + skippedCount: skippedProviderThreadIds.length, + }) + ); + return importResult; + }), + ); + }).pipe( + Effect.mapError((error) => + isCodexImportServiceError(error) + ? error + : toImportError(errorMessage(error, String(error))), + ), + ); + + const deleteThread: CodexImportServiceShape["deleteThread"] = (input) => + Effect.gen(function* () { + const snapshot = yield* getSnapshot(); + const thread = snapshot.threads.find( + (entry) => entry.id === input.threadId && entry.deletedAt === null, + ); + if (!thread) { + return yield* toImportError(`Thread '${input.threadId}' was not found.`); + } + + let archivedExternal = false; + const hasActiveSession = + thread.session !== null && + thread.session.status !== "stopped" && + thread.session.status !== "idle"; + let externalToArchive: + | { + providerThreadId: string; + overrides: CodexImportOverrides; + } + | null = null; + if (thread.external?.provider === "codex") { + const external = thread.external; + const binding = yield* providerSessionDirectory.getBinding(input.threadId); + const storedOverrides = Option.match(binding, { + onNone: () => + ({ + binaryPath: undefined, + homePath: undefined, + } satisfies CodexImportOverrides), + onSome: (value) => readStoredCodexOverrides(value.runtimePayload), + }); + externalToArchive = { + providerThreadId: external.providerThreadId, + overrides: storedOverrides, + }; + } + + if (hasActiveSession) { + const stopResult = yield* Effect.exit( + providerService.stopSession({ threadId: input.threadId }), + ); + if (Exit.isFailure(stopResult)) { + yield* Effect.logWarning("codex import delete failed before local delete because session stop failed", { + threadId: input.threadId, + }); + return yield* toImportError( + `Stopping the active session for thread '${input.threadId}' failed, so the thread was not deleted. Please check the provider runtime and try again. (${errorMessage(stopResult.cause, "Session stop failed.")})`, + ); + } + } + + if (externalToArchive) { + const archiveResult = yield* Effect.exit( + codexBrowser.withSession(externalToArchive.overrides, (browserSession) => + browserSession.archiveThread(externalToArchive.providerThreadId), + ), + ); + if (Exit.isFailure(archiveResult)) { + yield* Effect.logWarning("codex import delete failed before local delete because external archive failed", { + threadId: input.threadId, + providerThreadId: externalToArchive.providerThreadId, + }); + return yield* toImportError( + `Archiving the linked Codex session for thread '${input.threadId}' failed, so the thread was not deleted in T3. Please archive it in Codex and try again. (${errorMessage(archiveResult.cause, "Codex archive failed.")})`, + ); + } + archivedExternal = true; + } + + const deleteResult = yield* Effect.exit( + orchestration.dispatch({ + type: "thread.delete", + commandId: CommandId.makeUnsafe(randomUUID()), + threadId: input.threadId, + }), + ); + if (Exit.isFailure(deleteResult)) { + if (externalToArchive && archivedExternal) { + yield* Effect.logWarning("codex import delete archived externally but local delete failed", { + threadId: input.threadId, + providerThreadId: externalToArchive.providerThreadId, + }); + return yield* toImportError( + `The linked Codex session for thread '${input.threadId}' was archived, but deleting the thread in T3 failed. Please refresh T3 and clean up the thread manually if it still appears. (${errorMessage(deleteResult.cause, "Local delete failed.")})`, + ); + } + return yield* Effect.failCause(deleteResult.cause); + } + + yield* Effect.logInfo("codex import delete completed", { + threadId: input.threadId, + archivedExternal, + hadActiveSession: hasActiveSession, + }); + return { + sequence: deleteResult.value.sequence, + archivedExternal, + } satisfies ServerDeleteThreadResult; + }).pipe( + Effect.mapError((error) => + isCodexImportServiceError(error) + ? error + : toImportError(errorMessage(error, String(error))), + ), + ); + + return { + previewCodexImport, + importCodexSessions, + deleteThread, + } satisfies CodexImportServiceShape; +}); + +export const CodexImportServiceLive = Layer.effect( + CodexImportService, + makeCodexImportService, +).pipe( + Layer.provideMerge(GitCoreLive.pipe(Layer.provideMerge(GitServiceLive), Layer.provideMerge(NodeServices.layer))), + Layer.provideMerge(CodexImportBrowserLive), + Layer.provideMerge( + ProviderSessionDirectoryLive.pipe(Layer.provideMerge(ProviderSessionRuntimeRepositoryLive)), + ), + Layer.provideMerge(ProjectionExternalThreadRepositoryLive), + Layer.provideMerge(ProjectionImportedThreadMessageRepositoryLive), + Layer.provideMerge(ProjectionImportedThreadProposedPlanRepositoryLive), + Layer.provideMerge(ProjectionImportedThreadActivityRepositoryLive), +); diff --git a/apps/server/src/codexImport/Services/CodexImportBrowser.ts b/apps/server/src/codexImport/Services/CodexImportBrowser.ts new file mode 100644 index 000000000000..7bc4e62f1242 --- /dev/null +++ b/apps/server/src/codexImport/Services/CodexImportBrowser.ts @@ -0,0 +1,66 @@ +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export type CodexImportSourceKind = "cli" | "vscode"; + +export interface CodexThreadSummary { + readonly id: string; + readonly preview: string; + readonly modelProvider: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly cwd: string; + readonly sourceKind: CodexImportSourceKind; + readonly name: string | null; + readonly branch: string | null; +} + +export interface CodexThreadRead extends CodexThreadSummary { + readonly turns: ReadonlyArray<{ + readonly id: string; + readonly items: ReadonlyArray>; + }>; +} + +export interface CodexImportBrowserOverrides { + readonly binaryPath: string | undefined; + readonly homePath: string | undefined; +} + +export class CodexImportBrowserError extends Schema.TaggedErrorClass()( + "CodexImportBrowserError", + { + message: Schema.String, + }, +) {} + +export interface CodexImportBrowserSession { + readonly listThreads: () => Effect.Effect, CodexImportBrowserError>; + readonly readThread: (threadId: string) => Effect.Effect; + readonly archiveThread: ( + threadId: string, + ) => Effect.Effect; +} + +export interface CodexImportBrowserShape { + readonly withSession: ( + overrides: CodexImportBrowserOverrides, + effect: (session: CodexImportBrowserSession) => Effect.Effect, + ) => Effect.Effect; + readonly listThreads: ( + overrides: CodexImportBrowserOverrides, + ) => Effect.Effect, CodexImportBrowserError>; + readonly readThread: ( + overrides: CodexImportBrowserOverrides, + threadId: string, + ) => Effect.Effect; + readonly archiveThread: ( + overrides: CodexImportBrowserOverrides, + threadId: string, + ) => Effect.Effect; +} + +export class CodexImportBrowser extends ServiceMap.Service< + CodexImportBrowser, + CodexImportBrowserShape +>()("t3/codexImport/Services/CodexImportBrowser") {} diff --git a/apps/server/src/codexImport/Services/CodexImportService.ts b/apps/server/src/codexImport/Services/CodexImportService.ts new file mode 100644 index 000000000000..787920003b61 --- /dev/null +++ b/apps/server/src/codexImport/Services/CodexImportService.ts @@ -0,0 +1,34 @@ +import type { + ServerDeleteThreadInput, + ServerDeleteThreadResult, + ServerImportCodexSessionsInput, + ServerImportCodexSessionsResult, + ServerPreviewCodexImportInput, + ServerPreviewCodexImportResult, +} from "@t3tools/contracts"; +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export class CodexImportServiceError extends Schema.TaggedErrorClass()( + "CodexImportServiceError", + { + message: Schema.String, + }, +) {} + +export interface CodexImportServiceShape { + readonly previewCodexImport: ( + input: ServerPreviewCodexImportInput, + ) => Effect.Effect; + readonly importCodexSessions: ( + input: ServerImportCodexSessionsInput, + ) => Effect.Effect; + readonly deleteThread: ( + input: ServerDeleteThreadInput, + ) => Effect.Effect; +} + +export class CodexImportService extends ServiceMap.Service< + CodexImportService, + CodexImportServiceShape +>()("t3/codexImport/Services/CodexImportService") {} diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index d03ad60615fb..5c2fcb868e1f 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -111,6 +111,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => checkoutBranch: (input) => core.checkoutBranch(input), initRepo: (input) => core.initRepo(input), listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd), + inspectWorktreeLayout: (cwd) => core.inspectWorktreeLayout(cwd), } satisfies GitCoreShape; }); @@ -142,6 +143,13 @@ function checkoutGitBranch(input: Parameters[0]) }); } +function inspectGitWorktreeLayout(cwd: string) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.inspectWorktreeLayout(cwd); + }); +} + function createGitWorktree(input: Parameters[0]) { return Effect.gen(function* () { const core = yield* GitCore; @@ -1114,6 +1122,72 @@ it.layer(TestLayer)("git integration", (it) => { ); }); + describe("inspectWorktreeLayout", () => { + it.effect("reports the main workspace root and null worktree path for the main checkout", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + + const layout = yield* inspectGitWorktreeLayout(tmp); + const normalizedTmp = realpathSync.native(tmp); + + expect(layout.isRepo).toBe(true); + expect(layout.workspaceRoot).toBe(normalizedTmp); + expect(layout.currentTopLevel).toBe(normalizedTmp); + expect(layout.currentWorktreePath).toBeNull(); + expect(layout.worktrees).toEqual([{ path: normalizedTmp }]); + }), + ); + + it.effect("reports the owning workspace root and linked worktree path for a linked worktree", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const wtPath = path.join(tmp, "wt-layout"); + const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (branch) => branch.current, + )!.name; + + yield* createGitWorktree({ + cwd: tmp, + branch: currentBranch, + newBranch: "feature/layout", + path: wtPath, + }); + + const layout = yield* inspectGitWorktreeLayout(wtPath); + const normalizedTmp = realpathSync.native(tmp); + const normalizedWtPath = realpathSync.native(wtPath); + + expect(layout.isRepo).toBe(true); + expect(layout.workspaceRoot).toBe(normalizedTmp); + expect(layout.currentTopLevel).toBe(normalizedWtPath); + expect(layout.currentWorktreePath).toBe(normalizedWtPath); + expect(layout.worktrees).toEqual( + expect.arrayContaining([{ path: normalizedTmp }, { path: normalizedWtPath }]), + ); + + yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + }), + ); + + it.effect("falls back cleanly for directories that are not git repositories", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + + const layout = yield* inspectGitWorktreeLayout(tmp); + + expect(layout).toEqual({ + isRepo: false, + workspaceRoot: tmp, + currentTopLevel: null, + currentWorktreePath: null, + worktrees: [], + }); + }), + ); + }); + // ── Full flow: local branch checkout ── describe("full flow: local branch checkout", () => { diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index a288b2f3799c..d65dd85b3b9f 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -2,7 +2,7 @@ import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "ef import { GitCommandError } from "../Errors.ts"; import { GitService } from "../Services/GitService.ts"; -import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; +import { GitCore, type GitCoreShape, type GitWorktreeLayout } from "../Services/GitCore.ts"; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -100,6 +100,20 @@ function parseRemoteNames(stdout: string): ReadonlyArray { .toSorted((a, b) => b.length - a.length); } +function parseWorktreePaths(stdout: string): ReadonlyArray { + const worktrees: string[] = []; + for (const line of stdout.split("\n")) { + if (!line.startsWith("worktree ")) { + continue; + } + const candidate = line.slice("worktree ".length).trim(); + if (candidate.length > 0) { + worktrees.push(candidate); + } + } + return worktrees; +} + function parseRemoteRefWithRemoteNames( branchName: string, remoteNames: ReadonlyArray, @@ -1186,6 +1200,97 @@ const makeGitCore = Effect.gen(function* () { ), ); + const inspectWorktreeLayout: GitCoreShape["inspectWorktreeLayout"] = (cwd) => + Effect.gen(function* () { + const [topLevelResult, commonDirResult, worktreeListResult] = yield* Effect.all( + [ + executeGit("GitCore.inspectWorktreeLayout.showTopLevel", cwd, ["rev-parse", "--show-toplevel"], { + timeoutMs: 5_000, + allowNonZeroExit: true, + }), + executeGit("GitCore.inspectWorktreeLayout.gitCommonDir", cwd, ["rev-parse", "--git-common-dir"], { + timeoutMs: 5_000, + allowNonZeroExit: true, + }), + executeGit( + "GitCore.inspectWorktreeLayout.worktreeList", + cwd, + ["worktree", "list", "--porcelain"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + ], + { concurrency: "unbounded" }, + ); + + const isNotRepo = [topLevelResult, commonDirResult].some((result) => + result.code !== 0 && result.stderr.toLowerCase().includes("not a git repository") + ); + if (isNotRepo) { + return { + isRepo: false, + workspaceRoot: cwd, + currentTopLevel: null, + currentWorktreePath: null, + worktrees: [], + } satisfies GitWorktreeLayout; + } + + if (topLevelResult.code !== 0) { + return yield* createGitCommandError( + "GitCore.inspectWorktreeLayout", + cwd, + ["rev-parse", "--show-toplevel"], + topLevelResult.stderr.trim() || "git rev-parse --show-toplevel failed", + ); + } + if (commonDirResult.code !== 0) { + return yield* createGitCommandError( + "GitCore.inspectWorktreeLayout", + cwd, + ["rev-parse", "--git-common-dir"], + commonDirResult.stderr.trim() || "git rev-parse --git-common-dir failed", + ); + } + if (worktreeListResult.code !== 0) { + return yield* createGitCommandError( + "GitCore.inspectWorktreeLayout", + cwd, + ["worktree", "list", "--porcelain"], + worktreeListResult.stderr.trim() || "git worktree list --porcelain failed", + ); + } + + const currentTopLevel = topLevelResult.stdout.trim(); + const commonDir = path.resolve(currentTopLevel, commonDirResult.stdout.trim()); + const worktreePaths = parseWorktreePaths(worktreeListResult.stdout); + const existingWorktrees = yield* Effect.forEach( + worktreePaths, + (worktreePath) => + fileSystem + .stat(worktreePath) + .pipe( + Effect.map(() => worktreePath), + Effect.catch(() => Effect.succeed(null)), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((paths) => paths.filter((value): value is string => value !== null))); + + const workspaceRoot = + existingWorktrees.find((candidatePath) => path.resolve(candidatePath, ".git") === commonDir) ?? + (path.basename(commonDir) === ".git" ? path.dirname(commonDir) : currentTopLevel); + + return { + isRepo: true, + workspaceRoot, + currentTopLevel, + currentWorktreePath: currentTopLevel === workspaceRoot ? null : currentTopLevel, + worktrees: existingWorktrees.map((worktreePath) => ({ path: worktreePath })), + } satisfies GitWorktreeLayout; + }); + return { status, statusDetails, @@ -1203,6 +1308,7 @@ const makeGitCore = Effect.gen(function* () { checkoutBranch, initRepo, listLocalBranchNames, + inspectWorktreeLayout, } satisfies GitCoreShape; }); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index c60b9bd3e903..dccdde2e8984 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -46,6 +46,16 @@ export interface GitRangeContext { diffPatch: string; } +export interface GitWorktreeLayout { + readonly isRepo: boolean; + readonly workspaceRoot: string; + readonly currentTopLevel: string | null; + readonly currentWorktreePath: string | null; + readonly worktrees: ReadonlyArray<{ + readonly path: string; + }>; +} + export interface GitRenameBranchInput { cwd: string; oldBranch: string; @@ -162,6 +172,13 @@ export interface GitCoreShape { * List local branch names (short format). */ readonly listLocalBranchNames: (cwd: string) => Effect.Effect; + + /** + * Inspect the canonical main workspace root and linked worktrees for a cwd. + */ + readonly inspectWorktreeLayout: ( + cwd: string, + ) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 6e4e824f3968..2b4336bdb934 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1117,6 +1117,208 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { ), ); + it.effect("removes imported Codex projection rows when a thread is deleted", () => + Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-imported-delete-"))).pipe( + Effect.flatMap((stateDir) => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-imported-delete-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-imported-delete"), + occurredAt: "2026-03-02T00:00:00.000Z", + commandId: CommandId.makeUnsafe("cmd-imported-delete-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-imported-delete-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-imported-delete"), + title: "Imported Delete Project", + workspaceRoot: "/tmp/project-imported-delete", + defaultModel: null, + scripts: [], + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-02T00:00:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-imported-delete-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-imported-delete"), + occurredAt: "2026-03-02T00:00:01.000Z", + commandId: CommandId.makeUnsafe("cmd-imported-delete-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-imported-delete-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-imported-delete"), + projectId: ProjectId.makeUnsafe("project-imported-delete"), + title: "Imported Delete Thread", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: "2026-03-02T00:00:01.000Z", + updatedAt: "2026-03-02T00:00:01.000Z", + }, + }); + + yield* sql` + INSERT INTO projection_external_threads ( + thread_id, + project_id, + provider, + provider_thread_id, + source_kind, + cwd, + model_provider, + remote_updated_at, + imported_at, + adopted_at + ) + VALUES ( + 'thread-imported-delete', + 'project-imported-delete', + 'codex', + 'codex-thread-delete', + 'cli', + '/tmp/project-imported-delete', + 'openai', + '2026-03-02T00:00:02.000Z', + '2026-03-02T00:00:02.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_imported_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'imported-delete-message-1', + 'thread-imported-delete', + NULL, + 'user', + 'Imported delete message', + NULL, + 0, + '2026-03-02T00:00:02.000Z', + '2026-03-02T00:00:02.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_imported_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + created_at, + updated_at + ) + VALUES ( + 'imported-delete-plan-1', + 'thread-imported-delete', + NULL, + '# Delete plan', + '2026-03-02T00:00:02.000Z', + '2026-03-02T00:00:02.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_imported_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES ( + 'imported-delete-activity-1', + 'thread-imported-delete', + NULL, + 'info', + 'codex.reasoning', + 'Imported delete activity', + '{}', + '2026-03-02T00:00:02.000Z' + ) + `; + + yield* appendAndProject({ + type: "thread.deleted", + eventId: EventId.makeUnsafe("evt-imported-delete-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-imported-delete"), + occurredAt: "2026-03-02T00:00:03.000Z", + commandId: CommandId.makeUnsafe("cmd-imported-delete-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-imported-delete-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-imported-delete"), + deletedAt: "2026-03-02T00:00:03.000Z", + }, + }); + + const [externalRows, importedMessageRows, importedPlanRows, importedActivityRows] = + yield* Effect.all([ + sql<{ readonly count: number }>` + SELECT COUNT(*) AS "count" + FROM projection_external_threads + WHERE thread_id = 'thread-imported-delete' + `, + sql<{ readonly count: number }>` + SELECT COUNT(*) AS "count" + FROM projection_imported_thread_messages + WHERE thread_id = 'thread-imported-delete' + `, + sql<{ readonly count: number }>` + SELECT COUNT(*) AS "count" + FROM projection_imported_thread_proposed_plans + WHERE thread_id = 'thread-imported-delete' + `, + sql<{ readonly count: number }>` + SELECT COUNT(*) AS "count" + FROM projection_imported_thread_activities + WHERE thread_id = 'thread-imported-delete' + `, + ]); + + assert.equal(externalRows[0]?.count, 0); + assert.equal(importedMessageRows[0]?.count, 0); + assert.equal(importedPlanRows[0]?.count, 0); + assert.equal(importedActivityRows[0]?.count, 0); + }).pipe( + (effect) => runWithProjectionPipelineLayer(stateDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + ), + ), + ), + ); + it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 24b81d514ab1..eec28e2090a5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -9,6 +9,10 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { ProjectionExternalThreadRepository } from "../../persistence/Services/ProjectionExternalThreads.ts"; +import { ProjectionImportedThreadActivityRepository } from "../../persistence/Services/ProjectionImportedThreadActivities.ts"; +import { ProjectionImportedThreadMessageRepository } from "../../persistence/Services/ProjectionImportedThreadMessages.ts"; +import { ProjectionImportedThreadProposedPlanRepository } from "../../persistence/Services/ProjectionImportedThreadProposedPlans.ts"; import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; @@ -28,6 +32,10 @@ import { ProjectionTurnRepository, } from "../../persistence/Services/ProjectionTurns.ts"; import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; +import { ProjectionExternalThreadRepositoryLive } from "../../persistence/Layers/ProjectionExternalThreads.ts"; +import { ProjectionImportedThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionImportedThreadActivities.ts"; +import { ProjectionImportedThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionImportedThreadMessages.ts"; +import { ProjectionImportedThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionImportedThreadProposedPlans.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; @@ -345,6 +353,12 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const projectionStateRepository = yield* ProjectionStateRepository; const projectionProjectRepository = yield* ProjectionProjectRepository; const projectionThreadRepository = yield* ProjectionThreadRepository; + const projectionExternalThreadRepository = yield* ProjectionExternalThreadRepository; + const projectionImportedThreadMessageRepository = yield* ProjectionImportedThreadMessageRepository; + const projectionImportedThreadProposedPlanRepository = + yield* ProjectionImportedThreadProposedPlanRepository; + const projectionImportedThreadActivityRepository = + yield* ProjectionImportedThreadActivityRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; @@ -497,6 +511,18 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { deletedAt: event.payload.deletedAt, updatedAt: event.payload.deletedAt, }); + yield* projectionExternalThreadRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + yield* projectionImportedThreadMessageRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + yield* projectionImportedThreadProposedPlanRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + yield* projectionImportedThreadActivityRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); return; } @@ -1224,6 +1250,10 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ProjectionProjectRepositoryLive), Layer.provideMerge(ProjectionThreadRepositoryLive), + Layer.provideMerge(ProjectionExternalThreadRepositoryLive), + Layer.provideMerge(ProjectionImportedThreadMessageRepositoryLive), + Layer.provideMerge(ProjectionImportedThreadProposedPlanRepositoryLive), + Layer.provideMerge(ProjectionImportedThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index e7e9cd4e1271..822dfd683bbb 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -296,4 +296,284 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ]); }), ); + + it.effect("merges imported Codex history and external metadata into the snapshot", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_thread_proposed_plans`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_thread_sessions`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_external_threads`; + yield* sql`DELETE FROM projection_imported_thread_messages`; + yield* sql`DELETE FROM projection_imported_thread_proposed_plans`; + yield* sql`DELETE FROM projection_imported_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-imported', + 'Imported Project', + '/tmp/imported-project', + 'gpt-5-codex', + '[]', + '2026-03-01T00:00:00.000Z', + '2026-03-01T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'thread-imported', + 'project-imported', + 'Imported Thread', + 'gpt-5-codex', + 'full-access', + 'default', + 'feature/imported', + NULL, + NULL, + '2026-03-01T00:00:02.000Z', + '2026-03-01T00:00:03.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'native-message-1', + 'thread-imported', + NULL, + 'assistant', + 'Native follow-up', + 0, + '2026-03-01T00:00:06.000Z', + '2026-03-01T00:00:07.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + created_at + ) + VALUES ( + 'native-activity-1', + 'thread-imported', + NULL, + 'info', + 'runtime.note', + 'Native activity', + '{"source":"native"}', + '2026-03-01T00:00:08.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_external_threads ( + thread_id, + project_id, + provider, + provider_thread_id, + source_kind, + cwd, + model_provider, + remote_updated_at, + imported_at, + adopted_at + ) + VALUES ( + 'thread-imported', + 'project-imported', + 'codex', + 'codex-thread-1', + 'cli', + '/tmp/imported-project', + 'openai', + '2026-03-01T00:00:10.000Z', + '2026-03-01T00:00:05.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_imported_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'imported-message-1', + 'thread-imported', + NULL, + 'user', + 'Imported prompt', + NULL, + 0, + '2026-03-01T00:00:04.000Z', + '2026-03-01T00:00:04.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_imported_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + created_at, + updated_at + ) + VALUES ( + 'imported-plan-1', + 'thread-imported', + NULL, + '# Imported plan', + '2026-03-01T00:00:04.500Z', + '2026-03-01T00:00:04.500Z' + ) + `; + + yield* sql` + INSERT INTO projection_imported_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + 'imported-activity-1', + 'thread-imported', + NULL, + 'tool', + 'codex.commandExecution', + 'Imported activity', + '{"source":"imported"}', + NULL, + '2026-03-01T00:00:05.500Z' + ) + `; + + let sequence = 10; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state ( + projector, + last_applied_sequence, + updated_at + ) + VALUES ( + ${projector}, + ${sequence}, + '2026-03-01T00:00:09.000Z' + ) + `; + sequence += 1; + } + + const snapshot = yield* snapshotQuery.getSnapshot(); + const thread = snapshot.threads[0]; + + assert.equal(thread !== undefined, true); + assert.equal(thread?.external?.providerThreadId, "codex-thread-1"); + assert.equal(thread?.external?.sourceKind, "cli"); + assert.equal(thread?.external?.cwd, "/tmp/imported-project"); + assert.equal(thread?.external?.modelProvider, "openai"); + assert.deepEqual( + thread?.messages.map((message) => ({ + id: message.id, + text: message.text, + })), + [ + { id: asMessageId("imported-message-1"), text: "Imported prompt" }, + { id: asMessageId("native-message-1"), text: "Native follow-up" }, + ], + ); + assert.deepEqual(thread?.proposedPlans, [ + { + id: "imported-plan-1", + turnId: null, + planMarkdown: "# Imported plan", + createdAt: "2026-03-01T00:00:04.500Z", + updatedAt: "2026-03-01T00:00:04.500Z", + }, + ]); + assert.deepEqual( + thread?.activities.map((activity) => ({ + id: activity.id, + kind: activity.kind, + summary: activity.summary, + })), + [ + { + id: asEventId("imported-activity-1"), + kind: "codex.commandExecution", + summary: "Imported activity", + }, + { + id: asEventId("native-activity-1"), + kind: "runtime.note", + summary: "Native activity", + }, + ], + ); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5fd38a540175..dbccdd6748a8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -3,6 +3,7 @@ import { IsoDateTime, MessageId, NonNegativeInt, + OrchestrationExternalThread, OrchestrationCheckpointFile, OrchestrationReadModel, ProjectScript, @@ -29,6 +30,7 @@ import { import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; +import { ProjectionExternalThread } from "../../persistence/Services/ProjectionExternalThreads.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; @@ -54,6 +56,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread; +const ProjectionExternalThreadDbRowSchema = ProjectionExternalThread; const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -94,6 +97,33 @@ function maxIso(left: string | null, right: string): string { return left > right ? left : right; } +function compareMessages(left: OrchestrationMessage, right: OrchestrationMessage): number { + return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); +} + +function compareProposedPlans( + left: OrchestrationProposedPlan, + right: OrchestrationProposedPlan, +): number { + return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); +} + +function compareActivities( + left: OrchestrationThreadActivity, + right: OrchestrationThreadActivity, +): number { + if (left.sequence !== undefined && right.sequence !== undefined && left.sequence !== right.sequence) { + return left.sequence - right.sequence; + } + if (left.sequence !== undefined) { + return 1; + } + if (right.sequence !== undefined) { + return -1; + } + return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); +} + function computeSnapshotSequence( stateRows: ReadonlyArray>, ): number { @@ -232,6 +262,89 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listExternalThreadRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionExternalThreadDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + provider, + provider_thread_id AS "providerThreadId", + source_kind AS "sourceKind", + cwd, + model_provider AS "modelProvider", + remote_updated_at AS "remoteUpdatedAt", + imported_at AS "importedAt", + adopted_at AS "adoptedAt" + FROM projection_external_threads + ORDER BY imported_at ASC, thread_id ASC + `, + }); + + const listImportedThreadMessageRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadMessageDbRowSchema, + execute: () => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_imported_thread_messages + ORDER BY thread_id ASC, created_at ASC, message_id ASC + `, + }); + + const listImportedThreadProposedPlanRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadProposedPlanDbRowSchema, + execute: () => + sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_imported_thread_proposed_plans + ORDER BY thread_id ASC, created_at ASC, plan_id ASC + `, + }); + + const listImportedThreadActivityRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadActivityDbRowSchema, + execute: () => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_imported_thread_activities + ORDER BY + thread_id ASC, + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const listThreadSessionRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadSessionDbRowSchema, @@ -312,8 +425,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { projectRows, threadRows, messageRows, + importedMessageRows, proposedPlanRows, + importedProposedPlanRows, activityRows, + importedActivityRows, + externalThreadRows, sessionRows, checkpointRows, latestTurnRows, @@ -343,6 +460,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listImportedThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listImportedThreadMessages:query", + "ProjectionSnapshotQuery.getSnapshot:listImportedThreadMessages:decodeRows", + ), + ), + ), listThreadProposedPlanRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -351,6 +476,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listImportedThreadProposedPlanRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listImportedThreadProposedPlans:query", + "ProjectionSnapshotQuery.getSnapshot:listImportedThreadProposedPlans:decodeRows", + ), + ), + ), listThreadActivityRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -359,6 +492,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listImportedThreadActivityRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listImportedThreadActivities:query", + "ProjectionSnapshotQuery.getSnapshot:listImportedThreadActivities:decodeRows", + ), + ), + ), + listExternalThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listExternalThreads:query", + "ProjectionSnapshotQuery.getSnapshot:listExternalThreads:decodeRows", + ), + ), + ), listThreadSessionRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -396,6 +545,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); const activitiesByThread = new Map>(); + const externalByThread = new Map(); const checkpointsByThread = new Map>(); const sessionsByThread = new Map(); const latestTurnByThread = new Map(); @@ -408,6 +558,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { for (const row of threadRows) { updatedAt = maxIso(updatedAt, row.updatedAt); } + for (const row of externalThreadRows) { + updatedAt = maxIso(updatedAt, row.remoteUpdatedAt); + } for (const row of stateRows) { updatedAt = maxIso(updatedAt, row.updatedAt); } @@ -427,6 +580,21 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); messagesByThread.set(row.threadId, threadMessages); } + for (const row of importedMessageRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadMessages = messagesByThread.get(row.threadId) ?? []; + threadMessages.push({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, threadMessages); + } for (const row of proposedPlanRows) { updatedAt = maxIso(updatedAt, row.updatedAt); @@ -440,6 +608,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); proposedPlansByThread.set(row.threadId, threadProposedPlans); } + for (const row of importedProposedPlanRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; + threadProposedPlans.push({ + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + proposedPlansByThread.set(row.threadId, threadProposedPlans); + } for (const row of activityRows) { updatedAt = maxIso(updatedAt, row.createdAt); @@ -456,6 +636,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); activitiesByThread.set(row.threadId, threadActivities); } + for (const row of importedActivityRows) { + 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, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + }); + activitiesByThread.set(row.threadId, threadActivities); + } + for (const row of externalThreadRows) { + externalByThread.set(row.threadId, { + provider: row.provider, + providerThreadId: row.providerThreadId, + sourceKind: row.sourceKind, + cwd: row.cwd, + modelProvider: row.modelProvider, + remoteUpdatedAt: row.remoteUpdatedAt, + importedAt: row.importedAt, + adoptedAt: row.adoptedAt, + }); + } for (const row of checkpointRows) { updatedAt = maxIso(updatedAt, row.completedAt); @@ -537,11 +744,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], + messages: (messagesByThread.get(row.threadId) ?? []).toSorted(compareMessages), + proposedPlans: (proposedPlansByThread.get(row.threadId) ?? []).toSorted( + compareProposedPlans, + ), + activities: (activitiesByThread.get(row.threadId) ?? []).toSorted(compareActivities), checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, + ...(externalByThread.has(row.threadId) + ? { external: externalByThread.get(row.threadId) } + : {}), })); const snapshot = { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4f352435fe50..fb43ac73cb27 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -204,6 +204,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), ), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 2a72d5902750..ec1c6ff50338 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -17,6 +17,8 @@ import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Schema, Stream } import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; +import { ProjectionExternalThreadRepositoryLive } from "../../persistence/Layers/ProjectionExternalThreads.ts"; +import { ProjectionExternalThreadRepository } from "../../persistence/Services/ProjectionExternalThreads.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; @@ -132,6 +134,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const git = yield* GitCore; const textGeneration = yield* TextGeneration; + const projectionExternalThreadRepository = yield* ProjectionExternalThreadRepository; const handledTurnStartKeys = yield* Cache.make({ capacity: HANDLED_TURN_START_KEY_MAX, timeToLive: HANDLED_TURN_START_KEY_TTL, @@ -264,6 +267,16 @@ const make = Effect.gen(function* () { createdAt, }); + const syncThreadModelFromSession = (session: ProviderSession) => + session.model && session.model !== thread.model + ? orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("provider-session-model-sync"), + threadId, + model: session.model, + }) + : Effect.void; + const existingSessionThreadId = thread.session && thread.session.status !== "stopped" ? thread.id : null; if (existingSessionThreadId) { @@ -312,6 +325,7 @@ const make = Effect.gen(function* () { runtimeMode: restartedSession.runtimeMode, }); yield* bindSessionToThread(restartedSession); + yield* syncThreadModelFromSession(restartedSession); return restartedSession.threadId; } @@ -319,6 +333,7 @@ const make = Effect.gen(function* () { options?.provider !== undefined ? { provider: options.provider } : undefined, ); yield* bindSessionToThread(startedSession); + yield* syncThreadModelFromSession(startedSession); return startedSession.threadId; }); @@ -348,6 +363,12 @@ const make = Effect.gen(function* () { ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); + yield* projectionExternalThreadRepository + .markAdopted({ + threadId: input.threadId, + adoptedAt: input.createdAt, + }) + .pipe(Effect.catch(() => Effect.void)); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService.listSessions().pipe( @@ -706,4 +727,6 @@ const make = Effect.gen(function* () { } satisfies ProviderCommandReactorShape; }); -export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); +export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make).pipe( + Layer.provideMerge(ProjectionExternalThreadRepositoryLive), +); diff --git a/apps/server/src/persistence/Layers/ProjectionExternalThreads.ts b/apps/server/src/persistence/Layers/ProjectionExternalThreads.ts new file mode 100644 index 000000000000..e05829c7e45e --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionExternalThreads.ts @@ -0,0 +1,180 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionExternalThreadInput, + GetProjectionExternalThreadByProviderThreadIdInput, + GetProjectionExternalThreadByThreadIdInput, + ProjectionExternalThread, + ProjectionExternalThreadRepository, + type ProjectionExternalThreadRepositoryShape, +} from "../Services/ProjectionExternalThreads.ts"; + +const makeProjectionExternalThreadRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: ProjectionExternalThread, + execute: (row) => sql` + INSERT INTO projection_external_threads ( + thread_id, + project_id, + provider, + provider_thread_id, + source_kind, + cwd, + model_provider, + remote_updated_at, + imported_at, + adopted_at + ) + VALUES ( + ${row.threadId}, + ${row.projectId}, + ${row.provider}, + ${row.providerThreadId}, + ${row.sourceKind}, + ${row.cwd}, + ${row.modelProvider}, + ${row.remoteUpdatedAt}, + ${row.importedAt}, + ${row.adoptedAt} + ) + ON CONFLICT (thread_id) + DO UPDATE SET + project_id = excluded.project_id, + provider = excluded.provider, + provider_thread_id = excluded.provider_thread_id, + source_kind = excluded.source_kind, + cwd = excluded.cwd, + model_provider = excluded.model_provider, + remote_updated_at = excluded.remote_updated_at, + imported_at = excluded.imported_at, + adopted_at = excluded.adopted_at + `, + }); + + const getByThreadId = SqlSchema.findOneOption({ + Request: GetProjectionExternalThreadByThreadIdInput, + Result: ProjectionExternalThread, + execute: ({ threadId }) => sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + provider, + provider_thread_id AS "providerThreadId", + source_kind AS "sourceKind", + cwd, + model_provider AS "modelProvider", + remote_updated_at AS "remoteUpdatedAt", + imported_at AS "importedAt", + adopted_at AS "adoptedAt" + FROM projection_external_threads + WHERE thread_id = ${threadId} + `, + }); + + const getByProviderThreadId = SqlSchema.findOneOption({ + Request: GetProjectionExternalThreadByProviderThreadIdInput, + Result: ProjectionExternalThread, + execute: ({ providerThreadId }) => sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + provider, + provider_thread_id AS "providerThreadId", + source_kind AS "sourceKind", + cwd, + model_provider AS "modelProvider", + remote_updated_at AS "remoteUpdatedAt", + imported_at AS "importedAt", + adopted_at AS "adoptedAt" + FROM projection_external_threads + WHERE provider_thread_id = ${providerThreadId} + `, + }); + + const listRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionExternalThread, + execute: () => sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + provider, + provider_thread_id AS "providerThreadId", + source_kind AS "sourceKind", + cwd, + model_provider AS "modelProvider", + remote_updated_at AS "remoteUpdatedAt", + imported_at AS "importedAt", + adopted_at AS "adoptedAt" + FROM projection_external_threads + ORDER BY imported_at ASC, thread_id ASC + `, + }); + + const MarkProjectionExternalThreadAdoptedInput = Schema.Struct({ + threadId: GetProjectionExternalThreadByThreadIdInput.fields.threadId, + adoptedAt: ProjectionExternalThread.fields.importedAt, + }); + + const markAdoptedRow = SqlSchema.void({ + Request: MarkProjectionExternalThreadAdoptedInput, + execute: ({ threadId, adoptedAt }) => sql` + UPDATE projection_external_threads + SET adopted_at = COALESCE(adopted_at, ${adoptedAt}) + WHERE thread_id = ${threadId} + `, + }); + + const deleteRow = SqlSchema.void({ + Request: DeleteProjectionExternalThreadInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_external_threads + WHERE thread_id = ${threadId} + `, + }); + + return { + upsert: (row) => + upsertRow(row).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionExternalThreadRepository.upsert:query")), + ), + getByThreadId: (input) => + getByThreadId(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionExternalThreadRepository.getByThreadId:query"), + ), + ), + getByProviderThreadId: (input) => + getByProviderThreadId(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionExternalThreadRepository.getByProviderThreadId:query"), + ), + ), + list: () => + listRows(undefined).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionExternalThreadRepository.list:query")), + ), + markAdopted: (input) => + markAdoptedRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionExternalThreadRepository.markAdopted:query"), + ), + ), + deleteByThreadId: (input) => + deleteRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionExternalThreadRepository.deleteByThreadId:query"), + ), + ), + } satisfies ProjectionExternalThreadRepositoryShape; +}); + +export const ProjectionExternalThreadRepositoryLive = Layer.effect( + ProjectionExternalThreadRepository, + makeProjectionExternalThreadRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionImportedThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionImportedThreadActivities.ts new file mode 100644 index 000000000000..0b328dfb5989 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionImportedThreadActivities.ts @@ -0,0 +1,137 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema, Struct } from "effect"; +import { NonNegativeInt } from "@t3tools/contracts"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + ProjectionImportedThreadActivityRepository, + type ProjectionImportedThreadActivityRepositoryShape, +} from "../Services/ProjectionImportedThreadActivities.ts"; +import { + DeleteProjectionThreadActivitiesInput, + ListProjectionThreadActivitiesInput, + ProjectionThreadActivity, +} from "../Services/ProjectionThreadActivities.ts"; + +const ImportedThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( + Struct.assign({ + payload: Schema.fromJsonString(Schema.Unknown), + sequence: Schema.NullOr(NonNegativeInt), + }), +); + +const makeProjectionImportedThreadActivityRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: ProjectionThreadActivity, + execute: (row) => sql` + INSERT INTO projection_imported_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + ${row.activityId}, + ${row.threadId}, + ${row.turnId}, + ${row.tone}, + ${row.kind}, + ${row.summary}, + ${JSON.stringify(row.payload)}, + ${row.sequence ?? null}, + ${row.createdAt} + ) + ON CONFLICT (activity_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + tone = excluded.tone, + kind = excluded.kind, + summary = excluded.summary, + payload_json = excluded.payload_json, + sequence = excluded.sequence, + created_at = excluded.created_at + `, + }); + + const listRows = SqlSchema.findAll({ + Request: ListProjectionThreadActivitiesInput, + Result: ImportedThreadActivityDbRowSchema, + execute: ({ threadId }) => sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_imported_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + + const deleteRows = SqlSchema.void({ + Request: DeleteProjectionThreadActivitiesInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_imported_thread_activities + WHERE thread_id = ${threadId} + `, + }); + + return { + upsert: (row) => + upsertRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionImportedThreadActivityRepository.upsert:query"), + ), + ), + listByThreadId: (input) => + listRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionImportedThreadActivityRepository.listByThreadId:query"), + ), + Effect.map((rows) => + rows.map((row) => ({ + activityId: row.activityId, + threadId: row.threadId, + turnId: row.turnId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + createdAt: row.createdAt, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + })), + ), + ), + deleteByThreadId: (input) => + deleteRows(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionImportedThreadActivityRepository.deleteByThreadId:query", + ), + ), + ), + } satisfies ProjectionImportedThreadActivityRepositoryShape; +}); + +export const ProjectionImportedThreadActivityRepositoryLive = Layer.effect( + ProjectionImportedThreadActivityRepository, + makeProjectionImportedThreadActivityRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionImportedThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionImportedThreadMessages.ts new file mode 100644 index 000000000000..2b09b492d580 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionImportedThreadMessages.ts @@ -0,0 +1,135 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema, Struct } from "effect"; +import { ChatAttachment } from "@t3tools/contracts"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + ProjectionImportedThreadMessageRepository, + type ProjectionImportedThreadMessageRepositoryShape, +} from "../Services/ProjectionImportedThreadMessages.ts"; +import { + DeleteProjectionThreadMessagesInput, + ListProjectionThreadMessagesInput, + ProjectionThreadMessage, +} from "../Services/ProjectionThreadMessages.ts"; + +const ImportedThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( + Struct.assign({ + isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + }), +); + +const makeProjectionImportedThreadMessageRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: ProjectionThreadMessage, + execute: (row) => { + const nextAttachmentsJson = + row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` + INSERT INTO projection_imported_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.turnId}, + ${row.role}, + ${row.text}, + ${nextAttachmentsJson}, + ${row.isStreaming ? 1 : 0}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + role = excluded.role, + text = excluded.text, + attachments_json = excluded.attachments_json, + is_streaming = excluded.is_streaming, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `; + }, + }); + + const listRows = SqlSchema.findAll({ + Request: ListProjectionThreadMessagesInput, + Result: ImportedThreadMessageDbRowSchema, + execute: ({ threadId }) => sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_imported_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, message_id ASC + `, + }); + + const deleteRows = SqlSchema.void({ + Request: DeleteProjectionThreadMessagesInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_imported_thread_messages + WHERE thread_id = ${threadId} + `, + }); + + return { + upsert: (row) => + upsertRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionImportedThreadMessageRepository.upsert:query"), + ), + ), + listByThreadId: (input) => + listRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionImportedThreadMessageRepository.listByThreadId:query"), + ), + Effect.map((rows) => + rows.map((row) => ({ + messageId: row.messageId, + threadId: row.threadId, + turnId: row.turnId, + role: row.role, + text: row.text, + isStreaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + })), + ), + ), + deleteByThreadId: (input) => + deleteRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionImportedThreadMessageRepository.deleteByThreadId:query"), + ), + ), + } satisfies ProjectionImportedThreadMessageRepositoryShape; +}); + +export const ProjectionImportedThreadMessageRepositoryLive = Layer.effect( + ProjectionImportedThreadMessageRepository, + makeProjectionImportedThreadMessageRepository, +); diff --git a/apps/server/src/persistence/Layers/ProjectionImportedThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionImportedThreadProposedPlans.ts new file mode 100644 index 000000000000..ec57db9eb344 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionImportedThreadProposedPlans.ts @@ -0,0 +1,102 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer } from "effect"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + ProjectionImportedThreadProposedPlanRepository, + type ProjectionImportedThreadProposedPlanRepositoryShape, +} from "../Services/ProjectionImportedThreadProposedPlans.ts"; +import { + DeleteProjectionThreadProposedPlansInput, + ListProjectionThreadProposedPlansInput, + ProjectionThreadProposedPlan, +} from "../Services/ProjectionThreadProposedPlans.ts"; + +const makeProjectionImportedThreadProposedPlanRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: ProjectionThreadProposedPlan, + execute: (row) => sql` + INSERT INTO projection_imported_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + created_at, + updated_at + ) + VALUES ( + ${row.planId}, + ${row.threadId}, + ${row.turnId}, + ${row.planMarkdown}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (plan_id) + DO UPDATE SET + thread_id = excluded.thread_id, + turn_id = excluded.turn_id, + plan_markdown = excluded.plan_markdown, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `, + }); + + const listRows = SqlSchema.findAll({ + Request: ListProjectionThreadProposedPlansInput, + Result: ProjectionThreadProposedPlan, + execute: ({ threadId }) => sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_imported_thread_proposed_plans + WHERE thread_id = ${threadId} + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const deleteRows = SqlSchema.void({ + Request: DeleteProjectionThreadProposedPlansInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_imported_thread_proposed_plans + WHERE thread_id = ${threadId} + `, + }); + + return { + upsert: (row) => + upsertRow(row).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionImportedThreadProposedPlanRepository.upsert:query"), + ), + ), + listByThreadId: (input) => + listRows(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionImportedThreadProposedPlanRepository.listByThreadId:query", + ), + ), + ), + deleteByThreadId: (input) => + deleteRows(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionImportedThreadProposedPlanRepository.deleteByThreadId:query", + ), + ), + ), + } satisfies ProjectionImportedThreadProposedPlanRepositoryShape; +}); + +export const ProjectionImportedThreadProposedPlanRepositoryLive = Layer.effect( + ProjectionImportedThreadProposedPlanRepository, + makeProjectionImportedThreadProposedPlanRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7deb890dd854..7b7c6791c62b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -25,6 +25,7 @@ import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"; import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; +import Migration0014 from "./Migrations/014_ImportedCodexThreads.ts"; import { Effect } from "effect"; /** @@ -51,6 +52,7 @@ const loader = Migrator.fromRecord({ "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, "12_ProjectionThreadsInteractionMode": Migration0012, "13_ProjectionThreadProposedPlans": Migration0013, + "14_ImportedCodexThreads": Migration0014, }); /** diff --git a/apps/server/src/persistence/Migrations/014_ImportedCodexThreads.ts b/apps/server/src/persistence/Migrations/014_ImportedCodexThreads.ts new file mode 100644 index 000000000000..63d9f84011e1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/014_ImportedCodexThreads.ts @@ -0,0 +1,80 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_external_threads ( + thread_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + cwd TEXT NOT NULL, + model_provider TEXT, + remote_updated_at TEXT NOT NULL, + imported_at TEXT NOT NULL, + adopted_at TEXT + ) + `; + + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_projection_external_threads_provider_thread + ON projection_external_threads(provider_thread_id) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_imported_thread_messages ( + message_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_id TEXT, + role TEXT NOT NULL, + text TEXT NOT NULL, + attachments_json TEXT, + is_streaming INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_imported_thread_messages_thread_created + ON projection_imported_thread_messages(thread_id, created_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_imported_thread_proposed_plans ( + plan_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_id TEXT, + plan_markdown TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_imported_thread_plans_thread_created + ON projection_imported_thread_proposed_plans(thread_id, created_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_imported_thread_activities ( + activity_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + turn_id TEXT, + tone TEXT NOT NULL, + kind TEXT NOT NULL, + summary TEXT NOT NULL, + payload_json TEXT NOT NULL, + sequence INTEGER, + created_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_imported_thread_activities_thread_created + ON projection_imported_thread_activities(thread_id, created_at) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionExternalThreads.ts b/apps/server/src/persistence/Services/ProjectionExternalThreads.ts new file mode 100644 index 000000000000..c0beb5dd8126 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionExternalThreads.ts @@ -0,0 +1,65 @@ +import { + IsoDateTime, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionExternalThread = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + provider: Schema.Literal("codex"), + providerThreadId: TrimmedNonEmptyString, + sourceKind: Schema.Literals(["cli", "vscode"]), + cwd: TrimmedNonEmptyString, + modelProvider: Schema.NullOr(TrimmedNonEmptyString), + remoteUpdatedAt: IsoDateTime, + importedAt: IsoDateTime, + adoptedAt: Schema.NullOr(IsoDateTime), +}); +export type ProjectionExternalThread = typeof ProjectionExternalThread.Type; + +export const GetProjectionExternalThreadByThreadIdInput = Schema.Struct({ + threadId: ThreadId, +}); +export type GetProjectionExternalThreadByThreadIdInput = + typeof GetProjectionExternalThreadByThreadIdInput.Type; + +export const GetProjectionExternalThreadByProviderThreadIdInput = Schema.Struct({ + providerThreadId: TrimmedNonEmptyString, +}); +export type GetProjectionExternalThreadByProviderThreadIdInput = + typeof GetProjectionExternalThreadByProviderThreadIdInput.Type; + +export const DeleteProjectionExternalThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionExternalThreadInput = typeof DeleteProjectionExternalThreadInput.Type; + +export interface ProjectionExternalThreadRepositoryShape { + readonly upsert: ( + row: ProjectionExternalThread, + ) => Effect.Effect; + readonly getByThreadId: ( + input: GetProjectionExternalThreadByThreadIdInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly getByProviderThreadId: ( + input: GetProjectionExternalThreadByProviderThreadIdInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly list: () => Effect.Effect, ProjectionRepositoryError>; + readonly markAdopted: ( + input: GetProjectionExternalThreadByThreadIdInput & { adoptedAt: IsoDateTime }, + ) => Effect.Effect; + readonly deleteByThreadId: ( + input: DeleteProjectionExternalThreadInput, + ) => Effect.Effect; +} + +export class ProjectionExternalThreadRepository extends ServiceMap.Service< + ProjectionExternalThreadRepository, + ProjectionExternalThreadRepositoryShape +>()("t3/persistence/Services/ProjectionExternalThreads/ProjectionExternalThreadRepository") {} diff --git a/apps/server/src/persistence/Services/ProjectionImportedThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionImportedThreadActivities.ts new file mode 100644 index 000000000000..cee3f5ce3577 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionImportedThreadActivities.ts @@ -0,0 +1,28 @@ +import { type ProjectionThreadActivity } from "./ProjectionThreadActivities.ts"; +import { + DeleteProjectionThreadActivitiesInput, + ListProjectionThreadActivitiesInput, +} from "./ProjectionThreadActivities.ts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export interface ProjectionImportedThreadActivityRepositoryShape { + readonly upsert: ( + row: ProjectionThreadActivity, + ) => Effect.Effect; + readonly listByThreadId: ( + input: typeof ListProjectionThreadActivitiesInput.Type, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByThreadId: ( + input: typeof DeleteProjectionThreadActivitiesInput.Type, + ) => Effect.Effect; +} + +export class ProjectionImportedThreadActivityRepository extends ServiceMap.Service< + ProjectionImportedThreadActivityRepository, + ProjectionImportedThreadActivityRepositoryShape +>()( + "t3/persistence/Services/ProjectionImportedThreadActivities/ProjectionImportedThreadActivityRepository", +) {} diff --git a/apps/server/src/persistence/Services/ProjectionImportedThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionImportedThreadMessages.ts new file mode 100644 index 000000000000..d10381b1b9a1 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionImportedThreadMessages.ts @@ -0,0 +1,28 @@ +import { + type ProjectionThreadMessage, + DeleteProjectionThreadMessagesInput, + ListProjectionThreadMessagesInput, +} from "./ProjectionThreadMessages.ts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export interface ProjectionImportedThreadMessageRepositoryShape { + readonly upsert: ( + message: ProjectionThreadMessage, + ) => Effect.Effect; + readonly listByThreadId: ( + input: typeof ListProjectionThreadMessagesInput.Type, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByThreadId: ( + input: typeof DeleteProjectionThreadMessagesInput.Type, + ) => Effect.Effect; +} + +export class ProjectionImportedThreadMessageRepository extends ServiceMap.Service< + ProjectionImportedThreadMessageRepository, + ProjectionImportedThreadMessageRepositoryShape +>()( + "t3/persistence/Services/ProjectionImportedThreadMessages/ProjectionImportedThreadMessageRepository", +) {} diff --git a/apps/server/src/persistence/Services/ProjectionImportedThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionImportedThreadProposedPlans.ts new file mode 100644 index 000000000000..087140c5dc04 --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionImportedThreadProposedPlans.ts @@ -0,0 +1,28 @@ +import { type ProjectionThreadProposedPlan } from "./ProjectionThreadProposedPlans.ts"; +import { + DeleteProjectionThreadProposedPlansInput, + ListProjectionThreadProposedPlansInput, +} from "./ProjectionThreadProposedPlans.ts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export interface ProjectionImportedThreadProposedPlanRepositoryShape { + readonly upsert: ( + row: ProjectionThreadProposedPlan, + ) => Effect.Effect; + readonly listByThreadId: ( + input: typeof ListProjectionThreadProposedPlansInput.Type, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByThreadId: ( + input: typeof DeleteProjectionThreadProposedPlansInput.Type, + ) => Effect.Effect; +} + +export class ProjectionImportedThreadProposedPlanRepository extends ServiceMap.Service< + ProjectionImportedThreadProposedPlanRepository, + ProjectionImportedThreadProposedPlanRepositoryShape +>()( + "t3/persistence/Services/ProjectionImportedThreadProposedPlans/ProjectionImportedThreadProposedPlanRepository", +) {} diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 05a1de149522..6420807bf4d0 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -17,6 +17,7 @@ import { ProviderRespondToUserInputInput, ProviderSendTurnInput, ProviderSessionStartInput, + type ProviderSessionStartInput as ProviderSessionStartInputType, ProviderStopSessionInput, type ProviderRuntimeEvent, type ProviderSession, @@ -99,17 +100,6 @@ function toRuntimePayloadFromSession( }; } -function readPersistedProviderOptions( - runtimePayload: ProviderRuntimeBinding["runtimePayload"], -): Record | undefined { - if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { - return undefined; - } - const raw = "providerOptions" in runtimePayload ? runtimePayload.providerOptions : undefined; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; - return raw as Record; -} - function readPersistedCwd( runtimePayload: ProviderRuntimeBinding["runtimePayload"], ): string | undefined { @@ -122,6 +112,34 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +function readPersistedProviderOptions( + runtimePayload: ProviderRuntimeBinding["runtimePayload"], +): ProviderSessionStartInputType["providerOptions"] | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const providerOptionsRaw = (runtimePayload as Record).providerOptions; + if (!providerOptionsRaw || typeof providerOptionsRaw !== "object" || Array.isArray(providerOptionsRaw)) { + return undefined; + } + const codexRaw = (providerOptionsRaw as Record).codex; + if (!codexRaw || typeof codexRaw !== "object" || Array.isArray(codexRaw)) { + return undefined; + } + const binaryPath = (codexRaw as Record).binaryPath; + const homePath = (codexRaw as Record).homePath; + return { + codex: { + ...(typeof binaryPath === "string" && binaryPath.trim().length > 0 + ? { binaryPath: binaryPath.trim() } + : {}), + ...(typeof homePath === "string" && homePath.trim().length > 0 + ? { homePath: homePath.trim() } + : {}), + }, + }; +} + const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { const analytics = yield* Effect.service(AnalyticsService); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b0630a55b954..81f2e2b97943 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -6,6 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; +import { CodexImportServiceLive } from "./codexImport/Layers/CodexImportService"; import { ServerConfig } from "./config"; import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; @@ -88,6 +89,7 @@ export function makeServerRuntimeServicesLayer() { CheckpointStoreLive, checkpointDiffQueryLayer, ); + const codexImportLayer = CodexImportServiceLive.pipe(Layer.provideMerge(runtimeServicesLayer)); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), ); @@ -121,6 +123,7 @@ export function makeServerRuntimeServicesLayer() { return Layer.mergeAll( orchestrationReactorLayer, + codexImportLayer, gitCoreLayer, gitManagerLayer, terminalLayer, diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 80d57d7c6f9c..e55d6970e2e2 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -17,6 +17,7 @@ import { EventId, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, + ProjectId, ProviderItemId, ThreadId, TurnId, @@ -51,6 +52,7 @@ import { GitCore } from "./git/Services/GitCore.ts"; import { GitCommandError, GitManagerError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { CodexImportService, type CodexImportServiceShape } from "./codexImport/Services/CodexImportService.ts"; interface PendingMessages { queue: unknown[]; @@ -391,6 +393,7 @@ describe("WebSocket Server", () => { stateDir?: string; staticDir?: string; providerLayer?: Layer.Layer; + codexImportService?: CodexImportServiceShape; providerHealth?: ProviderHealthShape; open?: OpenShape; gitManager?: GitManagerShape; @@ -434,6 +437,9 @@ describe("WebSocket Server", () => { options.terminalManager ? Layer.succeed(TerminalManager, options.terminalManager) : Layer.empty, + options.codexImportService + ? Layer.succeed(CodexImportService, options.codexImportService) + : Layer.empty, ); const runtimeLayer = Layer.merge( @@ -1028,6 +1034,152 @@ describe("WebSocket Server", () => { ); }); + it("routes Codex import utility RPCs through the injected service", async () => { + const previewCalls: Array = []; + const importCalls: Array = []; + const deleteCalls: Array = []; + const codexImportService: CodexImportServiceShape = { + previewCodexImport: (input) => + Effect.sync(() => { + previewCalls.push(input); + return { + groups: [ + { + cwd: "/tmp/codex-project", + displayCwd: "/tmp/codex-project", + cwdExists: true, + existingProjectId: null, + existingProjectTitle: null, + suggestedProjectTitle: "codex-project", + mainSessions: [ + { + providerThreadId: "codex-thread-1", + linkedThreadId: null, + title: "Imported Thread", + preview: "hello", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:01.000Z", + sourceKind: "cli", + modelProvider: "openai", + importState: "new", + disabledReason: null, + }, + ], + worktrees: [], + }, + ], + }; + }), + importCodexSessions: (input) => + Effect.sync(() => { + importCalls.push(input); + return { + createdProjectIds: [ProjectId.makeUnsafe("project-imported")], + importedThreadIds: [ThreadId.makeUnsafe("thread-imported")], + refreshedThreadIds: [], + skippedProviderThreadIds: [], + failures: [], + }; + }), + deleteThread: (input) => + Effect.sync(() => { + deleteCalls.push(input); + return { sequence: 42, archivedExternal: true }; + }), + }; + + server = await createTestServer({ cwd: "/test", codexImportService }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const previewResponse = await sendRequest(ws, WS_METHODS.serverPreviewCodexImport, { + codexBinaryPath: "/usr/local/bin/codex", + codexHomePath: "/Users/test/.codex", + }); + expect(previewResponse.error).toBeUndefined(); + expect(previewCalls).toEqual([ + { + codexBinaryPath: "/usr/local/bin/codex", + codexHomePath: "/Users/test/.codex", + }, + ]); + expect(previewResponse.result).toEqual({ + groups: [ + { + cwd: "/tmp/codex-project", + displayCwd: "/tmp/codex-project", + cwdExists: true, + existingProjectId: null, + existingProjectTitle: null, + suggestedProjectTitle: "codex-project", + mainSessions: [ + { + providerThreadId: "codex-thread-1", + linkedThreadId: null, + title: "Imported Thread", + preview: "hello", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:01.000Z", + sourceKind: "cli", + modelProvider: "openai", + importState: "new", + disabledReason: null, + }, + ], + worktrees: [], + }, + ], + }); + + const importResponse = await sendRequest(ws, WS_METHODS.serverImportCodexSessions, { + codexBinaryPath: "/usr/local/bin/codex", + codexHomePath: "/Users/test/.codex", + selections: [ + { + cwd: "/tmp/codex-project", + projectId: null, + createProject: true, + projectTitle: "codex-project", + providerThreadIds: ["codex-thread-1"], + }, + ], + }); + expect(importResponse.error).toBeUndefined(); + expect(importCalls).toEqual([ + { + codexBinaryPath: "/usr/local/bin/codex", + codexHomePath: "/Users/test/.codex", + selections: [ + { + cwd: "/tmp/codex-project", + projectId: null, + createProject: true, + projectTitle: "codex-project", + providerThreadIds: ["codex-thread-1"], + }, + ], + }, + ]); + expect(importResponse.result).toEqual({ + createdProjectIds: ["project-imported"], + importedThreadIds: ["thread-imported"], + refreshedThreadIds: [], + skippedProviderThreadIds: [], + failures: [], + }); + + const deleteResponse = await sendRequest(ws, WS_METHODS.serverDeleteThread, { + threadId: "thread-imported", + }); + expect(deleteResponse.error).toBeUndefined(); + expect(deleteCalls).toEqual([{ threadId: "thread-imported" }]); + expect(deleteResponse.result).toEqual({ sequence: 42, archivedExternal: true }); + }); + it("returns error for unknown methods", async () => { server = await createTestServer({ cwd: "/test" }); const addr = server.address(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d8859c2fa5b7..3c1741bc9dc0 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -73,6 +73,7 @@ import { import { parseBase64DataUrl } from "./imageMime.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { expandHomePath } from "./os-jank.ts"; +import { CodexImportService } from "./codexImport/Services/CodexImportService.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -208,7 +209,8 @@ export type ServerCoreRuntimeServices = | CheckpointDiffQuery | OrchestrationReactor | ProviderService - | ProviderHealth; + | ProviderHealth + | CodexImportService; export type ServerRuntimeServices = | ServerCoreRuntimeServices @@ -254,6 +256,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; + const codexImportService = yield* CodexImportService; const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -887,6 +890,21 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< availableEditors, }; + case WS_METHODS.serverPreviewCodexImport: { + const body = stripRequestTag(request.body); + return yield* codexImportService.previewCodexImport(body); + } + + case WS_METHODS.serverImportCodexSessions: { + const body = stripRequestTag(request.body); + return yield* codexImportService.importCodexSessions(body); + } + + case WS_METHODS.serverDeleteThread: { + const body = stripRequestTag(request.body); + return yield* codexImportService.deleteThread(body); + } + case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 51b300ba8ec2..42d7b4ab2d57 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -336,6 +336,7 @@ function buildLocalDraftThread( return { id: threadId, codexThreadId: null, + isImportedCodexThread: false, projectId: draftThread.projectId, title: "New thread", model: fallbackModel, @@ -2683,13 +2684,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } })().catch(async (err: unknown) => { if (createdServerThreadForLocalDraft && !turnStartSucceeded) { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadIdForSend, - }) - .catch(() => undefined); + await api.server.deleteThread({ threadId: threadIdForSend }).catch(() => undefined); } if ( !turnStartSucceeded && @@ -3079,13 +3074,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }) .catch(async (err) => { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: nextThreadId, - }) - .catch(() => undefined); + await api.server.deleteThread({ threadId: nextThreadId }).catch(() => undefined); await api.orchestration .getSnapshot() .then((snapshot) => { diff --git a/apps/web/src/components/CodexImportDialog.browser.tsx b/apps/web/src/components/CodexImportDialog.browser.tsx new file mode 100644 index 000000000000..3167a5eae1f3 --- /dev/null +++ b/apps/web/src/components/CodexImportDialog.browser.tsx @@ -0,0 +1,327 @@ +import "../index.css"; + +import type { + OrchestrationReadModel, + ServerImportCodexSessionsResult, + ServerPreviewCodexImportResult, +} from "@t3tools/contracts"; +import { page } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const { + syncServerReadModel, + importCodexSessions, + getSnapshot, + toastAdd, + invalidateQueries, + previewQueryData, +} = vi.hoisted(() => ({ + syncServerReadModel: vi.fn(), + importCodexSessions: vi.fn(), + getSnapshot: vi.fn(), + toastAdd: vi.fn(), + invalidateQueries: vi.fn(), + previewQueryData: { + current: null as ServerPreviewCodexImportResult | null, + }, +})); + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQuery: () => ({ + data: previewQueryData.current, + isPending: false, + error: null, + }), + useQueryClient: () => ({ + invalidateQueries, + }), + useMutation: (options: { + mutationFn: (input: unknown) => Promise; + onSuccess?: (result: ServerImportCodexSessionsResult) => unknown; + onError?: (error: unknown) => unknown; + }) => ({ + isPending: false, + mutateAsync: async (input: unknown) => { + try { + const result = await options.mutationFn(input); + await options.onSuccess?.(result); + return result; + } catch (error) { + await options.onError?.(error); + throw error; + } + }, + }), + }; +}); + +vi.mock("../appSettings", () => ({ + useAppSettings: () => ({ + settings: { + codexBinaryPath: "", + codexHomePath: "", + }, + }), +})); + +vi.mock("../store", () => ({ + useStore: (selector: (state: { syncServerReadModel: typeof syncServerReadModel }) => unknown) => + selector({ syncServerReadModel }), +})); + +vi.mock("../nativeApi", () => ({ + ensureNativeApi: () => ({ + server: { + importCodexSessions, + }, + orchestration: { + getSnapshot, + }, + }), +})); + +vi.mock("./ui/toast", () => ({ + toastManager: { + add: toastAdd, + }, +})); + +import { CodexImportDialog } from "./CodexImportDialog"; + +function createPreviewGroups(): ServerPreviewCodexImportResult { + return { + groups: [ + { + cwd: "/repo/root", + displayCwd: "~/repo/root", + cwdExists: true, + existingProjectId: null, + existingProjectTitle: null, + suggestedProjectTitle: "root", + mainSessions: [ + { + providerThreadId: "main-new", + linkedThreadId: null, + title: "Main new", + preview: "Main preview", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + sourceKind: "cli", + modelProvider: "openai", + importState: "new", + disabledReason: null, + }, + ], + worktrees: [ + { + cwd: "/repo/root/worktrees/feature-a", + displayCwd: "~/repo/root/worktrees/feature-a", + cwdExists: true, + sessions: [ + { + providerThreadId: "wt-imported", + linkedThreadId: "thread-existing" as never, + title: "WT imported", + preview: "Imported preview", + createdAt: "2026-03-08T00:00:02.000Z", + updatedAt: "2026-03-08T00:00:03.000Z", + sourceKind: "cli", + modelProvider: "openai", + importState: "already-imported", + disabledReason: null, + }, + ], + }, + ], + }, + ], + }; +} + +function createSnapshot(): OrchestrationReadModel { + return { + snapshotSequence: 1, + updatedAt: "2026-03-08T00:00:10.000Z", + projects: [ + { + id: "project-1" as never, + title: "Root Project", + workspaceRoot: "/repo/root", + defaultModel: "gpt-5", + scripts: [], + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:00.000Z", + deletedAt: null, + }, + ], + threads: [ + { + id: "thread-imported" as never, + projectId: "project-1" as never, + title: "Imported thread", + model: "gpt-5", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:00.000Z", + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + }; +} + +async function mountDialog() { + const host = document.createElement("div"); + document.body.appendChild(host); + const screen = await render( + ( + + )} + />, + { container: host }, + ); + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("CodexImportDialog", () => { + beforeEach(() => { + document.body.innerHTML = ""; + importCodexSessions.mockReset(); + getSnapshot.mockReset(); + syncServerReadModel.mockReset(); + toastAdd.mockReset(); + invalidateQueries.mockReset(); + previewQueryData.current = createPreviewGroups(); + getSnapshot.mockResolvedValue(createSnapshot()); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("limits bulk select and import to the current filtered view", async () => { + importCodexSessions.mockResolvedValue({ + createdProjectIds: [], + importedThreadIds: [], + refreshedThreadIds: [], + skippedProviderThreadIds: [], + failures: [], + }); + const mounted = await mountDialog(); + + try { + await page.getByRole("button", { name: "Open Codex import" }).click(); + await expect.element(page.getByText("Import Codex sessions")).toBeVisible(); + + await page + .getByPlaceholder("Filter by repo path, worktree path, or session title") + .fill("WT imported"); + await page.getByRole("button", { name: /^Select all$/ }).click(); + + await expect.element(page.getByText("1 visible session selected")).toBeVisible(); + await page.getByRole("button", { name: "Import selected" }).click(); + + expect(importCodexSessions).toHaveBeenCalledWith({ + codexBinaryPath: "", + codexHomePath: "", + selections: [ + { + cwd: "/repo/root", + projectId: null, + createProject: true, + projectTitle: "root", + providerThreadIds: ["wt-imported"], + }, + ], + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the post-import results dialog with imported, skipped, and failed sessions", async () => { + importCodexSessions.mockResolvedValue({ + createdProjectIds: ["project-1" as never], + importedThreadIds: ["thread-imported" as never], + refreshedThreadIds: [], + skippedProviderThreadIds: ["wt-imported"], + failures: [ + { + providerThreadId: "main-new", + message: "boom", + }, + ], + }); + const mounted = await mountDialog(); + + try { + await page.getByRole("button", { name: "Open Codex import" }).click(); + await expect.element(page.getByText("Import Codex sessions")).toBeVisible(); + await page.getByRole("button", { name: /^Select all$/ }).click(); + await page.getByRole("button", { name: "Import selected" }).click(); + + await expect.element(page.getByText("Codex import results")).toBeVisible(); + await expect.element(page.getByText("Imported thread")).toBeVisible(); + await expect.element(page.getByText("WT imported")).toBeVisible(); + await expect.element(page.getByText("Main new")).toBeVisible(); + await expect.element(page.getByText("boom")).toBeVisible(); + await expect.element(page.getByText("1 imported")).toBeVisible(); + await expect.element(page.getByText("1 skipped")).toBeVisible(); + await expect.element(page.getByText("1 failed")).toBeVisible(); + await expect.element(page.getByText("1 projects created")).toBeVisible(); + } finally { + await mounted.cleanup(); + } + }); + + it("only clears selections from the current filtered view", async () => { + const mounted = await mountDialog(); + + try { + await page.getByRole("button", { name: "Open Codex import" }).click(); + await expect.element(page.getByText("Import Codex sessions")).toBeVisible(); + + const filterInput = page.getByPlaceholder( + "Filter by repo path, worktree path, or session title", + ); + await filterInput.fill("WT imported"); + await page.getByRole("button", { name: /^Select all$/ }).click(); + await expect.element(page.getByText("1 visible session selected")).toBeVisible(); + + await filterInput.fill(""); + await expect.element(page.getByText("2 visible sessions selected")).toBeVisible(); + + await filterInput.fill("WT imported"); + await page.getByRole("button", { name: /^Unselect all$/ }).click(); + await expect.element(page.getByText("0 visible sessions selected")).toBeVisible(); + + await filterInput.fill(""); + await expect.element(page.getByText("1 visible session selected")).toBeVisible(); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/CodexImportDialog.tsx b/apps/web/src/components/CodexImportDialog.tsx new file mode 100644 index 000000000000..dfa52b09aea2 --- /dev/null +++ b/apps/web/src/components/CodexImportDialog.tsx @@ -0,0 +1,849 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { type ServerImportCodexSessionsInput } from "@t3tools/contracts"; +import { ChevronRightIcon } from "lucide-react"; + +import { useAppSettings } from "../appSettings"; +import { + buildCodexImportMainExpansionKey, + buildCodexImportRootExpansionKey, + buildCodexImportResultSummary, + buildCodexImportSelections, + buildCodexImportWorktreeExpansionKey, + buildCodexImportWorktreesExpansionKey, + buildDefaultCodexImportSelection, + countUnavailableCodexImportSessions, + filterCodexImportGroupsByQuery, + filterCodexImportGroupsForDisplay, + formatCodexImportStateLabel, + getSelectableProviderThreadIdsForGroups, + getSelectableProviderThreadIdsForMainGroup, + getSelectableProviderThreadIdsForRootGroup, + getSelectableProviderThreadIdsForWorktree, + getSelectableProviderThreadIdsForWorktreesGroup, + isCodexImportSessionSelectable, +} from "../lib/codexImport"; +import { serverCodexImportPreviewQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; +import { ensureNativeApi } from "../nativeApi"; +import { useStore } from "../store"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { toastManager } from "./ui/toast"; + +function formatCodexImportTimestamp(value: string): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + return value; + } + return new Date(timestamp).toLocaleString(); +} + +function codexImportStateBadgeClass(importState: "new" | "already-imported" | "continued-in-t3" | "unavailable"): string { + return importState === "already-imported" + ? "border-emerald-300/60 bg-emerald-500/8 text-emerald-700 dark:text-emerald-300" + : "border-border text-muted-foreground"; +} + +export function CodexImportDialog(props: { + readonly renderTrigger: (triggerProps: { + readonly openDialog: () => void; + readonly disabled: boolean; + }) => ReactNode; +}) { + const { settings } = useAppSettings(); + const queryClient = useQueryClient(); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); + const [isOpen, setIsOpen] = useState(false); + const [expandedGroups, setExpandedGroups] = useState([]); + const [selectedThreadIds, setSelectedThreadIds] = useState([]); + const [showUnavailableSessions, setShowUnavailableSessions] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [resultSummary, setResultSummary] = useState | null>(null); + + const codexBinaryPath = settings.codexBinaryPath; + const codexHomePath = settings.codexHomePath; + const previewQuery = useQuery( + serverCodexImportPreviewQueryOptions({ + codexBinaryPath, + codexHomePath, + enabled: isOpen, + }), + ); + const selectedThreadIdSet = useMemo(() => new Set(selectedThreadIds), [selectedThreadIds]); + const displayGroups = useMemo( + () => filterCodexImportGroupsForDisplay(previewQuery.data?.groups ?? [], showUnavailableSessions), + [previewQuery.data?.groups, showUnavailableSessions], + ); + const searchedGroups = useMemo( + () => filterCodexImportGroupsByQuery(displayGroups, searchQuery), + [displayGroups, searchQuery], + ); + const unavailableSessionCount = useMemo( + () => countUnavailableCodexImportSessions(previewQuery.data?.groups ?? []), + [previewQuery.data?.groups], + ); + const visibleImportSelections = useMemo( + () => buildCodexImportSelections(searchedGroups, selectedThreadIdSet), + [searchedGroups, selectedThreadIdSet], + ); + const visibleSelectableThreadIds = useMemo( + () => getSelectableProviderThreadIdsForGroups(searchedGroups), + [searchedGroups], + ); + const selectedImportCount = useMemo( + () => + visibleImportSelections.reduce( + (count, selection) => count + selection.providerThreadIds.length, + 0, + ), + [visibleImportSelections], + ); + const allSelectableSelected = + visibleSelectableThreadIds.length > 0 && + visibleSelectableThreadIds.every((providerThreadId) => selectedThreadIdSet.has(providerThreadId)); + + useEffect(() => { + if (!isOpen || !previewQuery.data) { + return; + } + setSelectedThreadIds(buildDefaultCodexImportSelection(previewQuery.data.groups)); + }, [isOpen, previewQuery.data]); + + const closeDialog = useCallback(() => { + setIsOpen(false); + setExpandedGroups([]); + setSelectedThreadIds([]); + setShowUnavailableSessions(false); + setSearchQuery(""); + }, []); + + const importMutation = useMutation({ + mutationKey: ["server", "mutation", "import-codex-sessions"] as const, + mutationFn: async (selections: ServerImportCodexSessionsInput["selections"]) => { + const api = ensureNativeApi(); + return api.server.importCodexSessions({ + codexBinaryPath, + codexHomePath, + selections, + }); + }, + onSuccess: async (result) => { + const api = ensureNativeApi(); + const snapshot = await api.orchestration.getSnapshot(); + syncServerReadModel(snapshot); + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + setResultSummary(buildCodexImportResultSummary(previewQuery.data?.groups ?? [], snapshot, result)); + closeDialog(); + const importedCount = result.importedThreadIds.length; + const refreshedCount = result.refreshedThreadIds.length; + const failureCount = result.failures.length; + const createdProjectCount = result.createdProjectIds.length; + const skippedCount = result.skippedProviderThreadIds.length; + toastManager.add({ + type: failureCount > 0 ? "warning" : "success", + title: failureCount > 0 ? "Codex import completed with issues" : "Codex sessions imported", + description: [ + importedCount > 0 ? `${importedCount} imported` : null, + refreshedCount > 0 ? `${refreshedCount} refreshed` : null, + createdProjectCount > 0 ? `${createdProjectCount} projects created` : null, + skippedCount > 0 ? `${skippedCount} skipped` : null, + failureCount > 0 ? `${failureCount} failed` : null, + ] + .filter(Boolean) + .join(", "), + }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Could not import Codex sessions", + description: + error instanceof Error ? error.message : "An error occurred while importing sessions.", + }); + }, + }); + + const toggleSessionSelection = useCallback((providerThreadId: string, nextChecked: boolean) => { + setSelectedThreadIds((existing) => { + if (nextChecked) { + return existing.includes(providerThreadId) ? existing : [...existing, providerThreadId]; + } + return existing.filter((id) => id !== providerThreadId); + }); + }, []); + + const toggleSelectionScope = useCallback( + (providerThreadIds: ReadonlyArray, nextChecked: boolean) => { + setSelectedThreadIds((existing) => { + if (nextChecked) { + return Array.from(new Set([...existing, ...providerThreadIds])); + } + const blockedIds = new Set(providerThreadIds); + return existing.filter((id) => !blockedIds.has(id)); + }); + }, + [], + ); + + const submitImport = useCallback(() => { + if (visibleImportSelections.length === 0) { + toastManager.add({ + type: "warning", + title: "Choose at least one session", + }); + return; + } + void importMutation.mutateAsync(visibleImportSelections); + }, [importMutation, visibleImportSelections]); + + const toggleGroupExpanded = useCallback((key: string, nextOpen: boolean) => { + setExpandedGroups((existing) => { + if (nextOpen) { + return existing.includes(key) ? existing : [...existing, key]; + } + return existing.filter((entry) => entry !== key); + }); + }, []); + + const countSelectedThreadIds = useCallback( + (providerThreadIds: ReadonlyArray) => + providerThreadIds.filter((providerThreadId) => selectedThreadIdSet.has(providerThreadId)).length, + [selectedThreadIdSet], + ); + + const selectAllSessions = useCallback(() => { + setSelectedThreadIds((existing) => + Array.from(new Set([...existing, ...visibleSelectableThreadIds])), + ); + }, [visibleSelectableThreadIds]); + + const unselectAllSessions = useCallback(() => { + const visibleProviderThreadIds = new Set(visibleSelectableThreadIds); + setSelectedThreadIds((existing) => + existing.filter((providerThreadId) => !visibleProviderThreadIds.has(providerThreadId)), + ); + }, [visibleSelectableThreadIds]); + const resultSections = resultSummary + ? [ + { label: "Imported", items: resultSummary.imported }, + { label: "Refreshed", items: resultSummary.refreshed }, + { label: "Skipped", items: resultSummary.skipped }, + ] + : []; + + return ( + <> + {props.renderTrigger({ + openDialog: () => setIsOpen(true), + disabled: importMutation.isPending, + })} + + { + if (importMutation.isPending) { + return; + } + if (open) { + setIsOpen(true); + return; + } + closeDialog(); + }} + > + + +
+ Import Codex sessions + + Import active, non-archived Codex sessions from {codexBinaryPath || "codex"}. + +
+ {unavailableSessionCount > 0 ? ( + + ) : null} +
+ +
+ setSearchQuery(event.currentTarget.value)} + placeholder="Filter by repo path, worktree path, or session title" + disabled={importMutation.isPending || previewQuery.isPending} + /> +

+ Bulk actions apply to the current filtered view. Existing selections stay intact. +

+
+ {previewQuery.isPending ? ( +
+ Loading Codex sessions... +
+ ) : previewQuery.error ? ( +
+ {previewQuery.error instanceof Error + ? previewQuery.error.message + : "Could not load Codex sessions."} +
+ ) : searchedGroups.length === 0 ? ( +
+ {searchQuery.trim().length > 0 + ? `No sessions match "${searchQuery.trim()}".` + : unavailableSessionCount > 0 && !showUnavailableSessions + ? "All available sessions are currently hidden." + : "No active Codex sessions were found."} +
+ ) : ( + searchedGroups.map((group) => { + const rootSelectableProviderThreadIds = getSelectableProviderThreadIdsForRootGroup(group); + const rootSelectedCount = countSelectedThreadIds(rootSelectableProviderThreadIds); + const rootExpansionKey = buildCodexImportRootExpansionKey(group.cwd); + const mainSelectableProviderThreadIds = getSelectableProviderThreadIdsForMainGroup(group); + const mainSelectedCount = countSelectedThreadIds(mainSelectableProviderThreadIds); + const mainExpansionKey = buildCodexImportMainExpansionKey(group.cwd); + const worktreesSelectableProviderThreadIds = + getSelectableProviderThreadIdsForWorktreesGroup(group); + const worktreesSelectedCount = countSelectedThreadIds( + worktreesSelectableProviderThreadIds, + ); + const worktreesExpansionKey = buildCodexImportWorktreesExpansionKey(group.cwd); + const isExpanded = expandedGroups.includes(rootExpansionKey); + return ( + toggleGroupExpanded(rootExpansionKey, open)} + > +
+
+
+ 0 && + rootSelectedCount === rootSelectableProviderThreadIds.length + } + disabled={ + rootSelectableProviderThreadIds.length === 0 || + importMutation.isPending + } + onCheckedChange={(checked) => + toggleSelectionScope( + rootSelectableProviderThreadIds, + Boolean(checked), + ) + } + aria-label={`Select sessions for ${group.cwd}`} + /> +
+ + +
+

+ {group.displayCwd} +

+
+ + {group.existingProjectTitle + ? `Project: ${group.existingProjectTitle}` + : `New project: ${group.suggestedProjectTitle}`} + + + {group.cwdExists ? "Workspace available" : "Workspace missing"} + + {rootSelectableProviderThreadIds.length > 0 ? ( + + {rootSelectedCount}/{rootSelectableProviderThreadIds.length} selected + + ) : null} +
+
+
+
+ + +
+ {group.mainSessions.length > 0 ? ( + toggleGroupExpanded(mainExpansionKey, open)} + > +
+
+ 0 && + mainSelectedCount === mainSelectableProviderThreadIds.length + } + disabled={ + mainSelectableProviderThreadIds.length === 0 || + importMutation.isPending + } + onCheckedChange={(checked) => + toggleSelectionScope( + mainSelectableProviderThreadIds, + Boolean(checked), + ) + } + aria-label={`Select main sessions for ${group.cwd}`} + /> +
+ + +
+

main

+
+ + {group.mainSessions.length} session + {group.mainSessions.length === 1 ? "" : "s"} + + {mainSelectableProviderThreadIds.length > 0 ? ( + + {mainSelectedCount}/{mainSelectableProviderThreadIds.length} selected + + ) : null} +
+
+
+
+ + +
+ {group.mainSessions.length === 0 ? ( +
+ No main worktree sessions found. +
+ ) : ( + group.mainSessions.map((session) => { + const selectable = isCodexImportSessionSelectable( + group.cwdExists, + session, + ); + return ( + + ); + }) + )} +
+
+
+ ) : null} + + {group.worktrees.length > 0 ? ( + toggleGroupExpanded(worktreesExpansionKey, open)} + > +
+
+ 0 && + worktreesSelectedCount === worktreesSelectableProviderThreadIds.length + } + disabled={ + worktreesSelectableProviderThreadIds.length === 0 || + importMutation.isPending + } + onCheckedChange={(checked) => + toggleSelectionScope( + worktreesSelectableProviderThreadIds, + Boolean(checked), + ) + } + aria-label={`Select worktree sessions for ${group.cwd}`} + /> +
+ + +
+

worktrees

+
+ + {group.worktrees.length} worktree + {group.worktrees.length === 1 ? "" : "s"} + + {worktreesSelectableProviderThreadIds.length > 0 ? ( + + {worktreesSelectedCount}/{worktreesSelectableProviderThreadIds.length} selected + + ) : null} +
+
+
+
+ + +
+ {group.worktrees.length === 0 ? ( +
+ No linked worktree sessions found. +
+ ) : ( + group.worktrees.map((worktree) => { + const worktreeSelectableProviderThreadIds = + getSelectableProviderThreadIdsForWorktree(worktree); + const worktreeSelectedCount = countSelectedThreadIds( + worktreeSelectableProviderThreadIds, + ); + const worktreeExpansionKey = + buildCodexImportWorktreeExpansionKey(group.cwd, worktree.cwd); + return ( + + toggleGroupExpanded(worktreeExpansionKey, open) + } + > +
+
+ 0 && + worktreeSelectedCount === + worktreeSelectableProviderThreadIds.length + } + disabled={ + worktreeSelectableProviderThreadIds.length === 0 || + importMutation.isPending + } + onCheckedChange={(checked) => + toggleSelectionScope( + worktreeSelectableProviderThreadIds, + Boolean(checked), + ) + } + aria-label={`Select sessions for ${worktree.cwd}`} + /> +
+ + +
+

+ {worktree.displayCwd} +

+
+ + {worktree.cwdExists + ? "Worktree available" + : "Worktree missing"} + + + {worktree.sessions.length} session + {worktree.sessions.length === 1 ? "" : "s"} + + {worktreeSelectableProviderThreadIds.length > 0 ? ( + + {worktreeSelectedCount}/{worktreeSelectableProviderThreadIds.length} selected + + ) : null} +
+
+
+
+ + +
+ {worktree.sessions.map((session) => { + const selectable = isCodexImportSessionSelectable( + worktree.cwdExists, + session, + ); + return ( + + ); + })} +
+
+
+ ); + }) + )} +
+
+
+ ) : null} +
+
+
+
+ ); + }) + )} +
+ +
+ + +
+ {selectedImportCount} visible session{selectedImportCount === 1 ? "" : "s"} selected +
+
+ + +
+
+
+ + !open && setResultSummary(null)}> + + + Codex import results + + Review what was imported, skipped, or failed before you move on. + + + + {resultSummary ? ( + <> +
+ + {resultSummary.imported.length} imported + + + {resultSummary.refreshed.length} refreshed + + + {resultSummary.skipped.length} skipped + + + {resultSummary.failures.length} failed + + {resultSummary.createdProjectCount > 0 ? ( + + {resultSummary.createdProjectCount} projects created + + ) : null} +
+ + {resultSections.map(({ label, items }) => + items.length > 0 ? ( +
+

{label}

+
+ {items.map((item) => ( +
+

{item.title}

+ {item.detail ? ( +

{item.detail}

+ ) : null} +
+ ))} +
+
+ ) : null, + )} + + {resultSummary.failures.length > 0 ? ( +
+

Failed

+
+ {resultSummary.failures.map((item) => ( +
+

{item.title}

+ {item.detail ? ( +

{item.detail}

+ ) : null} +

{item.message}

+
+ ))} +
+
+ ) : null} + + ) : null} +
+ + + +
+
+ + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 894fde25e916..357366089845 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -36,6 +36,7 @@ import { readNativeApi } from "../nativeApi"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; +import { CodexImportDialog } from "./CodexImportDialog"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, @@ -652,41 +653,60 @@ export default function Sidebar() { if (clicked !== "delete") return; if (appSettings.confirmThreadDelete) { const confirmed = await api.dialogs.confirm( - [ - `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", - ].join("\n"), + thread.isImportedCodexThread + ? [ + `Archive Codex session "${thread.title}"?`, + "This archives the session in Codex and removes it from T3.", + ].join("\n") + : [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), ); if (!confirmed) { return; } } const threadProject = projects.find((project) => project.id === thread.projectId); - const orphanedWorktreePath = getOrphanedWorktreePathForThread(threads, threadId); - const displayWorktreePath = orphanedWorktreePath - ? formatWorktreePathForDisplay(orphanedWorktreePath) + const candidateWorktreePath = getOrphanedWorktreePathForThread(threads, threadId); + const displayWorktreePath = candidateWorktreePath + ? formatWorktreePathForDisplay(candidateWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const canDeleteWorktree = candidateWorktreePath !== null && threadProject !== undefined; const shouldDeleteWorktree = canDeleteWorktree && (await api.dialogs.confirm( [ "This thread is the only one linked to this worktree:", - displayWorktreePath ?? orphanedWorktreePath, + displayWorktreePath ?? candidateWorktreePath, "", "Delete the worktree too?", ].join("\n"), )); - if (thread.session && thread.session.status !== "closed") { - await api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); + const shouldNavigateToFallback = routeThreadId === threadId; + const fallbackThreadId = threads.find((entry) => entry.id !== threadId)?.id ?? null; + let deletedInT3 = false; + try { + await api.server.deleteThread({ threadId }); + deletedInT3 = true; + } catch (error) { + const message = + error instanceof Error ? error.message : "An error occurred while deleting the thread."; + const snapshot = await api.orchestration.getSnapshot().catch(() => null); + deletedInT3 = snapshot + ? !snapshot.threads.some((entry) => entry.id === threadId && entry.deletedAt === null) + : false; + toastManager.add({ + type: deletedInT3 ? "warning" : "error", + title: deletedInT3 + ? "Thread deleted, but Codex archive failed" + : "Failed to delete thread", + description: message, + }); + if (!deletedInT3) { + return; + } } try { @@ -698,13 +718,6 @@ export default function Sidebar() { // Terminal may already be closed } - const shouldNavigateToFallback = routeThreadId === threadId; - const fallbackThreadId = threads.find((entry) => entry.id !== threadId)?.id ?? null; - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId, - }); clearComposerDraftForThread(threadId); clearProjectDraftThreadById(thread.projectId, thread.id); clearTerminalState(threadId); @@ -720,14 +733,31 @@ export default function Sidebar() { } } - if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { + if (!shouldDeleteWorktree || !candidateWorktreePath || !threadProject) { + return; + } + + const snapshotAfterDelete = await api.orchestration.getSnapshot().catch(() => null); + const stillOrphaned = snapshotAfterDelete + ? !snapshotAfterDelete.threads.some((entry) => { + const normalizedWorktreePath = entry.worktreePath?.trim() || null; + return ( + entry.deletedAt === null && + entry.id !== threadId && + entry.projectId === thread.projectId && + normalizedWorktreePath === candidateWorktreePath + ); + }) + : false; + + if (!stillOrphaned) { return; } try { await removeWorktreeMutation.mutateAsync({ cwd: threadProject.cwd, - path: orphanedWorktreePath, + path: candidateWorktreePath, force: true, }); } catch (error) { @@ -735,13 +765,13 @@ export default function Sidebar() { console.error("Failed to remove orphaned worktree after thread deletion", { threadId, projectCwd: threadProject.cwd, - worktreePath: orphanedWorktreePath, + worktreePath: candidateWorktreePath, error, }); toastManager.add({ type: "error", title: "Thread deleted, but worktree removal failed", - description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, + description: `Could not remove ${displayWorktreePath ?? candidateWorktreePath}. ${message}`, }); } }, @@ -1102,6 +1132,18 @@ export default function Sidebar() { {isPickingFolder ? "Picking folder..." : "Browse for folder"} )} + ( + + )} + />
; + refreshed: Array<{ id: string; title: string; detail: string | null }>; + skipped: Array<{ id: string; title: string; detail: string | null }>; + failures: Array<{ id: string; title: string; detail: string | null; message: string }>; + createdProjectCount: number; +}; + +function normalizeCodexImportSearchQuery(query: string): string { + return query.trim().toLowerCase(); +} + +function matchesCodexImportSearch(query: string, ...values: Array): boolean { + return values.some((value) => value?.toLowerCase().includes(query)); +} + +export function isCodexImportSessionSelectable( + cwdExists: boolean, + session: CodexImportPreviewSession, +): boolean { + return ( + cwdExists && + session.importState !== "continued-in-t3" && + session.importState !== "unavailable" && + session.disabledReason === null + ); +} + +export function isCodexImportSessionUnavailableForImport( + session: CodexImportPreviewSession, +): boolean { + return ( + session.importState === "continued-in-t3" || + session.importState === "unavailable" || + session.disabledReason !== null + ); +} + +export function buildCodexImportRootExpansionKey(cwd: string): string { + return `root:${cwd}`; +} + +export function buildCodexImportMainExpansionKey(cwd: string): string { + return `main:${cwd}`; +} + +export function buildCodexImportWorktreesExpansionKey(cwd: string): string { + return `worktrees:${cwd}`; +} + +export function buildCodexImportWorktreeExpansionKey(rootCwd: string, worktreeCwd: string): string { + return `worktree:${rootCwd}:${worktreeCwd}`; +} + +export function getSelectableProviderThreadIdsForRootGroup(group: CodexImportPreviewGroup): string[] { + return [ + ...getSelectableProviderThreadIdsForMainGroup(group), + ...getSelectableProviderThreadIdsForWorktreesGroup(group), + ]; +} + +export function getSelectableProviderThreadIdsForGroups( + groups: ReadonlyArray, +): string[] { + return groups.flatMap((group) => getSelectableProviderThreadIdsForRootGroup(group)); +} + +export function countUnavailableCodexImportSessions( + groups: ReadonlyArray, +): number { + return groups.reduce( + (count, group) => + count + + group.mainSessions.filter((session) => isCodexImportSessionUnavailableForImport(session)).length + + group.worktrees.reduce( + (worktreeCount, worktree) => + worktreeCount + + worktree.sessions.filter((session) => isCodexImportSessionUnavailableForImport(session)).length, + 0, + ), + 0, + ); +} + +export function filterCodexImportGroupsForDisplay( + groups: ReadonlyArray, + showUnavailableSessions: boolean, +): CodexImportPreviewGroup[] { + return groups.flatMap((group) => { + const mainSessions = group.mainSessions.filter( + (session) => showUnavailableSessions || !isCodexImportSessionUnavailableForImport(session), + ); + const worktrees = group.worktrees + .map((worktree) => ({ + ...worktree, + sessions: worktree.sessions.filter( + (session) => showUnavailableSessions || !isCodexImportSessionUnavailableForImport(session), + ), + })) + .filter((worktree) => worktree.sessions.length > 0); + + if (mainSessions.length === 0 && worktrees.length === 0) { + return []; + } + + return [ + { + ...group, + mainSessions, + worktrees, + }, + ]; + }); +} + +export function filterCodexImportGroupsByQuery( + groups: ReadonlyArray, + rawQuery: string, +): CodexImportPreviewGroup[] { + const query = normalizeCodexImportSearchQuery(rawQuery); + if (!query) { + return [...groups]; + } + + return groups.flatMap((group) => { + const groupMatches = matchesCodexImportSearch( + query, + group.cwd, + group.displayCwd, + group.existingProjectTitle, + group.suggestedProjectTitle, + ); + + if (groupMatches) { + return [group]; + } + + const mainSessions = group.mainSessions.filter((session) => + matchesCodexImportSearch( + query, + session.providerThreadId, + session.title, + session.preview, + session.sourceKind, + session.modelProvider, + formatCodexImportStateLabel(session.importState), + ), + ); + + const worktrees = group.worktrees.flatMap((worktree) => { + const worktreeMatches = matchesCodexImportSearch(query, worktree.cwd, worktree.displayCwd); + if (worktreeMatches) { + return [worktree]; + } + const sessions = worktree.sessions.filter((session) => + matchesCodexImportSearch( + query, + session.providerThreadId, + session.title, + session.preview, + session.sourceKind, + session.modelProvider, + formatCodexImportStateLabel(session.importState), + ), + ); + return sessions.length > 0 ? [{ ...worktree, sessions }] : []; + }); + + if (mainSessions.length === 0 && worktrees.length === 0) { + return []; + } + + return [ + { + ...group, + mainSessions, + worktrees, + }, + ]; + }); +} + +export function getSelectableProviderThreadIdsForMainGroup(group: CodexImportPreviewGroup): string[] { + return group.mainSessions.flatMap((session) => + isCodexImportSessionSelectable(group.cwdExists, session) ? [session.providerThreadId] : [], + ); +} + +export function getSelectableProviderThreadIdsForWorktree( + worktree: CodexImportPreviewWorktree, +): string[] { + return worktree.sessions.flatMap((session) => + isCodexImportSessionSelectable(worktree.cwdExists, session) ? [session.providerThreadId] : [], + ); +} + +export function getSelectableProviderThreadIdsForWorktreesGroup( + group: CodexImportPreviewGroup, +): string[] { + return group.worktrees.flatMap((worktree) => getSelectableProviderThreadIdsForWorktree(worktree)); +} + +export function buildDefaultCodexImportSelection( + groups: ReadonlyArray, +): string[] { + return groups.flatMap((group) => + getSelectableProviderThreadIdsForRootGroup(group).filter((providerThreadId) => + group.mainSessions.some( + (session) => + session.providerThreadId === providerThreadId && session.importState === "new", + ) || + group.worktrees.some((worktree) => + worktree.sessions.some( + (session) => + session.providerThreadId === providerThreadId && session.importState === "new", + ), + ), + ), + ); +} + +export function buildCodexImportSelections( + groups: ReadonlyArray, + selectedProviderThreadIds: ReadonlySet, +): ServerImportCodexSessionsInput["selections"] { + return groups.flatMap((group) => { + const selectableProviderThreadIds = new Set(getSelectableProviderThreadIdsForRootGroup(group)); + const providerThreadIds = [...selectedProviderThreadIds].filter((providerThreadId) => + selectableProviderThreadIds.has(providerThreadId), + ); + if (providerThreadIds.length === 0) { + return []; + } + return [ + { + cwd: group.cwd, + projectId: group.existingProjectId, + createProject: group.existingProjectId === null, + projectTitle: group.existingProjectTitle ?? group.suggestedProjectTitle, + providerThreadIds, + }, + ]; + }); +} + +export function buildCodexImportResultSummary( + previewGroups: ReadonlyArray, + snapshot: OrchestrationReadModel, + result: ServerImportCodexSessionsResult, +): CodexImportResultSummary { + const sessionLookup = new Map( + previewGroups.flatMap((group) => [ + ...group.mainSessions.map((session) => [ + session.providerThreadId, + { title: session.title, detail: group.displayCwd }, + ] as const), + ...group.worktrees.flatMap((worktree) => + worktree.sessions.map((session) => [ + session.providerThreadId, + { title: session.title, detail: worktree.displayCwd }, + ] as const), + ), + ]), + ); + const projectLookup = new Map(snapshot.projects.map((project) => [project.id, project.title] as const)); + const threadLookup = new Map( + snapshot.threads.map((thread) => [ + thread.id, + { + title: thread.title, + detail: projectLookup.get(thread.projectId) ?? null, + }, + ] as const), + ); + + return { + imported: result.importedThreadIds.map((threadId) => { + const thread = threadLookup.get(threadId); + return { + id: threadId, + title: thread?.title ?? String(threadId), + detail: thread?.detail ?? null, + }; + }), + refreshed: result.refreshedThreadIds.map((threadId) => { + const thread = threadLookup.get(threadId); + return { + id: threadId, + title: thread?.title ?? String(threadId), + detail: thread?.detail ?? null, + }; + }), + skipped: result.skippedProviderThreadIds.map((providerThreadId) => { + const session = sessionLookup.get(providerThreadId); + return { + id: providerThreadId, + title: session?.title ?? providerThreadId, + detail: session?.detail ?? null, + }; + }), + failures: result.failures.map((failure) => { + const session = sessionLookup.get(failure.providerThreadId); + return { + id: failure.providerThreadId, + title: session?.title ?? failure.providerThreadId, + detail: session?.detail ?? null, + message: failure.message, + }; + }), + createdProjectCount: result.createdProjectIds.length, + }; +} + +export function formatCodexImportStateLabel(state: CodexImportPreviewSession["importState"]): string { + switch (state) { + case "already-imported": + return "Imported"; + case "continued-in-t3": + return "Continued in T3"; + case "unavailable": + return "Unavailable"; + case "new": + default: + return "New"; + } +} diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts index 85853e2ee23f..cb330d6d0331 100644 --- a/apps/web/src/lib/serverReactQuery.ts +++ b/apps/web/src/lib/serverReactQuery.ts @@ -4,6 +4,8 @@ import { ensureNativeApi } from "~/nativeApi"; export const serverQueryKeys = { all: ["server"] as const, config: () => ["server", "config"] as const, + codexImportPreview: (input: { codexBinaryPath: string; codexHomePath: string }) => + ["server", "codex-import-preview", input.codexBinaryPath, input.codexHomePath] as const, }; export function serverConfigQueryOptions() { @@ -16,3 +18,23 @@ export function serverConfigQueryOptions() { staleTime: Infinity, }); } + +export function serverCodexImportPreviewQueryOptions(input: { + codexBinaryPath: string; + codexHomePath: string; + enabled: boolean; +}) { + return queryOptions({ + queryKey: serverQueryKeys.codexImportPreview(input), + queryFn: async () => { + const api = ensureNativeApi(); + return api.server.previewCodexImport({ + codexBinaryPath: input.codexBinaryPath, + codexHomePath: input.codexHomePath, + }); + }, + enabled: input.enabled, + staleTime: 0, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/web/src/routes/_chat.settings.codexImport.test.ts b/apps/web/src/routes/_chat.settings.codexImport.test.ts new file mode 100644 index 000000000000..cfae32457b07 --- /dev/null +++ b/apps/web/src/routes/_chat.settings.codexImport.test.ts @@ -0,0 +1,286 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + buildCodexImportMainExpansionKey, + buildCodexImportRootExpansionKey, + buildCodexImportResultSummary, + buildCodexImportSelections, + filterCodexImportGroupsByQuery, + buildCodexImportWorktreeExpansionKey, + buildCodexImportWorktreesExpansionKey, + buildDefaultCodexImportSelection, + countUnavailableCodexImportSessions, + filterCodexImportGroupsForDisplay, + getSelectableProviderThreadIdsForGroups, + getSelectableProviderThreadIdsForMainGroup, + getSelectableProviderThreadIdsForRootGroup, + getSelectableProviderThreadIdsForWorktree, + getSelectableProviderThreadIdsForWorktreesGroup, + type CodexImportPreviewGroup, +} from "../lib/codexImport"; + +const groups: CodexImportPreviewGroup[] = [ + { + cwd: "/repo/root", + displayCwd: "~/repo/root", + cwdExists: true, + existingProjectId: null, + existingProjectTitle: null, + suggestedProjectTitle: "root", + mainSessions: [ + { + providerThreadId: "main-new", + linkedThreadId: null, + title: "Main new", + preview: "", + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:01.000Z", + sourceKind: "cli", + modelProvider: "openai", + importState: "new", + disabledReason: null, + }, + { + providerThreadId: "main-adopted", + linkedThreadId: ThreadId.makeUnsafe("thread-1"), + title: "Main adopted", + preview: "", + createdAt: "2026-03-08T00:00:02.000Z", + updatedAt: "2026-03-08T00:00:03.000Z", + sourceKind: "cli", + modelProvider: "openai", + importState: "continued-in-t3", + disabledReason: "Already continued", + }, + ], + worktrees: [ + { + cwd: "/repo/root/worktrees/feature-a", + displayCwd: "~/repo/root/worktrees/feature-a", + cwdExists: true, + sessions: [ + { + providerThreadId: "wt-new", + linkedThreadId: null, + title: "WT new", + preview: "", + createdAt: "2026-03-08T00:00:04.000Z", + updatedAt: "2026-03-08T00:00:05.000Z", + sourceKind: "cli", + modelProvider: "openai", + importState: "new", + disabledReason: null, + }, + { + providerThreadId: "wt-imported", + linkedThreadId: ThreadId.makeUnsafe("thread-2"), + title: "WT imported", + preview: "", + createdAt: "2026-03-08T00:00:06.000Z", + updatedAt: "2026-03-08T00:00:07.000Z", + sourceKind: "cli", + modelProvider: "openai", + importState: "already-imported", + disabledReason: null, + }, + ], + }, + { + cwd: "/repo/root/worktrees/missing", + displayCwd: "~/repo/root/worktrees/missing", + cwdExists: false, + sessions: [ + { + providerThreadId: "wt-missing", + linkedThreadId: null, + title: "WT missing", + preview: "", + createdAt: "2026-03-08T00:00:08.000Z", + updatedAt: "2026-03-08T00:00:09.000Z", + sourceKind: "vscode", + modelProvider: null, + importState: "unavailable", + disabledReason: "Missing", + }, + ], + }, + ], + }, +]; + +describe("codex import settings helpers", () => { + it("builds stable expansion keys for every nested subgroup", () => { + expect(buildCodexImportRootExpansionKey("/repo/root")).toBe("root:/repo/root"); + expect(buildCodexImportMainExpansionKey("/repo/root")).toBe("main:/repo/root"); + expect(buildCodexImportWorktreesExpansionKey("/repo/root")).toBe("worktrees:/repo/root"); + expect( + buildCodexImportWorktreeExpansionKey("/repo/root", "/repo/root/worktrees/feature-a"), + ).toBe("worktree:/repo/root:/repo/root/worktrees/feature-a"); + }); + + it("preselects only importable new sessions from main and worktree buckets", () => { + expect(buildDefaultCodexImportSelection(groups)).toEqual(["main-new", "wt-new"]); + }); + + it("returns the right selectable thread ids for each subtree", () => { + expect(getSelectableProviderThreadIdsForGroups(groups)).toEqual([ + "main-new", + "wt-new", + "wt-imported", + ]); + expect(getSelectableProviderThreadIdsForMainGroup(groups[0]!)).toEqual(["main-new"]); + expect(getSelectableProviderThreadIdsForWorktree(groups[0]!.worktrees[0]!)).toEqual([ + "wt-new", + "wt-imported", + ]); + expect(getSelectableProviderThreadIdsForWorktreesGroup(groups[0]!)).toEqual([ + "wt-new", + "wt-imported", + ]); + expect(getSelectableProviderThreadIdsForRootGroup(groups[0]!)).toEqual([ + "main-new", + "wt-new", + "wt-imported", + ]); + }); + + it("hides blocked sessions by default and prunes empty groups", () => { + const filteredGroups = filterCodexImportGroupsForDisplay(groups, false); + + expect(countUnavailableCodexImportSessions(groups)).toBe(2); + expect(filteredGroups).toHaveLength(1); + expect(filteredGroups[0]!.mainSessions.map((session) => session.providerThreadId)).toEqual([ + "main-new", + ]); + expect(filteredGroups[0]!.worktrees).toHaveLength(1); + expect( + filteredGroups[0]!.worktrees[0]!.sessions.map((session) => session.providerThreadId), + ).toEqual(["wt-new", "wt-imported"]); + }); + + it("shows hidden sessions again when requested", () => { + const filteredGroups = filterCodexImportGroupsForDisplay(groups, true); + + expect(filteredGroups).toEqual(groups); + }); + + it("filters groups by repo path, worktree path, and session fields without changing the source tree", () => { + expect(filterCodexImportGroupsByQuery(groups, "feature-a")).toEqual([ + { + ...groups[0]!, + mainSessions: [], + worktrees: [groups[0]!.worktrees[0]!], + }, + ]); + + expect(filterCodexImportGroupsByQuery(groups, "main new")).toEqual([ + { + ...groups[0]!, + mainSessions: [groups[0]!.mainSessions[0]!], + worktrees: [], + }, + ]); + + expect(filterCodexImportGroupsByQuery(groups, "root")).toEqual(groups); + }); + + it("builds import selections from the nested preview structure", () => { + const selections = buildCodexImportSelections( + groups, + new Set(["main-new", "wt-new", "wt-missing"]), + ); + + expect(selections).toEqual([ + { + cwd: "/repo/root", + projectId: null, + createProject: true, + projectTitle: "root", + providerThreadIds: ["main-new", "wt-new"], + }, + ]); + }); + + it("builds an import result summary from preview data and the synced snapshot", () => { + const resultSummary = buildCodexImportResultSummary( + groups, + { + snapshotSequence: 1, + updatedAt: "2026-03-08T00:00:10.000Z", + projects: [ + { + id: "project-1" as never, + title: "Root Project", + workspaceRoot: "/repo/root", + defaultModel: "gpt-5", + scripts: [], + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:00.000Z", + deletedAt: null, + }, + ], + threads: [ + { + id: ThreadId.makeUnsafe("thread-imported"), + projectId: "project-1" as never, + title: "Imported thread", + model: "gpt-5", + runtimeMode: "full-access", + interactionMode: "default", + latestTurn: null, + createdAt: "2026-03-08T00:00:00.000Z", + updatedAt: "2026-03-08T00:00:00.000Z", + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + branch: null, + worktreePath: null, + }, + ], + } as never, + { + createdProjectIds: ["project-1" as never], + importedThreadIds: [ThreadId.makeUnsafe("thread-imported")], + refreshedThreadIds: [], + skippedProviderThreadIds: ["wt-imported"], + failures: [ + { + providerThreadId: "main-new", + message: "boom", + }, + ], + }, + ); + + expect(resultSummary).toEqual({ + imported: [ + { + id: ThreadId.makeUnsafe("thread-imported"), + title: "Imported thread", + detail: "Root Project", + }, + ], + refreshed: [], + skipped: [ + { + id: "wt-imported", + title: "WT imported", + detail: "~/repo/root/worktrees/feature-a", + }, + ], + failures: [ + { + id: "main-new", + title: "Main new", + detail: "~/repo/root", + message: "boom", + }, + ], + createdProjectCount: 1, + }); + }); +}); diff --git a/apps/web/src/routes/_chat.settings.codexImport.ts b/apps/web/src/routes/_chat.settings.codexImport.ts new file mode 100644 index 000000000000..dd49cf8a054c --- /dev/null +++ b/apps/web/src/routes/_chat.settings.codexImport.ts @@ -0,0 +1 @@ +export * from "../lib/codexImport"; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index cc4a39a27212..8d3d0e5446d7 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -600,6 +600,7 @@ function SettingsRouteView() {
+ ); } diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 145d8301ee23..71b34f263e21 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -14,6 +14,7 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), codexThreadId: null, + isImportedCodexThread: false, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", model: "gpt-5-codex", diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 711e993e0fb8..715e0835e35f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -212,7 +212,8 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea const existing = existingThreadById.get(thread.id); return { id: thread.id, - codexThreadId: null, + codexThreadId: thread.external?.providerThreadId ?? null, + isImportedCodexThread: thread.external !== undefined, projectId: thread.projectId, title: thread.title, model: resolveModelSlugForProvider( diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index d5fff12991e5..54b3add605d9 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -86,6 +86,7 @@ export interface Project { export interface Thread { id: ThreadId; codexThreadId: string | null; + isImportedCodexThread: boolean; projectId: ProjectId; title: string; model: string; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 516df6046a50..6c9639b73ada 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -8,6 +8,7 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), codexThreadId: null, + isImportedCodexThread: false, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", model: "gpt-5.3-codex", diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 91e6a61107c3..524b17662362 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -184,6 +184,10 @@ export function createWsNativeApi(): NativeApi { }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), + previewCodexImport: (input) => transport.request(WS_METHODS.serverPreviewCodexImport, input), + importCodexSessions: (input) => + transport.request(WS_METHODS.serverImportCodexSessions, input), + deleteThread: (input) => transport.request(WS_METHODS.serverDeleteThread, input), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), }, orchestration: { diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts index 6083d6735e49..86e68230350d 100644 --- a/apps/web/vitest.browser.config.ts +++ b/apps/web/vitest.browser.config.ts @@ -15,7 +15,7 @@ export default mergeConfig( }, }, test: { - include: ["src/components/ChatView.browser.tsx"], + include: ["src/components/*.browser.tsx"], browser: { enabled: true, provider: playwright(), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index db9bab415ea0..beac1c30655c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -20,7 +20,15 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; -import type { ServerConfig } from "./server"; +import type { + ServerConfig, + ServerDeleteThreadInput, + ServerDeleteThreadResult, + ServerImportCodexSessionsInput, + ServerImportCodexSessionsResult, + ServerPreviewCodexImportInput, + ServerPreviewCodexImportResult, +} from "./server"; import type { TerminalClearInput, TerminalCloseInput, @@ -147,6 +155,13 @@ export interface NativeApi { }; server: { getConfig: () => Promise; + previewCodexImport: ( + input: ServerPreviewCodexImportInput, + ) => Promise; + importCodexSessions: ( + input: ServerImportCodexSessionsInput, + ) => Promise; + deleteThread: (input: ServerDeleteThreadInput) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; }; orchestration: { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e90ddd4b597b..185a7a182184 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -252,6 +252,18 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const OrchestrationExternalThread = Schema.Struct({ + provider: Schema.Literal("codex"), + providerThreadId: TrimmedNonEmptyString, + sourceKind: Schema.Literals(["cli", "vscode"]), + cwd: TrimmedNonEmptyString, + modelProvider: Schema.NullOr(TrimmedNonEmptyString), + remoteUpdatedAt: IsoDateTime, + importedAt: IsoDateTime, + adoptedAt: Schema.NullOr(IsoDateTime), +}); +export type OrchestrationExternalThread = typeof OrchestrationExternalThread.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -274,6 +286,7 @@ export const OrchestrationThread = Schema.Struct({ activities: Schema.Array(OrchestrationThreadActivity), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), + external: Schema.optional(OrchestrationExternalThread), }); export type OrchestrationThread = typeof OrchestrationThread.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f54c..75c0527552f9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; +import { IsoDateTime, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; @@ -69,3 +69,102 @@ export const ServerConfigUpdatedPayload = Schema.Struct({ providers: ServerProviderStatuses, }); export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; + +const OptionalSettingsPath = Schema.String.check(Schema.isMaxLength(4096)); +const CodexImportSourceKind = Schema.Literals(["cli", "vscode"]); +export type CodexImportSourceKind = typeof CodexImportSourceKind.Type; + +const CodexImportState = Schema.Literals([ + "new", + "already-imported", + "continued-in-t3", + "unavailable", +]); +export type CodexImportState = typeof CodexImportState.Type; + +const CodexImportFailure = Schema.Struct({ + providerThreadId: TrimmedNonEmptyString, + message: TrimmedNonEmptyString, +}); +export type CodexImportFailure = typeof CodexImportFailure.Type; + +const CodexImportSessionPreview = Schema.Struct({ + providerThreadId: TrimmedNonEmptyString, + linkedThreadId: Schema.NullOr(ThreadId), + title: Schema.String, + preview: Schema.String, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + sourceKind: CodexImportSourceKind, + modelProvider: Schema.NullOr(TrimmedNonEmptyString), + importState: CodexImportState, + disabledReason: Schema.NullOr(TrimmedNonEmptyString), +}); +export type CodexImportSessionPreview = typeof CodexImportSessionPreview.Type; + +const CodexImportWorktreePreview = Schema.Struct({ + cwd: TrimmedNonEmptyString, + displayCwd: TrimmedNonEmptyString, + cwdExists: Schema.Boolean, + sessions: Schema.Array(CodexImportSessionPreview), +}); +export type CodexImportWorktreePreview = typeof CodexImportWorktreePreview.Type; + +const CodexImportGroupPreview = Schema.Struct({ + cwd: TrimmedNonEmptyString, + displayCwd: TrimmedNonEmptyString, + cwdExists: Schema.Boolean, + existingProjectId: Schema.NullOr(ProjectId), + existingProjectTitle: Schema.NullOr(TrimmedNonEmptyString), + suggestedProjectTitle: TrimmedNonEmptyString, + mainSessions: Schema.Array(CodexImportSessionPreview), + worktrees: Schema.Array(CodexImportWorktreePreview), +}); +export type CodexImportGroupPreview = typeof CodexImportGroupPreview.Type; + +export const ServerPreviewCodexImportInput = Schema.Struct({ + codexBinaryPath: Schema.optional(OptionalSettingsPath), + codexHomePath: Schema.optional(OptionalSettingsPath), +}); +export type ServerPreviewCodexImportInput = typeof ServerPreviewCodexImportInput.Type; + +export const ServerPreviewCodexImportResult = Schema.Struct({ + groups: Schema.Array(CodexImportGroupPreview), +}); +export type ServerPreviewCodexImportResult = typeof ServerPreviewCodexImportResult.Type; + +const CodexImportSelection = Schema.Struct({ + cwd: TrimmedNonEmptyString, + projectId: Schema.NullOr(ProjectId), + createProject: Schema.Boolean, + projectTitle: TrimmedNonEmptyString, + providerThreadIds: Schema.Array(TrimmedNonEmptyString), +}); +export type CodexImportSelection = typeof CodexImportSelection.Type; + +export const ServerImportCodexSessionsInput = Schema.Struct({ + codexBinaryPath: Schema.optional(OptionalSettingsPath), + codexHomePath: Schema.optional(OptionalSettingsPath), + selections: Schema.Array(CodexImportSelection), +}); +export type ServerImportCodexSessionsInput = typeof ServerImportCodexSessionsInput.Type; + +export const ServerImportCodexSessionsResult = Schema.Struct({ + createdProjectIds: Schema.Array(ProjectId), + importedThreadIds: Schema.Array(ThreadId), + refreshedThreadIds: Schema.Array(ThreadId), + skippedProviderThreadIds: Schema.Array(TrimmedNonEmptyString), + failures: Schema.Array(CodexImportFailure), +}); +export type ServerImportCodexSessionsResult = typeof ServerImportCodexSessionsResult.Type; + +export const ServerDeleteThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ServerDeleteThreadInput = typeof ServerDeleteThreadInput.Type; + +export const ServerDeleteThreadResult = Schema.Struct({ + sequence: Schema.Number, + archivedExternal: Schema.Boolean, +}); +export type ServerDeleteThreadResult = typeof ServerDeleteThreadResult.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 1100b4f9df5a..f5fa9da4f260 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -31,6 +31,11 @@ import { import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; +import { + ServerDeleteThreadInput, + ServerImportCodexSessionsInput, + ServerPreviewCodexImportInput, +} from "./server"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -66,6 +71,9 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", + serverPreviewCodexImport: "server.previewCodexImport", + serverImportCodexSessions: "server.importCodexSessions", + serverDeleteThread: "server.deleteThread", serverUpsertKeybinding: "server.upsertKeybinding", } as const; @@ -128,6 +136,9 @@ const WebSocketRequestBody = Schema.Union([ // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverPreviewCodexImport, ServerPreviewCodexImportInput), + tagRequestBody(WS_METHODS.serverImportCodexSessions, ServerImportCodexSessionsInput), + tagRequestBody(WS_METHODS.serverDeleteThread, ServerDeleteThreadInput), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), ]);