diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d591dba4..6be90734 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -59,6 +59,11 @@ export interface WorkspaceDeleteResponse { status: WorkspaceStatus } +export interface WorkspaceSessionExportResponse { + info: Record + messages: unknown[] +} + export type WorktreeKind = "root" | "worktree" export interface WorktreeDescriptor { diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 517367f5..8318a922 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -1,3 +1,4 @@ +import { createReadStream } from "fs" import { FastifyInstance, FastifyReply } from "fastify" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" @@ -102,6 +103,77 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { reply.code(204) }) + app.get<{ + Params: { id: string; slug: string; sessionId: string } + }>("/api/workspaces/:id/worktrees/:slug/sessions/:sessionId/export", async (request, reply) => { + const controller = new AbortController() + const handleAbort = () => controller.abort() + request.raw.on("close", handleAbort) + request.raw.on("error", handleAbort) + + let exportFile: Awaited> | null = null + let cleanedUp = false + + const cleanup = async () => { + if (cleanedUp) return + cleanedUp = true + request.raw.off("close", handleAbort) + request.raw.off("error", handleAbort) + reply.raw.off("close", handleReplyDone) + reply.raw.off("finish", handleReplyDone) + await exportFile?.cleanup().catch(() => undefined) + } + + const handleReplyDone = () => { + void cleanup() + } + + reply.raw.on("close", handleReplyDone) + reply.raw.on("finish", handleReplyDone) + + try { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + await cleanup() + reply.code(404) + return { error: "Workspace not found" } + } + + const directory = await resolveWorktreeDirectory({ + workspaceId: workspace.id, + workspacePath: workspace.path, + worktreeSlug: request.params.slug, + logger: request.log, + }) + if (!directory) { + await cleanup() + reply.code(404) + return { error: "Worktree not found" } + } + + exportFile = await deps.workspaceManager.exportSessionDataToFile(request.params.id, request.params.sessionId, { + signal: controller.signal, + directory, + }) + const stream = createReadStream(exportFile.filePath) + stream.on("error", () => { + void cleanup() + }) + reply.type("application/json; charset=utf-8") + return reply.send(stream) + } catch (error) { + await cleanup() + if (request.raw.destroyed || controller.signal.aborted) { + return + } + if (error instanceof Error && error.message.includes("Session export timed out")) { + reply.code(504) + return { error: error.message } + } + return handleWorkspaceError(error, reply) + } + }) + app.get<{ Params: { id: string } Querystring: { path?: string } diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index cd1933b2..7cadcbac 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,6 +1,9 @@ +import { createWriteStream, promises as fsp } from "fs" +import os from "os" import path from "path" -import { spawnSync } from "child_process" +import { spawn, spawnSync } from "child_process" import { connect } from "net" +import { finished } from "stream/promises" import { EventBus } from "../events/bus" import type { SettingsService } from "../settings/service" import type { BinaryResolver } from "../settings/binaries" @@ -22,8 +25,15 @@ import { OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth" +import { buildSpawnSpec } from "./spawn" const STARTUP_STABILITY_DELAY_MS = 1500 +const SESSION_EXPORT_TIMEOUT_MS = 5 * 60_000 + +interface SessionExportFile { + filePath: string + cleanup: () => Promise +} interface WorkspaceManagerOptions { rootDir: string @@ -95,6 +105,124 @@ export class WorkspaceManager { browser.writeFile(relativePath, contents) } + async exportSessionDataToFile( + workspaceId: string, + sessionId: string, + options?: { signal?: AbortSignal; directory?: string }, + ): Promise { + const workspace = this.requireWorkspace(workspaceId) + const normalizedSessionId = sessionId.trim() + if (!normalizedSessionId) { + throw new Error("Session ID is required") + } + + const serverConfig = this.options.settings.getOwner("config", "server") + const envVars = (serverConfig as any)?.environmentVariables + const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} + const opencodeConfigContent = buildOpencodeConfigContent( + resolveExistingOpencodeConfigContent(userEnvironment), + this.codeNomadPluginUrl, + ) + + const environment = { + ...process.env, + ...userEnvironment, + OPENCODE_CONFIG_CONTENT: opencodeConfigContent, + } + + const spec = buildSpawnSpec(workspace.binaryId, ["export", normalizedSessionId], { + cwd: options?.directory ?? workspace.path, + env: environment, + propagateEnvKeys: Object.keys(userEnvironment), + }) + + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codenomad-session-export-")) + const filePath = path.join(tempDir, `${normalizedSessionId}.json`) + + const cleanup = async () => { + await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + } + + try { + return await new Promise((resolve, reject) => { + let settled = false + let timedOut = false + const controller = new AbortController() + const output = createWriteStream(filePath, { encoding: "utf8" }) + const timeout = setTimeout(() => { + timedOut = true + controller.abort() + }, SESSION_EXPORT_TIMEOUT_MS) + + const finish = async (result: { file?: SessionExportFile; error?: Error }) => { + if (settled) return + settled = true + clearTimeout(timeout) + options?.signal?.removeEventListener("abort", handleAbort) + + if (result.error) { + output.destroy() + await cleanup() + reject(result.error) + return + } + + resolve(result.file!) + } + + const handleAbort = () => { + controller.abort() + } + + options?.signal?.addEventListener("abort", handleAbort) + + const child = spawn(spec.command, spec.args, { + cwd: spec.cwd, + env: spec.env, + stdio: ["ignore", "pipe", "pipe"], + signal: controller.signal, + ...spec.options, + }) + + const stderrChunks: Buffer[] = [] + + child.stdout?.pipe(output) + output.on("error", (error) => { + void finish({ error: error instanceof Error ? error : new Error(String(error)) }) + }) + child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)) + child.on("error", async (error) => { + const abortReason = options?.signal?.aborted + ? new Error("Session export aborted") + : timedOut + ? new Error(`Session export timed out after ${SESSION_EXPORT_TIMEOUT_MS}ms`) + : error + await finish({ error: abortReason }) + }) + child.on("exit", (code) => { + void (async () => { + if (code !== 0) { + const stderr = Buffer.concat(stderrChunks).toString("utf8").trim() + const errorMessage = stderr || `opencode export exited with code ${code}` + await finish({ error: new Error(errorMessage) }) + return + } + + try { + await finished(output) + await finish({ file: { filePath, cleanup } }) + } catch (error) { + await finish({ error: error instanceof Error ? error : new Error(String(error)) }) + } + })() + }) + }) + } catch (error) { + await cleanup() + throw error + } + } + async create(folder: string, name?: string): Promise { const id = `${Date.now().toString(36)}` diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index e9acec27..1145f59e 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -8,6 +8,7 @@ import type { FileSystemFileContentResponse, FileSystemListResponse, InstanceData, + WorkspaceSessionExportResponse, SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse, @@ -215,6 +216,10 @@ export const serverApi = { return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`) }, + exportSessionData(id: string, slug: string, sessionId: string): Promise { + return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/sessions/${encodeURIComponent(sessionId)}/export`) + }, + writeWorktreeMap(id: string, map: WorktreeMap): Promise { return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, { method: "PUT", diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 36609380..edcd4e82 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -49,6 +49,7 @@ import { removeParentSessionMapping, setWorktreeSlugForParentSession, } from "./worktrees" +import { fetchSessionMessages } from "./session-message-source" const log = getLogger("api") @@ -760,10 +761,7 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const apiMessages = await requestData( - client.session.messages({ sessionID: sessionId }), - "session.messages", - ) + const apiMessages = await fetchSessionMessages(instanceId, sessionId, worktreeSlug, client) if (!Array.isArray(apiMessages)) { return diff --git a/packages/ui/src/stores/session-message-fallback.test.ts b/packages/ui/src/stores/session-message-fallback.test.ts new file mode 100644 index 00000000..cab5af4d --- /dev/null +++ b/packages/ui/src/stores/session-message-fallback.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { OpencodeApiError } from "../lib/opencode-api.js" +import { getExportedSessionMessages, isLegacyMissingAgentValidationError } from "./session-message-fallback.js" + +describe("isLegacyMissingAgentValidationError", () => { + it("matches the legacy missing-agent validation error", () => { + const error = new OpencodeApiError("session.messages failed", { + cause: { + name: "BadRequest", + data: { + kind: "Body", + message: 'Missing key\n at [1]["info"]["agent"]', + }, + }, + }) + + assert.equal(isLegacyMissingAgentValidationError(error), true) + }) + + it("ignores unrelated missing-key validation failures", () => { + const error = new OpencodeApiError("session.messages failed", { + cause: { + name: "BadRequest", + data: { + kind: "Body", + message: 'Missing key\n at [1]["info"]["model"]', + }, + }, + }) + + assert.equal(isLegacyMissingAgentValidationError(error), false) + }) + + it("throws when the export response does not contain a messages array", () => { + assert.throws(() => getExportedSessionMessages({ info: {}, messages: null as any }), /messages array/) + }) +}) diff --git a/packages/ui/src/stores/session-message-fallback.ts b/packages/ui/src/stores/session-message-fallback.ts new file mode 100644 index 00000000..91b5b858 --- /dev/null +++ b/packages/ui/src/stores/session-message-fallback.ts @@ -0,0 +1,22 @@ +import { OpencodeApiError } from "../lib/opencode-api" +import type { WorkspaceSessionExportResponse } from "../../../server/src/api-types" + +export function isLegacyMissingAgentValidationError(error: unknown): boolean { + if (!(error instanceof OpencodeApiError)) { + return false + } + + const cause = (error as any).cause + const causeName = cause && typeof cause === "object" ? (cause as any).name : undefined + const causeData = cause && typeof cause === "object" ? (cause as any).data : undefined + const message = typeof causeData?.message === "string" ? causeData.message : "" + return causeName === "BadRequest" && causeData?.kind === "Body" && /\["info"\]\["agent"\]/.test(message) +} + +export function getExportedSessionMessages(exported: WorkspaceSessionExportResponse): unknown[] { + if (!exported || !Array.isArray(exported.messages)) { + throw new Error("Legacy session export did not return a messages array") + } + + return exported.messages +} diff --git a/packages/ui/src/stores/session-message-source.ts b/packages/ui/src/stores/session-message-source.ts new file mode 100644 index 00000000..3d9517ad --- /dev/null +++ b/packages/ui/src/stores/session-message-source.ts @@ -0,0 +1,30 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" + +import { serverApi } from "../lib/api-client" +import { requestData } from "../lib/opencode-api" +import { getLogger } from "../lib/logger" +import { getExportedSessionMessages, isLegacyMissingAgentValidationError } from "./session-message-fallback" + +const log = getLogger("api") + +export async function fetchSessionMessages( + instanceId: string, + sessionId: string, + worktreeSlug: string, + client: OpencodeClient, +): Promise { + try { + return await requestData( + client.session.messages({ sessionID: sessionId }), + "session.messages", + ) + } catch (error) { + if (!isLegacyMissingAgentValidationError(error)) { + throw error + } + + log.warn("Falling back to opencode export for malformed session messages", { instanceId, sessionId, error }) + const exported = await serverApi.exportSessionData(instanceId, worktreeSlug, sessionId) + return getExportedSessionMessages(exported) as any[] + } +} diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 726f7cb8..fa44891b 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -7,10 +7,10 @@ import { messageStoreBus } from "./message-v2/bus" import { instances } from "./instances" import { showConfirmDialog } from "./alerts" import { getLogger } from "../lib/logger" -import { requestData } from "../lib/opencode-api" import { getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees" import { tGlobal } from "../lib/i18n" import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals" +import { fetchSessionMessages } from "./session-message-source" const log = getLogger("session") @@ -691,14 +691,11 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede return isFreshSession } let messages: any[] = [] - try { - const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id) - const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) - messages = await requestData( - client.session.messages({ sessionID: session.id }), - "session.messages", - ) - } catch (error) { + try { + const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id) + const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) + messages = await fetchSessionMessages(instanceId, session.id, worktreeSlug, client) + } catch (error) { log.error(`Failed to fetch messages for session ${session.id}`, error) return isFreshSession }