Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export interface WorkspaceDeleteResponse {
status: WorkspaceStatus
}

export interface WorkspaceSessionExportResponse {
info: Record<string, unknown>
messages: unknown[]
}

export type WorktreeKind = "root" | "worktree"

export interface WorktreeDescriptor {
Expand Down
72 changes: 72 additions & 0 deletions packages/server/src/server/routes/workspaces.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createReadStream } from "fs"
import { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
Expand Down Expand Up @@ -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<ReturnType<WorkspaceManager["exportSessionDataToFile"]>> | 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 }
Expand Down
130 changes: 129 additions & 1 deletion packages/server/src/workspaces/manager.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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<void>
}

interface WorkspaceManagerOptions {
rootDir: string
Expand Down Expand Up @@ -95,6 +105,124 @@ export class WorkspaceManager {
browser.writeFile(relativePath, contents)
}

async exportSessionDataToFile(
workspaceId: string,
sessionId: string,
options?: { signal?: AbortSignal; directory?: string },
): Promise<SessionExportFile> {
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<SessionExportFile>((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<WorkspaceDescriptor> {

const id = `${Date.now().toString(36)}`
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
FileSystemFileContentResponse,
FileSystemListResponse,
InstanceData,
WorkspaceSessionExportResponse,
SpeechCapabilitiesResponse,
SpeechSynthesisResponse,
SpeechTranscriptionResponse,
Expand Down Expand Up @@ -215,6 +216,10 @@ export const serverApi = {
return request<WorktreeMap>(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`)
},

exportSessionData(id: string, slug: string, sessionId: string): Promise<WorkspaceSessionExportResponse> {
return request<WorkspaceSessionExportResponse>(`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/sessions/${encodeURIComponent(sessionId)}/export`)
},

writeWorktreeMap(id: string, map: WorktreeMap): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}/worktrees/map`, {
method: "PUT",
Expand Down
6 changes: 2 additions & 4 deletions packages/ui/src/stores/session-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
removeParentSessionMapping,
setWorktreeSlugForParentSession,
} from "./worktrees"
import { fetchSessionMessages } from "./session-message-source"

const log = getLogger("api")

Expand Down Expand Up @@ -760,10 +761,7 @@ async function loadMessages(

try {
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
const apiMessages = await requestData<any[]>(
client.session.messages({ sessionID: sessionId }),
"session.messages",
)
const apiMessages = await fetchSessionMessages(instanceId, sessionId, worktreeSlug, client)

if (!Array.isArray(apiMessages)) {
return
Expand Down
39 changes: 39 additions & 0 deletions packages/ui/src/stores/session-message-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
22 changes: 22 additions & 0 deletions packages/ui/src/stores/session-message-fallback.ts
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 30 additions & 0 deletions packages/ui/src/stores/session-message-source.ts
Original file line number Diff line number Diff line change
@@ -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<any[]> {
try {
return await requestData<any[]>(
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[]
}
}
Loading
Loading