diff --git a/src/main/native-chat/transcript-opencode-row-budget.ts b/src/main/native-chat/transcript-opencode-row-budget.ts new file mode 100644 index 00000000000..1060ebe1277 --- /dev/null +++ b/src/main/native-chat/transcript-opencode-row-budget.ts @@ -0,0 +1,29 @@ +import type SyncDatabase from '../sqlite/sync-database' + +type BindValue = SyncDatabase.BindValue +type SqliteStatement = SyncDatabase.Statement + +// Every transcript query reads within one row and byte budget so a corrupt store cannot exhaust memory. +export function rowsOf(statement: SqliteStatement, ...params: BindValue[]): T[] { + return rowsWithinBudget(statement, { bytes: 0 }, ...params) +} + +export function rowsWithinBudget( + statement: SqliteStatement, + budget: { bytes: number }, + ...params: BindValue[] +): T[] { + const rows: ReturnType = [] + for (const row of statement.iterate(...params)) { + budget.bytes += Object.values(row).reduce( + (size, value) => size + (typeof value === 'string' ? Buffer.byteLength(value) : 0), + 0 + ) + if (rows.length >= 10000 || budget.bytes > 16 * 1024 * 1024) { + throw new Error('OpenCode transcript query exceeds its read limit') + } + rows.push(row) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each caller names the columns projected by its internal SQL query. + return rows as T[] +} diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index 3f207216698..121f8721c9f 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -10,9 +10,7 @@ import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-pa import { columnExists, tableExists } from '../opencode-usage/schema-helpers' import { asRecord, extractString, parseJsonObject } from '../ai-vault/session-scanner-values' import type SyncDatabase from '../sqlite/sync-database' - -type BindValue = SyncDatabase.BindValue -type SqliteStatement = SyncDatabase.Statement +import { rowsOf, rowsWithinBudget } from './transcript-opencode-row-budget' import { opencodeMessages, OPENCODE_TRANSCRIPT_MAX_ROW_BYTES @@ -48,30 +46,6 @@ function sessionExists(db: SyncDatabase, sessionId: string): boolean { ) } -function rowsOf(statement: SqliteStatement, ...params: BindValue[]): T[] { - return rowsWithinBudget(statement, { bytes: 0 }, ...params) -} - -function rowsWithinBudget( - statement: SqliteStatement, - budget: { bytes: number }, - ...params: BindValue[] -): T[] { - const rows: ReturnType = [] - for (const row of statement.iterate(...params)) { - budget.bytes += Object.values(row).reduce( - (size, value) => size + (typeof value === 'string' ? Buffer.byteLength(value) : 0), - 0 - ) - if (rows.length >= 10000 || budget.bytes > 16 * 1024 * 1024) { - throw new Error('OpenCode transcript query exceeds its read limit') - } - rows.push(row) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each caller names the columns projected by its internal SQL query. - return rows as T[] -} - export function readOpenCodeTranscriptSignal( dbPath: string, sessionId: string, @@ -117,7 +91,8 @@ export function readOpenCodeTranscriptSignal( return { cwd: columnExists(db, 'session', 'directory') ? extractString( - asRecord(db.prepare('SELECT directory FROM session WHERE id = ?').get(sessionId))?.directory + asRecord(db.prepare('SELECT directory FROM session WHERE id = ?').get(sessionId)) + ?.directory ) : null, messageCount: messageRow?.message_count ?? 0, @@ -303,7 +278,10 @@ function mapMessageRows( ? [{ message_id: row.id, time_updated: row.time_updated, data: null }] : partList ) - const updated = partList.reduce((max, row) => (row.time_updated > max ? row.time_updated : max), 0) + const updated = partList.reduce( + (max, row) => (row.time_updated > max ? row.time_updated : max), + 0 + ) for (const message of messages.toReversed()) { items.push({ rowid: row.message_rowid, diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index 2220e7499a0..112c0270fc9 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -10,6 +10,7 @@ import { resolveCommittedStructuredAgentSessionAdoptionIntent, requireUnownedStructuredAgentSessionAdoption, resolveStructuredAgentSessionAdoptionForCreate, + structuredAgentSessionAdoptAttach, type StructuredAgentSessionCreateIntentInput } from '../structured-agent-session-create-adoption' import { isNativeLocalLocation, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' @@ -161,7 +162,8 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte selfSessionId: input.envelope.sessionId }) } - const structuredAgentCliSourceEvidence = input.structuredAgentCliSourceEvidence ?? + const structuredAgentCliSourceEvidence = + input.structuredAgentCliSourceEvidence ?? (input.resumeFrom?.cliSource ? captureStructuredAgentCliSource(() => this.getAgentProviderSessionRows()) : undefined) @@ -241,7 +243,9 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte return committedReplay } const resumeFrom = input.resumeFrom - const workspaceCwd = resumeFrom ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path : '' + const workspaceCwd = resumeFrom + ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + : '' const selectedAccountHome = await resolveAccountHome({ launchEnv, location }) // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under @@ -254,10 +258,13 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte agent: input.agent, transcriptImport, providerSessionId: resumeFrom.providerSessionId, - resolveTranscriptPath: () => structuredAgentCliSourcePathFromEvidence({ - agent: input.agent, resumeFrom, workspaceId: location.workspaceId, - evidence: input.structuredAgentCliSourceEvidence - }), + resolveTranscriptPath: () => + structuredAgentCliSourcePathFromEvidence({ + agent: input.agent, + resumeFrom, + workspaceId: location.workspaceId, + evidence: input.structuredAgentCliSourceEvidence + }), prepareCodexResume: this.prepareAiVaultSessionResumeFn ?? undefined, selectedAccountHome, launchEnv: await resolveHostStructuredAgentLaunchEnvironment(input.agent, settings), @@ -278,23 +285,13 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte ...(options ? { options } : {}), ...(input.resumeFrom && adoption ? { - // `adopt` is what makes the reservation seed the handle chain. Presence of - // `providerHandle` alone must not: `agentSession.ensure` already passes one today - // without adopting anything. - adopt: { - providerHandle: { - transport: definition.handleTransport, - agent: definition.agent, - nativeId: transcriptImport!.handleNamesTranscript - ? adoption.transcriptPath - : input.resumeFrom.providerSessionId - }, + adopt: structuredAgentSessionAdoptAttach({ + definition, + handleNamesTranscript: transcriptImport!.handleNamesTranscript, transcriptPath: adoption.transcriptPath, - source: { - providerSessionId: input.resumeFrom.providerSessionId, - workspaceCwd: workspaceCwd - } - } + providerSessionId: input.resumeFrom.providerSessionId, + workspaceCwd + }) } : {}), runtimeKind: 'native' diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index 0288fdeb8d5..d4bf3a99255 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -1,12 +1,18 @@ import { stat } from 'node:fs/promises' -import { agentSessionRefusalError, isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + agentSessionRefusalError, + isAgentSessionRefusalError +} from '../../shared/agent-session-wire-refusals' import { prepareCodexAdoption } from '../native-chat/codex-adoption-preparation' import type { NativeCodexResumePreparation } from '../codex/codex-ai-vault-session-resume' import type { StructuredAgentSessionResumeSource } from '../../shared/structured-agent-session-create' import type { StructuredAgentCliSourceEvidence } from '../native-chat/structured-agent-cli-source' import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' +import type { + AgentSessionProviderHandle, + StructuredAgentId +} from '../../shared/agent-session-provider-handle' import { agentSessionExecutionLocationsEqual } from '../../shared/agent-session-record' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' @@ -112,9 +118,13 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { const stores = new Map() const storeOf = (accountHome: AgentSessionAccountHome): string | null => { if (!stores.has(accountHome)) { - stores.set(accountHome, input.transcriptImport.conversationStore({ - accountHome, launchEnv: input.launchEnv - })) + stores.set( + accountHome, + input.transcriptImport.conversationStore({ + accountHome, + launchEnv: input.launchEnv + }) + ) } return stores.get(accountHome) ?? null } @@ -145,8 +155,13 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { return exact } } catch (error) { - if (!(error instanceof Error && 'code' in error && - (error.code === 'ENOENT' || error.code === 'ENOTDIR'))) { + if ( + !( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) + ) { throw error } } @@ -165,7 +180,10 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { if (!found) { return null } - const path = await input.transcriptImport.admitTranscriptPath({ store, transcriptPath: found }) + const path = await input.transcriptImport.admitTranscriptPath({ + store, + transcriptPath: found + }) if (!path) { throw new Error('The conversation path is outside its account storage.') } @@ -175,13 +193,44 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { workspaceCwd: input.workspaceCwd }) return input.agent === 'codex' - ? await prepareCodexAdoption(adoption, input.providerSessionId, input.prepareCodexResume, candidates) + ? await prepareCodexAdoption( + adoption, + input.providerSessionId, + input.prepareCodexResume, + candidates + ) : adoption } catch (error) { if (isAgentSessionRefusalError(error)) { throw error } - throw agentSessionRefusalError('agent_session_identity_required', - { reason: 'transcriptUnreadable' }, error instanceof Error ? error.message : String(error)) + throw agentSessionRefusalError( + 'agent_session_identity_required', + { reason: 'transcriptUnreadable' }, + error instanceof Error ? error.message : String(error) + ) + } +} + +/** `adopt` is what makes the reservation seed the handle chain. Presence of `providerHandle` alone + * must not: `agentSession.ensure` already passes one today without adopting anything. */ +export function structuredAgentSessionAdoptAttach(input: { + definition: { + handleTransport: AgentSessionProviderHandle['transport'] + agent: AgentSessionProviderHandle['agent'] + } + handleNamesTranscript: boolean | undefined + transcriptPath: string + providerSessionId: string + workspaceCwd: string +}): NonNullable { + return { + providerHandle: { + transport: input.definition.handleTransport, + agent: input.definition.agent, + nativeId: input.handleNamesTranscript ? input.transcriptPath : input.providerSessionId + }, + transcriptPath: input.transcriptPath, + source: { providerSessionId: input.providerSessionId, workspaceCwd: input.workspaceCwd } } }