From 7f43c9efbc9cb46e53d3189619c5926b036c9da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 14 May 2026 17:17:38 +0200 Subject: [PATCH 1/3] fix(ui): recover legacy sessions with export fallback Some historical OpenCode sessions still appear in session.list but fail to open in CodeNomad because the strict v2 session.messages response validation rejects transcripts that are missing assistant metadata such as info.agent. This left the session visible in the UI while the transcript pane stayed empty. When that specific schema-validation failure happens, CodeNomad now falls back to an opencode export for the affected session and hydrates the transcript from the CLI-compatible export payload instead of abandoning the load. The fallback is scoped to malformed legacy transcripts so normal session loads still use the standard API path. Validation: npm run typecheck --workspace @codenomad/ui; npm run build --workspace @codenomad/tauri-app. Note: npm run typecheck --workspace @neuralnomads/codenomad currently fails on this origin/dev base because of unrelated server dependency/type drift. --- packages/server/src/api-types.ts | 5 ++ .../server/src/server/routes/workspaces.ts | 10 +++ packages/server/src/workspaces/manager.ts | 72 ++++++++++++++++++- packages/ui/src/lib/api-client.ts | 5 ++ packages/ui/src/stores/session-api.ts | 6 +- .../ui/src/stores/session-message-source.ts | 35 +++++++++ packages/ui/src/stores/session-state.ts | 15 ++-- 7 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/stores/session-message-source.ts 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..461a2b9c 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -102,6 +102,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { reply.code(204) }) + app.get<{ + Params: { id: string; sessionId: string } + }>("/api/workspaces/:id/sessions/:sessionId/export", async (request, reply) => { + try { + return await deps.workspaceManager.exportSessionData(request.params.id, request.params.sessionId) + } catch (error) { + 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..fae77fd9 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,5 +1,5 @@ import path from "path" -import { spawnSync } from "child_process" +import { spawn, spawnSync } from "child_process" import { connect } from "net" import { EventBus } from "../events/bus" import type { SettingsService } from "../settings/service" @@ -7,7 +7,7 @@ import type { BinaryResolver } from "../settings/binaries" import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" -import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" +import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry, WorkspaceSessionExportResponse } from "../api-types" import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" import { @@ -22,6 +22,7 @@ import { OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth" +import { buildSpawnSpec } from "./spawn" const STARTUP_STABILITY_DELAY_MS = 1500 @@ -95,6 +96,73 @@ export class WorkspaceManager { browser.writeFile(relativePath, contents) } + async exportSessionData(workspaceId: string, sessionId: 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: workspace.path, + env: environment, + propagateEnvKeys: Object.keys(userEnvironment), + }) + + return await new Promise((resolve, reject) => { + const child = spawn(spec.command, spec.args, { + cwd: spec.cwd, + env: spec.env, + stdio: ["ignore", "pipe", "pipe"], + ...spec.options, + }) + + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + + child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)) + child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)) + child.on("error", (error) => reject(error)) + child.on("exit", (code) => { + if (code !== 0) { + const stderr = Buffer.concat(stderrChunks).toString("utf8").trim() + const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim() + reject(new Error(stderr || stdout || `opencode export exited with code ${code}`)) + return + } + + try { + const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim() + if (!stdout) { + throw new Error("opencode export returned empty output") + } + + const parsed = JSON.parse(stdout) as { info?: Record; messages?: unknown[] } + resolve({ + info: parsed.info && typeof parsed.info === "object" ? parsed.info : {}, + messages: Array.isArray(parsed.messages) ? parsed.messages : [], + }) + } catch (error) { + reject(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..26f4d892 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, sessionId: string): Promise { + return request(`/api/workspaces/${encodeURIComponent(id)}/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..da58474c 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, client) if (!Array.isArray(apiMessages)) { return 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..2d020130 --- /dev/null +++ b/packages/ui/src/stores/session-message-source.ts @@ -0,0 +1,35 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" + +import { serverApi } from "../lib/api-client" +import { OpencodeApiError, requestData } from "../lib/opencode-api" +import { getLogger } from "../lib/logger" + +const log = getLogger("api") + +function shouldFallbackToSessionExport(error: unknown): boolean { + if (!(error instanceof OpencodeApiError)) { + return false + } + + const cause = (error as any).cause + const causeData = cause && typeof cause === "object" ? (cause as any).data : undefined + const message = typeof causeData?.message === "string" ? causeData.message : "" + return causeData?.kind === "Body" && message.includes("Missing key") +} + +export async function fetchSessionMessages(instanceId: string, sessionId: string, client: OpencodeClient): Promise { + try { + return await requestData( + client.session.messages({ sessionID: sessionId }), + "session.messages", + ) + } catch (error) { + if (!shouldFallbackToSessionExport(error)) { + throw error + } + + log.warn("Falling back to opencode export for malformed session messages", { instanceId, sessionId, error }) + const exported = await serverApi.exportSessionData(instanceId, sessionId) + return Array.isArray(exported.messages) ? exported.messages : [] + } +} diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 726f7cb8..9f79ee3a 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, client) + } catch (error) { log.error(`Failed to fetch messages for session ${session.id}`, error) return isFreshSession } From 838f0b75c4ab25ca31087bb028bb3d52b3ee4eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 14 May 2026 17:39:36 +0200 Subject: [PATCH 2/3] fix(ui): harden legacy session export recovery The initial legacy-session recovery path worked, but it was still too broad and too heavy for very large transcripts. Tighten the fallback to the exact missing-agent validation signature, stream export output through a temp file instead of buffering the full payload in memory, and abort stalled exports so the recovery path cannot hang indefinitely. Also add focused coverage for the legacy error matcher so unrelated schema regressions keep surfacing normally instead of being hidden behind the fallback. --- .../server/src/server/routes/workspaces.ts | 44 +++++- packages/server/src/workspaces/manager.ts | 128 +++++++++++++----- .../stores/session-message-fallback.test.ts | 35 +++++ .../ui/src/stores/session-message-fallback.ts | 13 ++ .../ui/src/stores/session-message-source.ts | 16 +-- 5 files changed, 188 insertions(+), 48 deletions(-) create mode 100644 packages/ui/src/stores/session-message-fallback.test.ts create mode 100644 packages/ui/src/stores/session-message-fallback.ts diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 461a2b9c..81d68a14 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" @@ -105,9 +106,50 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { app.get<{ Params: { id: string; sessionId: string } }>("/api/workspaces/:id/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 { - return await deps.workspaceManager.exportSessionData(request.params.id, request.params.sessionId) + exportFile = await deps.workspaceManager.exportSessionDataToFile(request.params.id, request.params.sessionId, { + signal: controller.signal, + }) + 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) } }) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index fae77fd9..966f87ac 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1,13 +1,16 @@ +import { createWriteStream, promises as fsp } from "fs" +import os from "os" import path from "path" 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" import { FileSystemBrowser } from "../filesystem/browser" import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search" import { clearWorkspaceSearchCache } from "../filesystem/search-cache" -import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry, WorkspaceSessionExportResponse } from "../api-types" +import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" import { @@ -25,6 +28,12 @@ import { 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 @@ -96,7 +105,11 @@ export class WorkspaceManager { browser.writeFile(relativePath, contents) } - async exportSessionData(workspaceId: string, sessionId: string): Promise { + async exportSessionDataToFile( + workspaceId: string, + sessionId: string, + options?: { signal?: AbortSignal }, + ): Promise { const workspace = this.requireWorkspace(workspaceId) const normalizedSessionId = sessionId.trim() if (!normalizedSessionId) { @@ -123,44 +136,91 @@ export class WorkspaceManager { propagateEnvKeys: Object.keys(userEnvironment), }) - return await new Promise((resolve, reject) => { - const child = spawn(spec.command, spec.args, { - cwd: spec.cwd, - env: spec.env, - stdio: ["ignore", "pipe", "pipe"], - ...spec.options, - }) + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "codenomad-session-export-")) + const filePath = path.join(tempDir, `${normalizedSessionId}.json`) - const stdoutChunks: Buffer[] = [] - const stderrChunks: Buffer[] = [] - - child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)) - child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)) - child.on("error", (error) => reject(error)) - child.on("exit", (code) => { - if (code !== 0) { - const stderr = Buffer.concat(stderrChunks).toString("utf8").trim() - const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim() - reject(new Error(stderr || stdout || `opencode export exited with code ${code}`)) - return - } + const cleanup = async () => { + await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => undefined) + } - try { - const stdout = Buffer.concat(stdoutChunks).toString("utf8").trim() - if (!stdout) { - throw new Error("opencode export returned empty output") + 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 } - const parsed = JSON.parse(stdout) as { info?: Record; messages?: unknown[] } - resolve({ - info: parsed.info && typeof parsed.info === "object" ? parsed.info : {}, - messages: Array.isArray(parsed.messages) ? parsed.messages : [], - }) - } catch (error) { - reject(error) + 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 { 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..6fefd317 --- /dev/null +++ b/packages/ui/src/stores/session-message-fallback.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { OpencodeApiError } from "../lib/opencode-api.js" +import { 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) + }) +}) 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..e48e0d65 --- /dev/null +++ b/packages/ui/src/stores/session-message-fallback.ts @@ -0,0 +1,13 @@ +import { OpencodeApiError } from "../lib/opencode-api" + +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) +} diff --git a/packages/ui/src/stores/session-message-source.ts b/packages/ui/src/stores/session-message-source.ts index 2d020130..78818dbc 100644 --- a/packages/ui/src/stores/session-message-source.ts +++ b/packages/ui/src/stores/session-message-source.ts @@ -1,22 +1,12 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { serverApi } from "../lib/api-client" -import { OpencodeApiError, requestData } from "../lib/opencode-api" +import { requestData } from "../lib/opencode-api" import { getLogger } from "../lib/logger" +import { isLegacyMissingAgentValidationError } from "./session-message-fallback" const log = getLogger("api") -function shouldFallbackToSessionExport(error: unknown): boolean { - if (!(error instanceof OpencodeApiError)) { - return false - } - - const cause = (error as any).cause - const causeData = cause && typeof cause === "object" ? (cause as any).data : undefined - const message = typeof causeData?.message === "string" ? causeData.message : "" - return causeData?.kind === "Body" && message.includes("Missing key") -} - export async function fetchSessionMessages(instanceId: string, sessionId: string, client: OpencodeClient): Promise { try { return await requestData( @@ -24,7 +14,7 @@ export async function fetchSessionMessages(instanceId: string, sessionId: string "session.messages", ) } catch (error) { - if (!shouldFallbackToSessionExport(error)) { + if (!isLegacyMissingAgentValidationError(error)) { throw error } From f3f5a22d337bda20e7e6c4f82e68d3d07ba7ee22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 14 May 2026 18:00:24 +0200 Subject: [PATCH 3/3] fix(ui): preserve worktree context in legacy session recovery The legacy-session export fallback was correctly recovering malformed root transcripts, but it could still read the wrong session data for worktree-backed sessions because the recovery export always ran from the workspace root. Make the fallback follow the same worktree context as the normal transcript request so legacy worktree sessions resolve against the correct directory. Tighten the recovery path further by failing hard when the export payload does not contain a transcript array instead of silently presenting an empty session, and keep the matcher/tests focused on the reproduced missing-agent validation failure. Validation: npx tsx --test packages/ui/src/stores/session-message-fallback.test.ts; npm run typecheck --workspace @codenomad/ui; npm run build --workspace @codenomad/tauri-app --- .../server/src/server/routes/workspaces.ts | 24 +++++++++++++++++-- packages/server/src/workspaces/manager.ts | 4 ++-- packages/ui/src/lib/api-client.ts | 4 ++-- packages/ui/src/stores/session-api.ts | 2 +- .../stores/session-message-fallback.test.ts | 6 ++++- .../ui/src/stores/session-message-fallback.ts | 9 +++++++ .../ui/src/stores/session-message-source.ts | 13 ++++++---- packages/ui/src/stores/session-state.ts | 2 +- 8 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index 81d68a14..8318a922 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -104,8 +104,8 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { }) app.get<{ - Params: { id: string; sessionId: string } - }>("/api/workspaces/:id/sessions/:sessionId/export", async (request, reply) => { + 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) @@ -132,8 +132,28 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { 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", () => { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 966f87ac..7cadcbac 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -108,7 +108,7 @@ export class WorkspaceManager { async exportSessionDataToFile( workspaceId: string, sessionId: string, - options?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal; directory?: string }, ): Promise { const workspace = this.requireWorkspace(workspaceId) const normalizedSessionId = sessionId.trim() @@ -131,7 +131,7 @@ export class WorkspaceManager { } const spec = buildSpawnSpec(workspace.binaryId, ["export", normalizedSessionId], { - cwd: workspace.path, + cwd: options?.directory ?? workspace.path, env: environment, propagateEnvKeys: Object.keys(userEnvironment), }) diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 26f4d892..1145f59e 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -216,8 +216,8 @@ export const serverApi = { return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`) }, - exportSessionData(id: string, sessionId: string): Promise { - return request(`/api/workspaces/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/export`) + 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 { diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index da58474c..edcd4e82 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -761,7 +761,7 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const apiMessages = await fetchSessionMessages(instanceId, sessionId, client) + 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 index 6fefd317..cab5af4d 100644 --- a/packages/ui/src/stores/session-message-fallback.test.ts +++ b/packages/ui/src/stores/session-message-fallback.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { OpencodeApiError } from "../lib/opencode-api.js" -import { isLegacyMissingAgentValidationError } from "./session-message-fallback.js" +import { getExportedSessionMessages, isLegacyMissingAgentValidationError } from "./session-message-fallback.js" describe("isLegacyMissingAgentValidationError", () => { it("matches the legacy missing-agent validation error", () => { @@ -32,4 +32,8 @@ describe("isLegacyMissingAgentValidationError", () => { 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 index e48e0d65..91b5b858 100644 --- a/packages/ui/src/stores/session-message-fallback.ts +++ b/packages/ui/src/stores/session-message-fallback.ts @@ -1,4 +1,5 @@ import { OpencodeApiError } from "../lib/opencode-api" +import type { WorkspaceSessionExportResponse } from "../../../server/src/api-types" export function isLegacyMissingAgentValidationError(error: unknown): boolean { if (!(error instanceof OpencodeApiError)) { @@ -11,3 +12,11 @@ export function isLegacyMissingAgentValidationError(error: unknown): boolean { 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 index 78818dbc..3d9517ad 100644 --- a/packages/ui/src/stores/session-message-source.ts +++ b/packages/ui/src/stores/session-message-source.ts @@ -3,11 +3,16 @@ 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 { isLegacyMissingAgentValidationError } from "./session-message-fallback" +import { getExportedSessionMessages, isLegacyMissingAgentValidationError } from "./session-message-fallback" const log = getLogger("api") -export async function fetchSessionMessages(instanceId: string, sessionId: string, client: OpencodeClient): Promise { +export async function fetchSessionMessages( + instanceId: string, + sessionId: string, + worktreeSlug: string, + client: OpencodeClient, +): Promise { try { return await requestData( client.session.messages({ sessionID: sessionId }), @@ -19,7 +24,7 @@ export async function fetchSessionMessages(instanceId: string, sessionId: string } log.warn("Falling back to opencode export for malformed session messages", { instanceId, sessionId, error }) - const exported = await serverApi.exportSessionData(instanceId, sessionId) - return Array.isArray(exported.messages) ? exported.messages : [] + 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 9f79ee3a..fa44891b 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -694,7 +694,7 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede try { const worktreeSlug = getWorktreeSlugForSession(instanceId, session.id) const client = getOrCreateWorktreeClient(instanceId, worktreeSlug) - messages = await fetchSessionMessages(instanceId, session.id, client) + messages = await fetchSessionMessages(instanceId, session.id, worktreeSlug, client) } catch (error) { log.error(`Failed to fetch messages for session ${session.id}`, error) return isFreshSession