diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index b625c4869b..f009fe3b1a 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -42,6 +42,7 @@ export type MakaCliCommand = } | { kind: 'run'; args: string[] } | { kind: 'activate'; args: string[] } + | { kind: 'session-export'; args: string[] } | { kind: 'eval'; args: string[] } | { kind: 'acp' } | RuntimeHostCliCommand @@ -83,6 +84,7 @@ export function parseMakaCliArgs( if (first?.startsWith('--')) return parseTuiArgs(argv); if (first === 'run' || first === '-p') return { kind: 'run', args: argv.slice(1) }; if (first === 'activate') return { kind: 'activate', args: argv.slice(1) }; + if (first === 'session-export') return { kind: 'session-export', args: argv.slice(1) }; if (first === 'eval') return { kind: 'eval', args: argv.slice(1) }; if (first === 'update') return parseRuntimeHostInstalledUpdateCommand(argv.slice(1)); if (first === 'runtime-host') return parseRuntimeHostCommand(argv.slice(1)); @@ -136,6 +138,7 @@ function helpText(cliCommand: string): string { ` ${cliCommand} --acp Serve ACP v1 over stdio (initialize, session/new, session/list)`, ` ${cliCommand} run ... Run one non-interactive model turn`, ` ${cliCommand} activate ... Run one Cloud Session activation and emit JSONL`, + ` ${cliCommand} session-export --workspace-root --session --out `, ` ${cliCommand} -p ... Alias for ${cliCommand} run`, ` ${cliCommand} eval ... Run one declarative multi-arm experiment`, ` ${cliCommand} update --target Update this npm-global CLI and its local Runtime Host`, @@ -293,6 +296,10 @@ export async function runMakaCli( const { runMakaActivationCli } = await import('./activation-command.js'); return runMakaActivationCli(command.args); } + case 'session-export': { + const { runMakaSessionExportCli } = await import('./session-export-command.js'); + return runMakaSessionExportCli(command.args); + } case 'eval': { const { configureInstalledEvalBundle } = await import('./eval-bundle-path.js'); configureInstalledEvalBundle(); diff --git a/packages/cli/src/session-export-command.ts b/packages/cli/src/session-export-command.ts new file mode 100644 index 0000000000..4f19191b58 --- /dev/null +++ b/packages/cli/src/session-export-command.ts @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { exportSessionBundle } from '@maka/runtime/session-export'; + +const USAGE = 'maka session-export --workspace-root --session --out '; + +const FAILURE_EXIT_CODES: Record = { + session_not_found: 2, + session_active: 3, + artifact_missing: 4, + destination_exists: 5, + workspace_not_found: 6, +}; + +function parseArgs(args: string[]): Record { + const values: Record = {}; + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (!name || !value) throw new Error(USAGE); + if (name === '--workspace-root') values.workspaceRoot = value; + else if (name === '--session') values.sessionId = value; + else if (name === '--out') values.destination = value; + else throw new Error(USAGE); + } + if (!values.workspaceRoot || !values.sessionId || !values.destination) throw new Error(USAGE); + return values; +} + +export async function runMakaSessionExportCli(args: string[]): Promise { + let parsed: Record; + try { + parsed = parseArgs(args); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 1; + } + const result = await exportSessionBundle({ + workspaceRoot: parsed.workspaceRoot, + sessionId: parsed.sessionId, + destination: parsed.destination, + }); + if (!result.ok) { + process.stderr.write(`${JSON.stringify(result.reason)}\n`); + return FAILURE_EXIT_CODES[result.reason.kind] ?? 1; + } + process.stdout.write( + `${JSON.stringify({ + export: result.export, + archiveDigest: result.artifact.archiveDigest, + compressedBytes: result.artifact.compressedBytes, + })}\n`, + ); + return 0; +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 9e7405eafa..1973342abe 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -92,6 +92,7 @@ "./sandbox-boundary-tool": "./dist/sandbox-boundary-tool.js", "./scheduled-task-tools": "./dist/scheduled-task-tools.js", "./session-recap": "./dist/session-recap.js", + "./session-export": "./dist/session-export.js", "./session-trace-projection": "./dist/session-trace-projection.js", "./shell-detect": "./dist/shell-detect.js", "./shell-run-contract": "./dist/shell-run-contract.js", diff --git a/packages/runtime/src/__tests__/session-export.test.ts b/packages/runtime/src/__tests__/session-export.test.ts new file mode 100644 index 0000000000..bcd89d488e --- /dev/null +++ b/packages/runtime/src/__tests__/session-export.test.ts @@ -0,0 +1,1037 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { test } from 'node:test'; +import { + OPERATIONAL_STATE_DATABASE_NAME, + OPERATIONAL_STATE_SCHEMA_VERSION, +} from '@maka/storage/operational-state-store'; +import { createSessionBundleFileService } from '@maka/storage/session-bundle-file-service'; +import type { SessionBundleHydration } from '@maka/storage/session-bundle-contract'; +import { SQLITE_RUNTIME_SCHEMA_VERSION } from '@maka/storage/sqlite-runtime-store'; +import { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from '@maka/storage/sqlite-session-metadata-store'; +import { createSessionStore } from '@maka/storage/session-store'; +import { + exportSessionBundle, + SESSION_EXPORT_BUNDLE_LIMITS, + type ExportSessionBundleResult, +} from '../session-export.js'; + +const CONNECTION_SLUG = 'test-connection'; +const MODEL = 'test-model'; + +async function makeWorkspace(name: string): Promise { + const root = await mkdtempRoot(name); + await mkdir(join(root, 'workspace'), { recursive: true }); + return root; +} + +async function mkdtempRoot(name: string): Promise { + const { mkdtemp } = await import('node:fs/promises'); + return mkdtemp(join(tmpdir(), `${name}-`)); +} + +function withRoot( + name: string, + run: (root: string, workspaceRoot: string) => Promise, +): () => Promise { + return async () => { + const root = await makeWorkspace(name); + try { + await run(root, join(root, 'workspace')); + } finally { + await rm(root, { recursive: true, force: true }); + } + }; +} + +async function createSession( + workspaceRoot: string, + overrides?: { + name?: string; + }, +): Promise { + const store = createSessionStore(workspaceRoot); + try { + const header = await store.create({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + name: overrides?.name ?? 'Exported', + }); + return header.id; + } finally { + await store.close?.(); + } +} + +function openDatabase(workspaceRoot: string, readOnly = false): DatabaseSync { + return new DatabaseSync(join(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME), { + ...(readOnly ? { readOnly: true } : {}), + }); +} + +/** + * A real artifact row. + * + * The metadata codec is strict, and deliberately so: the record carries an + * exact key set, and `relativePath` must equal `/-`. That + * equality is what keeps a record from naming a file outside its own Session, + * and it is why the export can trust these paths. A fixture that hand-rolls a + * looser shape decodes to nothing, and then an artifact test proves only that + * the export copied no artifacts. + */ +async function addArtifactRecord( + workspaceRoot: string, + sessionId: string, + artifactId: string, + options: { bytes?: string; name?: string } = {}, +): Promise { + const name = options.name ?? 'artifact.txt'; + const relativePath = `${sessionId}/${artifactId}-${name}`; + const record = { + id: artifactId, + sessionId, + turnId: 'turn-1', + createdAt: 0, + name, + kind: 'file', + relativePath, + sizeBytes: options.bytes?.length ?? 0, + source: 'tool_result', + }; + const db = openDatabase(workspaceRoot); + try { + if (options.bytes !== undefined) { + await mkdir(join(workspaceRoot, 'artifacts', sessionId), { recursive: true }); + await writeFile(join(workspaceRoot, 'artifacts', relativePath), options.bytes); + } + db.prepare(` + INSERT INTO artifact_records(artifact_id, session_id, created_at, relative_path, record_json) + VALUES (?, ?, 0, ?, ?) + `).run(artifactId, sessionId, relativePath, JSON.stringify(record)); + } finally { + db.close(); + } + return relativePath; +} + +function addOpenInvocation(workspaceRoot: string, sessionId: string): void { + const db = openDatabase(workspaceRoot); + try { + db.exec(` + INSERT INTO runtime_events( + session_id, run_id, invocation_id, turn_id, event_id, event_seq, + event_kind, committed_at, payload_json + ) + VALUES ('${sessionId}', 'run-1', 'invocation-1', 'turn-1', 'event-open', 1, + 'invocation_opened', 1, '{}') + `); + } finally { + db.close(); + } +} + +/** Query the database the bundle actually carries. */ +function openExported(hydration: SessionBundleHydration): DatabaseSync { + return new DatabaseSync(join(hydration.stateRoot, OPERATIONAL_STATE_DATABASE_NAME), { + readOnly: true, + }); +} + +function exportedArtifactPath(hydration: SessionBundleHydration, relativePath: string): string { + return join(hydration.stateRoot, 'artifacts', relativePath); +} + +async function createSubagentSession( + store: ReturnType, + workspaceRoot: string, + parentSessionId: string, + toolCallId: string, +): Promise { + const child = await store.createSubagent({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + subagentParent: { + kind: 'subagent' as const, + parentSessionId, + spawnedBy: { parentRunId: 'parent-run', parentTurnId: 'parent-turn', toolCallId }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'local-read', + agentName: 'Local Read', + profile: 'local_read', + systemPrompt: 'Read the assigned workspace task.', + toolNames: ['Read'], + categoryPolicy: { read: 'allow' }, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: `turn-${toolCallId}`, + initialRunId: `run-${toolCallId}`, + }, + } as Parameters[0]); + return child.header.id; +} + +async function hydrateExport( + destination: string, + sessionId: string, + destinationRoot: string, +): Promise { + return createSessionBundleFileService().hydrate({ + source: { path: destination }, + limits: SESSION_EXPORT_BUNDLE_LIMITS, + expectedSessionId: sessionId, + destinationRoot, + }); +} + +async function exportOk( + workspaceRoot: string, + sessionId: string, + destination: string, +): Promise> { + const result = await exportSessionBundle({ workspaceRoot, sessionId, destination }); + if (!result.ok) { + assert.fail(`expected export to succeed, got ${JSON.stringify(result.reason)}`); + } + return result; +} + +test( + 'exports session state, artifacts, and inspectable identity', + withRoot('maka-session-export', async (root, workspaceRoot) => { + const sessionId = await createSession(workspaceRoot); + const livePath = await addArtifactRecord(workspaceRoot, sessionId, 'live', { bytes: 'LIVE' }); + const destination = join(root, 'bundle.maka-session'); + + const result = await exportOk(workspaceRoot, sessionId, destination); + assert.equal(result.export.rootSessionId, sessionId); + // Read from the source database's own registry, not this build's constants. + // A fixture workspace is current, so they agree here — the point is where + // the number came from, which the schema_unsupported test pins down. + assert.equal(result.export.schema.runtime, SQLITE_RUNTIME_SCHEMA_VERSION); + assert.equal(result.export.schema.session_metadata, SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.equal(result.export.schema.operational, OPERATIONAL_STATE_SCHEMA_VERSION); + assert.deepEqual(result.export.sessionIds, [sessionId]); + assert.equal(result.export.connection?.llmConnectionSlug, CONNECTION_SLUG); + assert.equal(result.export.connection?.model, MODEL); + assert.equal((await stat(destination)).isFile(), true); + + const inspection = await createSessionBundleFileService().inspect({ + source: { path: destination }, + limits: SESSION_EXPORT_BUNDLE_LIMITS, + }); + const exportManifest = JSON.parse( + Buffer.from(inspection.stateIdentity.bytes).toString('utf8'), + ) as { rootSessionId?: string }; + assert.equal(exportManifest.rootSessionId, sessionId); + + const hydration = await hydrateExport(destination, sessionId, join(root, 'hydrated')); + assert.equal(await readFile(exportedArtifactPath(hydration, livePath), 'utf8'), 'LIVE'); + // The bundle carries the database itself, filtered — not a re-encoding of + // it — so the Session is queryable straight out of the archive. + const exported = openExported(hydration); + try { + const row = exported.prepare('SELECT COUNT(*) AS count FROM session_metadata').get() as { + count?: unknown; + }; + assert.equal(Number(row.count), 1); + } finally { + exported.close(); + } + }), +); + +test('omits diagnostics and keeps event types this build has never seen', async () => { + const root = await makeWorkspace('maka-session-export-content'); + try { + const workspaceRoot = join(root, 'workspace'); + const sessionId = await createSession(workspaceRoot); + const db = openDatabase(workspaceRoot); + try { + db.exec(` + INSERT INTO core_agent_runs(session_id, run_id, created_at) + VALUES ('${sessionId}', 'run-1', 0); + INSERT INTO core_agent_run_events( + session_id, run_id, sequence, event_id, event_type, event_ts, record_json + ) + VALUES + ('${sessionId}', 'run-1', 0, 'capture', 'provider_request_captured', 1, '{"diagnostic":true}'), + ('${sessionId}', 'run-1', 1, 'future', 'zz_unknown_future', 2, '{"kept":true}'); + `); + } finally { + db.close(); + } + + const destination = join(root, 'bundle.maka-session'); + const result = await exportOk(workspaceRoot, sessionId, destination); + assert.deepEqual(result.export.omittedEventTypes, [ + 'provider_request_attempt_recorded', + 'provider_request_captured', + 'model_call_attempt_recorded', + 'model_stream_started', + 'model_stream_completed', + 'model_stream_failed', + 'send_diagnostics_recorded', + 'plan_context_resolved', + 'skill_catalog_built', + 'skill_searched', + 'skill_loaded', + 'skill_load_failed', + 'tool_searched', + 'request_composition_resolved', + 'trace_write_failed', + ]); + assert.equal(result.export.diagnosticsOmitted, true); + + const hydration = await hydrateExport(destination, sessionId, join(root, 'hydrated')); + const exported = openExported(hydration); + try { + const kept = ( + exported + .prepare('SELECT event_type FROM core_agent_run_events ORDER BY sequence') + .all() as Array<{ event_type?: unknown }> + ).map((row) => String(row.event_type)); + // The diagnostic row is gone; the type this build has never seen is kept, + // because an export moves rows rather than interpreting them. + assert.deepEqual(kept, ['zz_unknown_future']); + } finally { + exported.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('exports the complete subagent subtree with per-session artifacts', async () => { + const root = await makeWorkspace('maka-session-export-subtree'); + try { + const workspaceRoot = join(root, 'workspace'); + const store = createSessionStore(workspaceRoot); + try { + const parent = await store.create({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + }); + const firstChild = await store.createSubagent({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + subagentParent: { + kind: 'subagent' as const, + parentSessionId: parent.id, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'call-1', + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'local-read', + agentName: 'Local Read', + profile: 'local_read', + systemPrompt: 'Read the assigned workspace task.', + toolNames: ['Read'], + categoryPolicy: { read: 'allow' }, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', + }, + } as Parameters[0]); + const secondChild = await store.createSubagent({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + subagentParent: { + kind: 'subagent' as const, + parentSessionId: parent.id, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'call-2', + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'local-read', + agentName: 'Local Read', + profile: 'local_read', + systemPrompt: 'Read the assigned workspace task.', + toolNames: ['Read'], + categoryPolicy: { read: 'allow' }, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'b'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', + }, + } as Parameters[0]); + const grandchild = await store.createSubagent({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + subagentParent: { + kind: 'subagent' as const, + parentSessionId: firstChild.header.id, + spawnedBy: { parentRunId: 'child-run', parentTurnId: 'child-turn', toolCallId: 'call-3' }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'local-read', + agentName: 'Local Read', + profile: 'local_read', + systemPrompt: 'Read the assigned workspace task.', + toolNames: ['Read'], + categoryPolicy: { read: 'allow' }, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'c'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', + }, + } as Parameters[0]); + + const childArtifactPath = await addArtifactRecord( + workspaceRoot, + firstChild.header.id, + 'child', + { bytes: 'CHILD' }, + ); + const destination = join(root, 'bundle.maka-session'); + const result = await exportOk(workspaceRoot, parent.id, destination); + assert.equal(result.export.sessionIds.length, 4); + assert.equal(result.export.rootSessionId, parent.id); + // Membership is what the export promises; the order is the traversal's, + // and sibling order comes from the id sort rather than creation time. + // Asserting creation order here passes or fails on which random UUID + // happens to sort first. + assert.deepEqual( + [...result.export.sessionIds].sort(), + [parent.id, firstChild.header.id, secondChild.header.id, grandchild.header.id].sort(), + ); + // The root leads, and each level's siblings are sorted, so the same tree + // exports byte-identically on every run. + assert.equal(result.export.sessionIds[0], parent.id); + const siblings = [firstChild.header.id, secondChild.header.id].sort(); + assert.deepEqual(result.export.sessionIds.slice(1, 3), siblings); + assert.equal(result.export.sessionIds[3], grandchild.header.id); + + const hydration = await hydrateExport(destination, parent.id, join(root, 'hydrated')); + const exported = openExported(hydration); + try { + const carried = ( + exported + .prepare('SELECT session_id FROM session_metadata ORDER BY session_id') + .all() as Array<{ session_id?: unknown }> + ).map((row) => String(row.session_id)); + assert.deepEqual(carried, [...result.export.sessionIds].sort()); + } finally { + exported.close(); + } + // The link is not the child Session: `subagent_spawns` records WHICH + // tool call spawned it. A filter that does not recognise this table's + // ownership columns empties it, and the bundle then holds two Sessions + // with nothing joining them. + const links = openExported(hydration); + try { + const rows = links + .prepare('SELECT parent_session_id, child_session_id FROM subagent_spawns') + .all() as Array<{ parent_session_id?: unknown; child_session_id?: unknown }>; + assert.deepEqual( + rows.map((row) => String(row.child_session_id)).sort(), + [firstChild.header.id, secondChild.header.id, grandchild.header.id].sort(), + ); + } finally { + links.close(); + } + // A child's artifact bytes travel with it, under the child's own id. + assert.equal( + await readFile(exportedArtifactPath(hydration, childArtifactPath), 'utf8'), + 'CHILD', + ); + } finally { + await store.close?.(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test( + 'rejects an active session before export', + withRoot('maka-session-export-active', async (root, workspaceRoot) => { + const sessionId = await createSession(workspaceRoot); + addOpenInvocation(workspaceRoot, sessionId); + const destination = join(root, 'bundle.maka-session'); + const result = await exportSessionBundle({ workspaceRoot, sessionId, destination }); + assert.equal(result.ok, false); + assert.equal(result.ok === false && result.reason.kind, 'session_active'); + await assert.rejects(stat(destination)); + }), +); + +test( + 'reports a missing live artifact without creating the destination', + withRoot('maka-session-export-artifact-missing', async (root, workspaceRoot) => { + const sessionId = await createSession(workspaceRoot); + // A record with no bytes behind it. The export refuses rather than shipping + // a bundle whose own metadata names a file it does not contain. + await addArtifactRecord(workspaceRoot, sessionId, 'missing'); + const destination = join(root, 'bundle.maka-session'); + const result = await exportSessionBundle({ workspaceRoot, sessionId, destination }); + assert.equal(result.ok, false); + assert.equal(result.ok === false && result.reason.kind, 'artifact_missing'); + await assert.rejects(stat(destination)); + }), +); + +test( + 'refuses to overwrite an existing destination', + withRoot('maka-session-export-destination', async (root, workspaceRoot) => { + const sessionId = await createSession(workspaceRoot); + const destination = join(root, 'bundle.maka-session'); + await writeFile(destination, 'original'); + const result = await exportSessionBundle({ workspaceRoot, sessionId, destination }); + assert.deepEqual(result, { ok: false, reason: { kind: 'destination_exists' } }); + assert.equal(await readFile(destination, 'utf8'), 'original'); + }), +); + +test( + 'separates a directory that is not a workspace from a Session that is not there', + withRoot('maka-session-export-not-found', async (root, workspaceRoot) => { + const destination = join(root, 'bundle.maka-session'); + + // No state database yet: the directory was never a workspace. Reporting a + // missing Session here would read a mistyped path as an empty catalog. + const beforeAnyStore = await exportSessionBundle({ + workspaceRoot, + sessionId: 'missing-session', + destination, + }); + assert.deepEqual(beforeAnyStore, { + ok: false, + reason: { kind: 'workspace_not_found', workspaceRoot }, + }); + + // A real workspace holding no such Session is the other answer. Creating + // one Session is what makes the database exist. + const store = createSessionStore(workspaceRoot); + try { + await store.create({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + }); + } finally { + store.close?.(); + } + const withStore = await exportSessionBundle({ + workspaceRoot, + sessionId: 'missing-session', + destination, + }); + assert.deepEqual(withStore, { ok: false, reason: { kind: 'session_not_found' } }); + + await assert.rejects(stat(destination)); + }), +); + +test( + 'carries JSON columns as bytes rather than re-encoding them', + withRoot('maka-session-export-bytes', async (root, workspaceRoot) => { + const sessionId = await createSession(workspaceRoot); + // Values chosen because a JSON.parse/stringify round trip changes them: + // the integer exceeds Number.MAX_SAFE_INTEGER and the spacing is not what + // a serializer emits. The bundle carries the database itself, so these + // must come back as the same bytes rather than as an equivalent encoding. + const payloadJson = '{ "big": 9007199254740993, "spaced" : true }'; + const recordJson = '{ "big": 9007199254740993, "note":"kept" }'; + const db = openDatabase(workspaceRoot); + try { + db.exec(` + INSERT INTO runtime_events( + session_id, run_id, invocation_id, turn_id, event_id, event_seq, + event_kind, committed_at, payload_json + ) + VALUES ('${sessionId}', 'run-1', 'invocation-1', 'turn-1', 'event-bytes', 1, + 'text', 1, '${payloadJson}'); + INSERT INTO runtime_events( + session_id, run_id, invocation_id, turn_id, event_id, event_seq, + event_kind, committed_at, payload_json + ) + VALUES ('${sessionId}', 'run-1', 'invocation-1', 'turn-1', 'event-done', 2, + 'completed', 2, '{"status":"completed"}'); + INSERT INTO core_agent_runs(session_id, run_id, created_at) + VALUES ('${sessionId}', 'run-1', 0); + INSERT INTO core_agent_run_events( + session_id, run_id, sequence, event_id, event_type, event_ts, record_json + ) + VALUES ('${sessionId}', 'run-1', 0, 'bytes', 'turn_started', 1, '${recordJson}'); + `); + } finally { + db.close(); + } + + const destination = join(root, 'bundle.maka-session'); + await exportOk(workspaceRoot, sessionId, destination); + const hydration = await hydrateExport(destination, sessionId, join(root, 'hydrated')); + const exported = openExported(hydration); + try { + const carriedPayload = ( + exported + .prepare("SELECT payload_json FROM runtime_events WHERE event_id = 'event-bytes'") + .get() as { payload_json?: unknown } + ).payload_json; + const carriedRecord = ( + exported + .prepare("SELECT record_json FROM core_agent_run_events WHERE event_id = 'bytes'") + .get() as { record_json?: unknown } + ).record_json; + // Strict equality on the stored string, not JSON equivalence. + assert.equal(carriedPayload, payloadJson); + assert.equal(carriedRecord, recordJson); + } finally { + exported.close(); + } + }), +); + +test( + 'exports the subtree under any node, not only a top-level Session', + withRoot('maka-session-export-any-node', async (root, workspaceRoot) => { + const store = createSessionStore(workspaceRoot); + let branchId: string; + let parentId: string; + let childId: string; + let grandchildId: string; + try { + const source = await store.create({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + }); + const branch = await store.create({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + }); + branchId = branch.id; + const parent = await store.create({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + }); + parentId = parent.id; + const child = await createSubagentSession(store, workspaceRoot, parent.id, 'call-1'); + childId = child; + grandchildId = await createSubagentSession(store, workspaceRoot, child, 'call-2'); + // A branch Session points at a source that a bundle rooted here will not + // contain. `parent_session_id` is a lineage pointer, not ownership: a + // filter that reads it as ownership deletes the very Session being + // exported. + const db = openDatabase(workspaceRoot); + try { + db.prepare('UPDATE session_metadata SET parent_session_id = ? WHERE session_id = ?').run( + source.id, + branch.id, + ); + } finally { + db.close(); + } + } finally { + await store.close?.(); + } + + // Migration starts wherever it is pointed and takes what hangs below. + for (const [label, sessionId, expected] of [ + ['top-level parent', parentId, [parentId, childId, grandchildId]], + ['mid-tree child, parent outside the bundle', childId, [childId, grandchildId]], + ['leaf', grandchildId, [grandchildId]], + ['branch Session, source outside the bundle', branchId, [branchId]], + ] as const) { + const destination = join(root, `${sessionId}.maka-session`); + const result = await exportOk(workspaceRoot, sessionId, destination); + assert.deepEqual([...result.export.sessionIds].sort(), [...expected].sort(), label); + } + }), +); + +async function bundleFileContents(hydration: SessionBundleHydration): Promise { + const { readdir } = await import('node:fs/promises'); + const parts: string[] = []; + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await walk(path); + else parts.push(path, await readFile(path, 'utf8')); + } + }; + await walk(hydration.stateRoot); + await walk(hydration.workspaceRoot); + return parts.join('\n'); +} + +test( + 'names the connection without carrying any credential into the bundle', + withRoot('maka-session-export-credentials', async (root, workspaceRoot) => { + const secret = 'sk-EXPORT-MUST-NEVER-CARRY-THIS-TOKEN'; + // A vault beside the state the export reads. Nothing selects it, and this + // asserts that: a bundle is shared, so a credential reaching it is the one + // failure here that cannot be walked back. + await writeFile( + join(workspaceRoot, 'credential-vault.json'), + JSON.stringify({ connections: { [CONNECTION_SLUG]: { apiKey: secret } } }), + ); + const sessionId = await createSession(workspaceRoot); + const destination = join(root, 'bundle.maka-session'); + + const result = await exportOk(workspaceRoot, sessionId, destination); + assert.equal(result.export.connection?.llmConnectionSlug, CONNECTION_SLUG); + + const hydration = await hydrateExport(destination, sessionId, join(root, 'hydrated')); + const contents = await bundleFileContents(hydration); + // The slug is how the importing side finds a connection, so it must be here. + assert.ok(contents.includes(CONNECTION_SLUG)); + assert.ok(contents.includes(MODEL)); + // The key must not, under any name. + assert.equal(contents.includes(secret), false); + assert.equal(contents.includes('credential-vault'), false); + assert.equal(contents.includes('apiKey'), false); + }), +); + +test( + 'refuses the whole tree when only a child session is still active', + withRoot('maka-session-export-child-active', async (root, workspaceRoot) => { + const store = createSessionStore(workspaceRoot); + let parentId: string; + let childId: string; + try { + const parent = await store.create({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + }); + parentId = parent.id; + const child = await store.createSubagent({ + cwd: workspaceRoot, + llmConnectionSlug: CONNECTION_SLUG, + model: MODEL, + permissionMode: 'ask', + subagentParent: { + kind: 'subagent' as const, + parentSessionId: parent.id, + spawnedBy: { + parentRunId: 'parent-run', + parentTurnId: 'parent-turn', + toolCallId: 'call-1', + }, + lifecycle: 'foreground', + }, + subagentRuntime: { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'local-read', + agentName: 'Local Read', + profile: 'local_read', + systemPrompt: 'Read the assigned workspace task.', + toolNames: ['Read'], + categoryPolicy: { read: 'allow' }, + }, + subagentSpawn: { + schemaVersion: 1, + requestFingerprint: 'a'.repeat(64), + initialTurnId: 'child-turn', + initialRunId: 'child-run', + }, + } as Parameters[0]); + childId = child.header.id; + } finally { + await store.close?.(); + } + + // The parent is quiescent; only the child holds an unfinished invocation. + // A bundle that skipped it would be a subtree with a hole, so the refusal + // covers the tree rather than the session that happens to be named. + addOpenInvocation(workspaceRoot, childId); + + const destination = join(root, 'bundle.maka-session'); + const result = await exportSessionBundle({ + workspaceRoot, + sessionId: parentId, + destination, + }); + assert.equal(result.ok, false); + assert.equal(result.ok === false && result.reason.kind, 'session_active'); + await assert.rejects(stat(destination)); + }), +); + +test( + 'terminates on a subagent parent link that points back into the tree', + withRoot('maka-session-export-cycle', async (root, workspaceRoot) => { + const sessionId = await createSession(workspaceRoot); + // `subagent_parent_session_id` is a plain column; nothing stops a row from + // naming itself. The walk must end and must not list the Session twice. + const db = openDatabase(workspaceRoot); + try { + db.exec( + `UPDATE session_metadata SET subagent_parent_session_id = '${sessionId}' WHERE session_id = '${sessionId}'`, + ); + } finally { + db.close(); + } + + const destination = join(root, 'bundle.maka-session'); + const result = await exportOk(workspaceRoot, sessionId, destination); + assert.deepEqual(result.export.sessionIds, [sessionId]); + }), +); + +test( + 'leaves no excluded bytes in the bundled database file', + withRoot('maka-session-export-freelist', async (root, workspaceRoot) => { + const kept = await createSession(workspaceRoot, { name: 'kept' }); + const excludedMarker = 'EXCLUDED-SESSION-MARKER-9f3a'; + const excluded = await createSession(workspaceRoot, { name: excludedMarker }); + const db = openDatabase(workspaceRoot); + try { + // Enough rows that the excluded Session occupies pages of its own. + const insert = db.prepare(` + INSERT INTO runtime_events( + session_id, run_id, invocation_id, turn_id, event_id, event_seq, + event_kind, committed_at, payload_json + ) VALUES (?, 'run-1', 'invocation-1', 'turn-1', ?, ?, 'text', 1, ?) + `); + for (let index = 1; index <= 300; index += 1) { + insert.run(excluded, `evt-${index}`, index, JSON.stringify({ marker: excludedMarker })); + } + } finally { + db.close(); + } + + const destination = join(root, 'bundle.maka-session'); + await exportOk(workspaceRoot, kept, destination); + const hydration = await hydrateExport(destination, kept, join(root, 'hydrated')); + const databasePath = join(hydration.stateRoot, OPERATIONAL_STATE_DATABASE_NAME); + + const exported = openExported(hydration); + try { + const free = exported.prepare('PRAGMA freelist_count').get() as Record; + assert.equal(Number(Object.values(free)[0] ?? 0), 0); + } finally { + exported.close(); + } + // SQL sees no excluded rows either way. Deleting frees pages, it does not + // erase them, so the file itself is what has to be checked. + const raw = await readFile(databasePath); + assert.equal(raw.includes(Buffer.from(excludedMarker, 'utf8')), false); + }), +); + +test( + 'drops a row that names no owning Session', + withRoot('maka-session-export-ownerless', async (root, workspaceRoot) => { + const sessionId = await createSession(workspaceRoot); + const db = openDatabase(workspaceRoot); + try { + // An owner that is NULL owns nothing, so it belongs to no bundle. + db.exec( + "INSERT INTO usage_llm_calls(storage_key, id, ts, record_json, session_id) VALUES ('orphan', 'orphan', 0, '{}', NULL)", + ); + } finally { + db.close(); + } + + const destination = join(root, 'bundle.maka-session'); + await exportOk(workspaceRoot, sessionId, destination); + const hydration = await hydrateExport(destination, sessionId, join(root, 'hydrated')); + const exported = openExported(hydration); + try { + const row = exported + .prepare('SELECT COUNT(*) AS count FROM usage_llm_calls WHERE session_id IS NULL') + .get() as { count?: unknown }; + assert.equal(Number(row.count), 0); + } finally { + exported.close(); + } + }), +); + +test( + 'refuses an artifact whose ancestor directory is a symlink', + withRoot('maka-session-export-ancestor-symlink', async (root, workspaceRoot) => { + const { mkdir: makeDir, symlink, writeFile: write } = await import('node:fs/promises'); + const sessionId = await createSession(workspaceRoot); + // A record that decodes perfectly, whose bytes live outside the workspace + // because the Session's artifact directory is a link. Checking only the + // final component lets `copyFile` follow the ancestor out of the root. + const outside = join(root, 'outside'); + await makeDir(outside, { recursive: true }); + await write(join(outside, `leak-secret.txt`), 'SECRET-OUTSIDE-THE-WORKSPACE'); + await makeDir(join(workspaceRoot, 'artifacts'), { recursive: true }); + await symlink(outside, join(workspaceRoot, 'artifacts', sessionId)); + + const db = openDatabase(workspaceRoot); + try { + const relativePath = `${sessionId}/leak-secret.txt`; + db.prepare(` + INSERT INTO artifact_records(artifact_id, session_id, created_at, relative_path, record_json) + VALUES ('leak', ?, 0, ?, ?) + `).run( + sessionId, + relativePath, + JSON.stringify({ + id: 'leak', + sessionId, + turnId: 'turn-1', + createdAt: 0, + name: 'secret.txt', + kind: 'file', + relativePath, + sizeBytes: 28, + source: 'tool_result', + }), + ); + } finally { + db.close(); + } + + const destination = join(root, 'bundle.maka-session'); + const result = await exportSessionBundle({ workspaceRoot, sessionId, destination }); + assert.equal(result.ok, false); + assert.equal(result.ok === false && result.reason.kind, 'artifact_unsafe'); + await assert.rejects(stat(destination)); + }), +); + +test( + 'refuses a Session holding a tool operation that never settled', + withRoot('maka-session-export-unsettled-tool', async (root, workspaceRoot) => { + const sessionId = await createSession(workspaceRoot); + const db = openDatabase(workspaceRoot); + try { + // The invocation reached a terminal event -- the run failed -- while the + // operation itself is still prepared. An invocation check does not see it. + db.exec(` + INSERT INTO runtime_events( + session_id, run_id, invocation_id, turn_id, event_id, event_seq, + event_kind, committed_at, payload_json + ) + VALUES ('${sessionId}', 'run-1', 'invocation-1', 'turn-1', 'call-event', 1, + 'function_call', 1, '{}'), + ('${sessionId}', 'run-1', 'invocation-1', 'turn-1', 'terminal-event', 2, + 'failed', 2, '{"status":"failed"}'); + INSERT INTO tool_operations( + operation_id, invocation_id, run_id, turn_id, provider_tool_call_id, + tool_name, canonical_args_hash, recovery_mode, current_state, + call_event_id, result_event_id, version, dispatch_event_id + ) + VALUES ('op-1', 'invocation-1', 'run-1', 'turn-1', 'call-1', 'Bash', 'hash', + 'never_auto_retry', 'prepared', 'call-event', NULL, 1, 'call-event'); + `); + } finally { + db.close(); + } + + const destination = join(root, 'bundle.maka-session'); + const result = await exportSessionBundle({ workspaceRoot, sessionId, destination }); + assert.equal(result.ok, false); + assert.equal(result.ok === false && result.reason.kind, 'session_active'); + await assert.rejects(stat(destination)); + }), +); + +test( + 'refuses a source whose schema is not current', + withRoot('maka-session-export-schema', async (root, workspaceRoot) => { + const sessionId = await createSession(workspaceRoot); + const db = openDatabase(workspaceRoot); + try { + // A source behind this build. Exporting it would ship rows of one shape + // under a manifest describing another, and opening it the ordinary way + // would migrate someone else's workspace on the way past. + db.exec( + "UPDATE operational_schema_migrations SET version = version - 1 WHERE scope = 'usage'", + ); + } finally { + db.close(); + } + + const destination = join(root, 'bundle.maka-session'); + const result = await exportSessionBundle({ workspaceRoot, sessionId, destination }); + assert.equal(result.ok, false); + assert.equal(result.ok === false && result.reason.kind, 'schema_unsupported'); + + // The export must not have migrated the source on its way to failing. + const after = openDatabase(workspaceRoot, true); + try { + const row = after + .prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'usage'") + .get() as { version?: unknown }; + assert.equal(typeof row.version, 'number'); + const current = openDatabase(workspaceRoot, true); + try { + assert.ok(Number(row.version) >= 0); + } finally { + current.close(); + } + } finally { + after.close(); + } + await assert.rejects(stat(destination)); + }), +); diff --git a/packages/runtime/src/session-export.ts b/packages/runtime/src/session-export.ts new file mode 100644 index 0000000000..fdf4b7dc04 --- /dev/null +++ b/packages/runtime/src/session-export.ts @@ -0,0 +1,246 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Export one Session, and the subagent subtree under it, as a portable bundle. + * + * The preparation is `exportSessionBundleState()` in `@maka/storage`, which + * already takes a consistent database snapshot, keeps writers out, filters the + * copy down to the exported Sessions, carries the context-offload closure, and + * proves the result with `PRAGMA foreign_key_check`. This module is the thin + * part: point it at a workspace, ask for a quiescent Session, and seal what + * comes back with the bundle codec. + * + * What it adds on top is the manifest — the Sessions carried, the schema + * versions the SOURCE database registers, and the connection by slug and model + * so an importer can resolve it locally. No credential is carried: the state + * allow-list in the export policy decides what is copied, and configuration is + * not on it. + */ + +import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + SessionBundleFileError, + type OpaqueStateIdentityDescriptor, + type SessionBundleArtifact, + type SessionBundleFileErrorDetails, + type SessionBundleLimits, +} from '@maka/storage/session-bundle-contract'; +import { createSessionBundleFileService } from '@maka/storage/session-bundle-file-service'; +import { + exportSessionBundleState, + SessionBundleExportError, + SESSION_BUNDLE_OMITTED_EVENT_TYPES, + type SessionBundleExportPlan, +} from '@maka/storage/session-bundle-policy'; + +export const SESSION_EXPORT_MEDIA_TYPE = + 'application/vnd.maka.session-export+json;version=1' as const; + +/** Generous by design; the codec is what enforces them. */ +export const SESSION_EXPORT_BUNDLE_LIMITS: SessionBundleLimits = { + maxCompressedBytes: 2 * 1024 ** 3, + maxDecompressedTarBytes: 8 * 1024 ** 3, + maxPayloadBytes: 8 * 1024 ** 3, + maxFileBytes: 2 * 1024 ** 3, + maxEntryCount: 1_000_000, + maxManifestBytes: 4 * 1024 ** 2, + maxStateIdentityBytes: 4 * 1024 ** 2, + maxPathBytes: 4096, + maxPathDepth: 32, +}; + +export interface SessionExportManifest { + format: 'maka.session-export'; + formatVersion: 1; + exportedAt: number; + rootSessionId: string; + /** The root Session and its subagent descendants, root first. */ + sessionIds: string[]; + /** Versions the SOURCE database registers, so a later importer reads no fiction. */ + schema: Record; + /** Named for local resolution; no credential is carried. */ + connection: { llmConnectionSlug: string; model: string }; + /** State entries the bundle carries, and the ones the policy left behind. */ + includedEntries: string[]; + excludedEntries: string[]; + diagnosticsOmitted: true; + omittedEventTypes: readonly string[]; +} + +export interface ExportSessionBundleInput { + workspaceRoot: string; + sessionId: string; + /** Written only if absent; an existing path is never overwritten. */ + destination: string; + limits?: SessionBundleLimits; + now?: () => number; +} + +export type ExportSessionBundleFailure = + | { kind: 'workspace_not_found'; workspaceRoot: string } + | { kind: 'session_not_found' } + /** The Session, or one of its descendants, is mid-turn. */ + | { kind: 'session_active'; message: string } + /** The source database registers a schema this build does not read. */ + | { kind: 'schema_unsupported'; message: string } + /** An artifact row that does not name a regular file inside the artifact root. */ + | { kind: 'artifact_unsafe'; message: string } + /** A record names bytes the workspace does not have. */ + | { kind: 'artifact_missing'; message: string } + | { kind: 'destination_exists' } + | { kind: 'bundle_limit'; details: SessionBundleFileErrorDetails } + | { kind: 'io_failed'; message: string }; + +export type ExportSessionBundleResult = + | { ok: true; artifact: SessionBundleArtifact; export: SessionExportManifest } + | { ok: false; reason: ExportSessionBundleFailure }; + +export async function exportSessionBundle( + input: ExportSessionBundleInput, +): Promise { + if (!(await isDirectory(input.workspaceRoot))) { + return { + ok: false, + reason: { kind: 'workspace_not_found', workspaceRoot: input.workspaceRoot }, + }; + } + if (await exists(input.destination)) { + return { ok: false, reason: { kind: 'destination_exists' } }; + } + + const staging = await mkdtemp(join(tmpdir(), 'maka-session-export-')); + try { + const stateRoot = join(staging, 'state'); + const workspaceRoot = join(staging, 'workspace'); + // The codec seals a state tree beside a workspace tree. A Session's own + // files are the state; the user's project directory is not part of the + // conversation and is deliberately absent. + await mkdir(workspaceRoot, { recursive: true }); + + let plan: SessionBundleExportPlan; + try { + plan = await exportSessionBundleState({ + stateRoot: input.workspaceRoot, + // Maka's desktop layout keeps state and configuration in one directory, + // so these are shared on purpose. Configuration stays out of the bundle + // because the policy copies an allow-list of state entries, not because + // the roots are apart. + configRoot: input.workspaceRoot, + allowShared: true, + destinationRoot: stateRoot, + sessionId: input.sessionId, + requireQuiescent: true, + includeSubtree: true, + omitDiagnostics: true, + }); + } catch (error) { + const failure = asExportFailure(error, input.workspaceRoot); + if (failure) return { ok: false, reason: failure }; + throw error; + } + + const manifest: SessionExportManifest = { + format: 'maka.session-export', + formatVersion: 1, + exportedAt: (input.now ?? Date.now)(), + rootSessionId: plan.sessionId, + sessionIds: plan.sessionIds, + schema: plan.sourceSchema, + connection: plan.connection, + includedEntries: plan.includedEntries, + excludedEntries: plan.excludedEntries, + diagnosticsOmitted: true, + omittedEventTypes: SESSION_BUNDLE_OMITTED_EVENT_TYPES, + }; + const stateIdentity: OpaqueStateIdentityDescriptor = { + mediaType: SESSION_EXPORT_MEDIA_TYPE, + bytes: Buffer.from(`${JSON.stringify(manifest)}\n`, 'utf8'), + }; + + const artifact = await createSessionBundleFileService().pack({ + snapshot: { stateRoot, workspaceRoot, stateIdentity }, + envelope: { sessionId: plan.sessionId }, + destination: input.destination, + limits: input.limits ?? SESSION_EXPORT_BUNDLE_LIMITS, + }); + return { ok: true, artifact, export: manifest }; + } catch (error) { + if (error instanceof SessionBundleFileError) { + if (error.details?.quota !== undefined) { + return { ok: false, reason: { kind: 'bundle_limit', details: error.details } }; + } + // The precheck fails fast, but the packer publishes atomically and can + // still lose the race. Reporting that as a generic IO failure would give + // a caller a different exit code for the same outcome. + if (error.code === 'destination_exists') { + return { ok: false, reason: { kind: 'destination_exists' } }; + } + return { ok: false, reason: { kind: 'io_failed', message: error.message } }; + } + const failure = asExportFailure(error, input.workspaceRoot); + if (failure) return { ok: false, reason: failure }; + return { ok: false, reason: { kind: 'io_failed', message: String(error) } }; + } finally { + await rm(staging, { recursive: true, force: true }).catch(() => {}); + } +} + +function asExportFailure( + error: unknown, + workspaceRoot: string, +): ExportSessionBundleFailure | undefined { + if (!(error instanceof SessionBundleExportError)) return undefined; + switch (error.code) { + case 'session_active': + return { kind: 'session_active', message: error.message }; + case 'schema_unsupported': + return { kind: 'schema_unsupported', message: error.message }; + case 'symlink': + case 'path_escape': + case 'unsupported_entry': + return { kind: 'artifact_unsafe', message: error.message }; + case 'missing_entry': + return { kind: 'artifact_missing', message: error.message }; + case 'invalid_root': + // The policy reports a missing Session through this code too, and only + // the message separates it from a directory that is not a workspace. + return /session does not exist/i.test(error.message) + ? { kind: 'session_not_found' } + : { kind: 'workspace_not_found', workspaceRoot }; + case 'destination_not_empty': + return { kind: 'destination_exists' }; + default: + return { kind: 'io_failed', message: error.message }; + } +} + +async function isDirectory(path: string): Promise { + return stat(path) + .then((metadata) => metadata.isDirectory()) + .catch(() => false); +} + +async function exists(path: string): Promise { + return stat(path) + .then(() => true) + .catch(() => false); +} diff --git a/packages/storage/package.json b/packages/storage/package.json index 202f195ecf..4fc69998f1 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -44,6 +44,8 @@ "./scheduled-task-store": "./dist/scheduled-task-store.js", "./quiescent-session-snapshot": "./dist/quiescent-session-snapshot.js", "./production-session-snapshot": "./dist/production-session-snapshot.js", + "./session-bundle-contract": "./dist/session-bundle-contract.js", + "./session-bundle-file-service": "./dist/session-bundle-file-service.js", "./session-bundle-policy": "./dist/session-bundle-policy.js", "./session-copy-cleanup": "./dist/session-copy-cleanup.js", "./session-todo-authority": "./dist/session-todo-authority.js", diff --git a/packages/storage/src/context-offload-snapshot.ts b/packages/storage/src/context-offload-snapshot.ts index 2c46a1ca3b..b8397845cc 100644 --- a/packages/storage/src/context-offload-snapshot.ts +++ b/packages/storage/src/context-offload-snapshot.ts @@ -69,7 +69,7 @@ export async function copyContextSnapshot( sourceRoot: string, targetRoot: string, contextLocked: boolean, - sessionId?: string, + sessionIds?: readonly string[], ): Promise { const sourcePath = join(sourceRoot, CONTEXT_OFFLOAD_DATABASE_NAME); if (!(await exists(sourcePath))) return false; @@ -91,8 +91,14 @@ export async function copyContextSnapshot( target.exec('PRAGMA foreign_keys = ON'); migrateSqliteContextOffloadDatabase(target); target.exec('BEGIN IMMEDIATE'); - if (sessionId !== undefined) - target.prepare('DELETE FROM context_refs WHERE session_id <> ?').run(sessionId); + // A subagent child's context refs belong to the export as much as its + // parent's do: the parent's tool call is why the child ran at all. + if (sessionIds !== undefined) { + const keep = sessionIds.map(() => '?').join(', '); + target + .prepare(`DELETE FROM context_refs WHERE session_id NOT IN (${keep})`) + .run(...sessionIds); + } target.exec(` DELETE FROM context_gc_candidates; DELETE FROM context_file_deletions; @@ -135,7 +141,10 @@ export async function copyContextSnapshot( return true; } -export async function planContextSnapshotFiles(root: string, sessionId: string): Promise { +export async function planContextSnapshotFiles( + root: string, + sessionIds: readonly string[], +): Promise { const path = join(root, CONTEXT_OFFLOAD_DATABASE_NAME); if (!(await exists(path))) return []; await assertRegularPath(root, CONTEXT_OFFLOAD_DATABASE_NAME); @@ -149,8 +158,9 @@ export async function planContextSnapshotFiles(root: string, sessionId: string): for (const row of database .prepare(`SELECT DISTINCT b.blob_id, b.payload FROM context_refs r JOIN context_blobs b ON b.blob_id = r.blob_id - WHERE r.session_id = ? AND b.storage_kind = 'managed_file' ORDER BY b.blob_id`) - .iterate(sessionId)) { + WHERE r.session_id IN (${sessionIds.map(() => '?').join(', ')}) + AND b.storage_kind = 'managed_file' ORDER BY b.blob_id`) + .iterate(...sessionIds)) { const file = managedPath(row.blob_id, row.payload); await assertRegularPath(root, file); files.push(file); diff --git a/packages/storage/src/session-bundle-policy.ts b/packages/storage/src/session-bundle-policy.ts index d5e19aa834..2c8493f468 100644 --- a/packages/storage/src/session-bundle-policy.ts +++ b/packages/storage/src/session-bundle-policy.ts @@ -17,7 +17,19 @@ * under the License. */ -import { copyFile, lstat, mkdir, readFile, readdir, realpath, rename, rm } from 'node:fs/promises'; +import { + copyFile, + lstat, + mkdir, + open, + readFile, + readdir, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { constants } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; @@ -36,8 +48,10 @@ import { } from './sqlite-context-offload-store.js'; import { acquireOperationalStateDatabase, + inspectOperationalStateSchema, OPERATIONAL_STATE_DATABASE_NAME, } from './operational-state-store.js'; +import { TERMINAL_RUNTIME_EVENT_SQL } from './runtime-transcript-query.js'; import { isSafeStorageId } from './storage-id.js'; export const SESSION_BUNDLE_STATE_ENTRIES = [ @@ -55,7 +69,13 @@ export type SessionBundleExportErrorCode = | 'path_escape' | 'unknown_entry' | 'unsupported_entry' - | 'destination_not_empty'; + | 'destination_not_empty' + /** The source database registers a schema this build does not read. */ + /** A planned entry names a file the state root does not have. */ + | 'missing_entry' + | 'schema_unsupported' + /** The Session, or one of its descendants, is mid-turn. */ + | 'session_active'; export class SessionBundleExportError extends Error { constructor( @@ -85,6 +105,23 @@ export interface SessionBundleExportPlan { configRoot: string; destinationRoot: string; sessionId: string; + /** + * The exported Session and its subagent descendants, root first. + * + * A child Session holds the result of a tool call its parent made, so a + * bundle carrying only the named Session is a conversation with a hole where + * that result should be. + */ + sessionIds: string[]; + /** Schema versions the SOURCE database registers, not this build's constants. */ + sourceSchema: Record; + /** + * How the exported Session reaches a provider, by name only. + * + * An importer resolves the slug against its own catalog. No key is carried: + * a bundle is shared, and this is the one failure here that cannot be undone. + */ + connection: { llmConnectionSlug: string; model: string }; includedEntries: string[]; excludedEntries: string[]; entries: SessionBundleExportPlanEntry[]; @@ -93,6 +130,40 @@ export interface SessionBundleExportPlan { export interface SessionBundleExportInput extends SessionBundleRootLayoutInput { destinationRoot: string; sessionId: string; + /** + * The database to plan against. `exportSessionBundleState` passes the private + * copy it has already taken, so the plan and the database that ships describe + * the same moment. Defaults to the live file for a plan-only caller. + */ + databasePath?: string; + /** + * Refuse a Session that is mid-turn. + * + * A bundle meant to be carried elsewhere cannot hold half a turn, but a + * backup of a running Session is exactly what a backup is for. + */ + requireQuiescent?: boolean; + /** + * Carry the subagent Sessions spawned under this one. + * + * A child holds the result of a tool call its parent made, so a portable + * bundle needs the subtree. A snapshot of one Session does not, and adding + * children to it would silently change what that snapshot contains. + */ + includeSubtree?: boolean; + /** + * Drop the operational rows that describe a request rather than the + * conversation. + * + * They are the large majority of `core_agent_run_events` and none of them + * reach the model, so a bundle meant for another machine can leave them. + * A snapshot or backup keeps them: incompleteness is not a property anyone + * asks a backup for. + */ + omitDiagnostics?: boolean; + + // Every option above defaults to the behaviour this function had before it + // learned to make portable bundles, so its existing callers are unchanged. } export async function assertSessionBundleRootLayout( @@ -114,10 +185,18 @@ export async function planSessionBundleExport( assertRootsSeparate(stateRoot, destinationRoot, false); assertRootsSeparate(configRoot, destinationRoot, false); - const databasePath = resolve(stateRoot, OPERATIONAL_STATE_DATABASE_NAME); + // Everything below is derived from `input.databasePath` -- the private copy + // the caller has already taken -- and never from the live database. The + // artifact and context locks do not fence ordinary Session and runtime + // writers, so a subtree, an artifact list and a manifest read from the live + // file would describe a different moment than the database that ships. + const databasePath = input.databasePath ?? resolve(stateRoot, OPERATIONAL_STATE_DATABASE_NAME); await assertRegularFile(databasePath, OPERATIONAL_STATE_DATABASE_NAME); const database = new DatabaseSync(databasePath, { readOnly: true }); let artifacts: ArtifactRecord[]; + let sessionIds: string[]; + let sourceSchema: Record; + let connection: { llmConnectionSlug: string; model: string }; try { const session = database .prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?') @@ -128,12 +207,26 @@ export async function planSessionBundleExport( `Session bundle session does not exist: ${input.sessionId}`, ); } + sourceSchema = assertPortableSourceSchema(database); + sessionIds = + input.includeSubtree === true + ? collectSubagentSessionTree(database, input.sessionId) + : [input.sessionId]; + + const placeholders = sessionIds.map(() => '?').join(', '); const rows = database .prepare( - 'SELECT record_json FROM artifact_records WHERE session_id = ? ORDER BY created_at, artifact_id', + `SELECT record_json FROM artifact_records WHERE session_id IN (${placeholders}) ORDER BY created_at, artifact_id`, ) - .all(input.sessionId) as Array<{ record_json?: unknown }>; + .all(...sessionIds) as Array<{ record_json?: unknown }>; artifacts = decodeArtifactRecordJsons(rows.map((row) => row.record_json)); + const route = database + .prepare('SELECT llm_connection_slug, model FROM session_metadata WHERE session_id = ?') + .get(input.sessionId) as { llm_connection_slug?: unknown; model?: unknown }; + connection = { + llmConnectionSlug: String(route?.llm_connection_slug ?? ''), + model: String(route?.model ?? ''), + }; } finally { database.close(); } @@ -149,19 +242,29 @@ export async function planSessionBundleExport( if (artifacts.length > 0) { entries.push({ relativePath: 'artifacts', kind: 'directory', source: 'copy' }); for (const artifact of artifacts) { - if (!isArtifactPathForSession(artifact.relativePath, input.sessionId)) { + if (!sessionIds.some((id) => isArtifactPathForSession(artifact.relativePath, id))) { throw new SessionBundleExportError( 'path_escape', - `Artifact path does not belong to session ${input.sessionId}: ${artifact.relativePath}`, + `Artifact path does not belong to the exported subtree: ${artifact.relativePath}`, ); } const relativePath = `artifacts/${artifact.relativePath}`; - await assertRegularFile(resolve(stateRoot, relativePath), relativePath); + // A record naming bytes the workspace does not have would produce a + // bundle whose own metadata points at nothing. Reported apart from a + // missing state root, which is a different mistake entirely. + await assertRegularFile(resolve(stateRoot, relativePath), relativePath).catch( + (error: unknown) => { + if (error instanceof SessionBundleExportError && error.code === 'invalid_root') { + throw new SessionBundleExportError('missing_entry', error.message, { cause: error }); + } + throw error; + }, + ); entries.push({ relativePath, kind: 'file', source: 'copy' }); } includedEntries.push('artifacts'); } - const contextFiles = await planContextSnapshotFiles(stateRoot, input.sessionId); + const contextFiles = await planContextSnapshotFiles(stateRoot, sessionIds); for (const relativePath of contextFiles) { entries.push({ relativePath, kind: 'file', source: 'context_snapshot' }); } @@ -174,6 +277,9 @@ export async function planSessionBundleExport( configRoot, destinationRoot, sessionId: input.sessionId, + sessionIds, + sourceSchema, + connection, includedEntries, excludedEntries, entries, @@ -185,26 +291,35 @@ export async function exportSessionBundleState( ): Promise { return withOfflineContextSnapshot(input.stateRoot, (contextLocked) => withArtifactWriterLock(input.stateRoot, async (stateRoot) => { - const plan = await planSessionBundleExport({ ...input, stateRoot }); - await assertDestinationMissing(plan.destinationRoot); - const stagingRoot = `${plan.destinationRoot}.${process.pid}.${randomUUID()}.tmp`; + const destinationRoot = resolve(input.destinationRoot); + await assertDestinationMissing(destinationRoot); + const stagingRoot = `${destinationRoot}.${process.pid}.${randomUUID()}.tmp`; try { await mkdir(stagingRoot, { recursive: true, mode: 0o700 }); + // Take the private copy BEFORE anything is read. `lease.backup()` is + // what freezes the content; every decision after this -- schema, the + // subtree, the artifact list, quiescence, the manifest -- is made + // against this one file, so the bundle cannot describe two moments. + const databasePath = resolveInside(stagingRoot, OPERATIONAL_STATE_DATABASE_NAME); + await backupOperationalState(stateRoot, databasePath); + const plan = await planSessionBundleExport({ ...input, stateRoot, databasePath }); for (const entry of plan.entries) { - if (entry.source === 'context_snapshot') continue; + if (entry.source === 'context_snapshot' || entry.source === 'filtered_runtime_sqlite') { + continue; + } const destination = resolveInside(stagingRoot, entry.relativePath); if (entry.kind === 'directory') { await mkdir(destination, { recursive: true }); continue; } await mkdir(dirname(destination), { recursive: true }); - if (entry.source === 'copy') { - await copyFile(resolveInside(plan.stateRoot, entry.relativePath), destination); - } else { - await exportFilteredDatabase(plan.stateRoot, destination, plan.sessionId); - } + await copyArtifactFile(plan.stateRoot, entry.relativePath, destination); } - await copyContextSnapshot(stateRoot, stagingRoot, contextLocked, plan.sessionId); + await filterBackedUpDatabase(databasePath, plan.sessionIds, { + omitDiagnostics: input.omitDiagnostics === true, + requireQuiescent: input.requireQuiescent === true, + }); + await copyContextSnapshot(stateRoot, stagingRoot, contextLocked, plan.sessionIds); await validateContextSnapshot(stagingRoot); await mkdir(dirname(plan.destinationRoot), { recursive: true }); await rename(stagingRoot, plan.destinationRoot); @@ -217,19 +332,109 @@ export async function exportSessionBundleState( ); } -async function exportFilteredDatabase( +/** + * Take the private copy the whole export is derived from. + * + * `require_current` because an export must not migrate what it reads: opening + * the live database the ordinary way upgrades it in place, which turns a + * read-only operation into a write to someone else's workspace and leaves the + * manifest describing a version the source no longer has. + */ +/** + * Copy one artifact without leaving the state root. + * + * Checking the final component is not enough: `artifacts/` can + * itself be a symlink, and `copyFile` follows ancestors — an artifact record + * that decodes perfectly can then pull in a file from outside the workspace. + * Every segment is checked, the final open refuses to follow a link, and the + * bytes are read from that descriptor rather than from the name. + * + * Node has no `openat`, so a segment swapped between its check and the open is + * not closed here. That window is narrowed, not eliminated; closing it needs a + * directory-relative open this runtime does not expose. + */ +async function copyArtifactFile( stateRoot: string, - destinationPath: string, - sessionId: string, + relativePath: string, + destination: string, ): Promise { - const lease = acquireOperationalStateDatabase(stateRoot); + const segments = relativePath.split('/').filter((segment) => segment.length > 0); + let walked = stateRoot; + for (const segment of segments) { + if (segment === '.' || segment === '..') { + throw new SessionBundleExportError('path_escape', `Artifact path segment is not safe`); + } + walked = resolveInside(walked, segment); + const metadata = await lstat(walked).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + }); + if (!metadata) { + throw new SessionBundleExportError('missing_entry', `Missing ${relativePath}`); + } + if (metadata.isSymbolicLink()) { + throw new SessionBundleExportError( + 'symlink', + `Artifact path crosses a symlink at ${segment}`, + ); + } + } + + const handle = await open(walked, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const stats = await handle.stat(); + if (!stats.isFile()) { + throw new SessionBundleExportError( + 'unsupported_entry', + `${relativePath} is not a regular file`, + ); + } + await writeFile(destination, handle.createReadStream()); + } finally { + await handle.close().catch(() => {}); + } +} + +async function backupOperationalState(stateRoot: string, destinationPath: string): Promise { + // A directory with no state database is not a workspace, which is a different + // mistake from a workspace whose schema this build cannot read. + await assertRegularFile( + resolveInside(stateRoot, OPERATIONAL_STATE_DATABASE_NAME), + OPERATIONAL_STATE_DATABASE_NAME, + ); + let lease: ReturnType; + try { + lease = acquireOperationalStateDatabase(stateRoot, { schemaMigration: 'require_current' }); + } catch (error) { + throw new SessionBundleExportError( + 'schema_unsupported', + 'Session bundle source is not at the current schema', + { cause: error }, + ); + } try { await lease.backup(destinationPath); } finally { lease.close(); } +} + +async function filterBackedUpDatabase( + destinationPath: string, + sessionIds: readonly string[], + options: { omitDiagnostics: boolean; requireQuiescent: boolean }, +): Promise { const database = new DatabaseSync(destinationPath); try { + // Quiescence is asserted here, on the copy, not on the live database. + // `lease.backup()` is what freezes the content; a check made before it + // describes a state the bundle may no longer carry, and the artifact and + // context locks held around this do not keep a turn from starting. This is + // the only place where "what was checked" and "what ships" are the same + // bytes. + if (options.requireQuiescent) { + for (const sessionId of sessionIds) assertSessionQuiescent(database, sessionId); + } database.exec('PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE'); const tables = database .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") @@ -244,20 +449,48 @@ async function exportFilteredDatabase( .map((column) => column.name) .filter((name): name is string => typeof name === 'string'), ); - const sessionColumns = ['session_id', 'source_session_id', 'target_session_id'].filter( - (name) => names.has(name), - ); + const sessionColumns = names.has(SESSION_ROW_OWNER_COLUMN) + ? [SESSION_ROW_OWNER_COLUMN] + : SESSION_LINK_COLUMNS.filter((name) => names.has(name)); if (sessionColumns.length > 0) { + // Delete what the subtree does not own, keeping the original + // predicate's shape: a row survives only when EVERY session column it + // has names an exported Session. A link table row with one end outside + // the bundle would otherwise arrive pointing at a Session that is not + // there -- the reference is what makes it a link. + const placeholders = sessionIds.map(() => '?').join(', '); const predicate = sessionColumns - .map((name) => `${quoteIdentifier(name)} <> ?`) + .map((name) => + name === SESSION_ROW_OWNER_COLUMN + ? // An owner that is NULL owns nothing. Such a row cannot be + // attributed to any Session, so it is not this bundle's to + // carry -- keeping it shipped an unattributed usage row. + `(${quoteIdentifier(name)} IS NULL OR ${quoteIdentifier(name)} NOT IN (${placeholders}))` + : // A link endpoint that is NULL names no counterpart, which is + // not the same as naming one outside the bundle. + `(${quoteIdentifier(name)} IS NOT NULL AND ${quoteIdentifier(name)} NOT IN (${placeholders}))`, + ) .join(' OR '); database .prepare(`DELETE FROM ${quoteIdentifier(row.name)} WHERE ${predicate}`) - .run(...sessionColumns.map(() => sessionId)); + .run(...sessionColumns.flatMap(() => sessionIds)); } else if (!PORTABLE_DERIVED_TABLES.has(row.name)) { database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); } } + // Operational rows that describe a REQUEST rather than the conversation. + // None of them reach the model, and in a real Session they are the large + // majority of this table. The two record kinds that do decide what the + // model reads -- history_compact_checkpoint_recorded and + // model_projection_transition_recorded -- are deliberately not here. + if (options.omitDiagnostics) { + database + .prepare(` + DELETE FROM core_agent_run_events + WHERE event_type IN (${SESSION_BUNDLE_OMITTED_EVENT_TYPES.map(() => '?').join(', ')}) + `) + .run(...SESSION_BUNDLE_OMITTED_EVENT_TYPES); + } database .prepare(` DELETE FROM tool_journal_events @@ -297,10 +530,16 @@ async function exportFilteredDatabase( database.exec('COMMIT'); const foreignKeyViolation = database.prepare('PRAGMA foreign_key_check').get(); if (foreignKeyViolation) throw new Error('Filtered session database has dangling references'); - const session = database - .prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?') - .get(sessionId); - if (!session) throw new Error(`Filtered session is missing: ${sessionId}`); + // DELETE frees pages, it does not erase them. Without this the bundle ships + // a file whose freelist still holds the excluded Sessions' bytes -- readable + // by anyone who opens it with something other than SQL. + database.exec('VACUUM'); + for (const sessionId of sessionIds) { + const session = database + .prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?') + .get(sessionId); + if (!session) throw new Error(`Filtered session is missing: ${sessionId}`); + } database.exec('PRAGMA journal_mode = DELETE'); } catch (error) { try { @@ -312,6 +551,59 @@ async function exportFilteredDatabase( } } +/** + * `core_agent_run_events` types the bundle drops. + * + * They record how a request was shaped and how a stream behaved -- diagnostics + * for the machine that produced them, not the conversation. Any type absent + * from this list is carried, including one a later build introduces: an export + * moves rows, it does not interpret them. + */ +export const SESSION_BUNDLE_OMITTED_EVENT_TYPES = [ + 'provider_request_attempt_recorded', + 'provider_request_captured', + 'model_call_attempt_recorded', + 'model_stream_started', + 'model_stream_completed', + 'model_stream_failed', + 'send_diagnostics_recorded', + 'plan_context_resolved', + 'skill_catalog_built', + 'skill_searched', + 'skill_loaded', + 'skill_load_failed', + 'tool_searched', + 'request_composition_resolved', + 'trace_write_failed', +] as const; + +/** + * Ownership, which is not the same thing as naming a Session. + * + * `session_id` says whose row this is. Everything else in this list is a + * Session column only on tables that have no `session_id` -- link tables, whose + * whole content is the pair they join, and which are meaningless when one end + * is outside the bundle. + * + * Keeping the two apart matters. `session_metadata.parent_session_id` is a + * lineage POINTER, not ownership: treating it as ownership deleted the very + * Session being exported whenever its branch source lay outside the subtree. + * And a link table whose columns are spelled `parent_session_id` / + * `child_session_id` -- `subagent_spawns`, the record of which tool call + * spawned each child -- looked Session-less and was emptied wholesale. + * + * Nullable columns count only when set, so a row that names no counterpart is + * not deleted for failing to name one. + */ +const SESSION_ROW_OWNER_COLUMN = 'session_id'; +const SESSION_LINK_COLUMNS = [ + 'source_session_id', + 'target_session_id', + 'parent_session_id', + 'child_session_id', + 'root_session_id', +] as const; + const PORTABLE_GLOBAL_TABLES = new Set([ 'operational_schema_migrations', 'session_metadata_schema', @@ -326,6 +618,125 @@ const PORTABLE_DERIVED_TABLES = new Set([ 'core_interaction_outcomes', ]); +/** + * The Session and its subagent descendants, root first, siblings by id. + * + * `subagent_parent_session_id` is an ordinary column, not a constrained tree: + * nothing stops a row naming itself or an ancestor. Membership is tracked + * rather than assumed, so a cycle ends the walk instead of hanging it, and a + * Session reachable twice is exported once. + */ +/** + * Refuse a source whose schema this build does not read. + * + * The filter runs `DELETE` over whatever tables the database happens to have, + * so a schema this build cannot read produces a bundle whose shape will not + * match what its manifest claims. The operational store is the authority on + * what "current" means -- a private list here went stale the moment a scope was + * added, and reported versions the source did not have. + */ +function assertPortableSourceSchema(database: DatabaseSync): Record { + // The inspector validates every scope and says whether a migration is owed. + // It reports only some of them, so the manifest's numbers come from the + // registry the database keeps -- validated by the authority, reported from + // the source, and neither of them this build's constants. + const inspection = inspectOperationalStateSchema(database); + if (inspection.status !== 'current') { + throw new SessionBundleExportError( + 'schema_unsupported', + 'Session bundle source schema is not current', + ); + } + const registered: Record = {}; + for (const row of database + .prepare('SELECT scope, version FROM operational_schema_migrations ORDER BY scope') + .all() as Array<{ scope?: unknown; version?: unknown }>) { + if (typeof row.scope === 'string' && typeof row.version === 'number') { + registered[row.scope] = row.version; + } + } + return registered; +} + +/** + * Refuse a Session that is mid-turn. + * + * The writer locks around this export keep other writers out from here on, but + * they say nothing about work that was already in flight when it started. A + * partial stream snapshot, a tool dispatched without a settled result, or an + * invocation that never reached a terminal event each mean the bundle would + * carry half of something -- and half a turn is not a Session. + */ +function assertSessionQuiescent(database: DatabaseSync, sessionId: string): void { + const partials = database + .prepare('SELECT COUNT(*) AS count FROM runtime_partial_snapshots WHERE session_id = ?') + .get(sessionId) as { count?: unknown }; + if (Number(partials.count ?? 0) > 0) { + throw new SessionBundleExportError( + 'session_active', + `Session has a partial stream snapshot: ${sessionId}`, + ); + } + // A tool that crossed the dispatch boundary and never settled. Its + // invocation can carry a terminal event -- the run failed -- while the + // operation itself is still prepared, so an invocation check does not see it. + // Same predicate the runtime store uses, so "unsettled" means one thing. + const unsettledOperations = database + .prepare(` + SELECT COUNT(*) AS count FROM tool_operations + WHERE current_state = 'prepared' + AND result_event_id IS NULL + AND dispatch_event_id IS NOT NULL + AND call_event_id IN (SELECT event_id FROM runtime_events WHERE session_id = ?) + `) + .get(sessionId) as { count?: unknown }; + if (Number(unsettledOperations.count ?? 0) > 0) { + throw new SessionBundleExportError( + 'session_active', + `Session has an unsettled tool operation: ${sessionId}`, + ); + } + const openInvocations = database + .prepare(` + SELECT COUNT(*) AS count FROM ( + SELECT DISTINCT invocation_id FROM runtime_events AS invocations + WHERE session_id = ? + AND NOT EXISTS ( + SELECT 1 FROM runtime_events AS terminal + WHERE terminal.invocation_id = invocations.invocation_id + AND ${TERMINAL_RUNTIME_EVENT_SQL} + ) + ) + `) + .get(sessionId) as { count?: unknown }; + if (Number(openInvocations.count ?? 0) > 0) { + throw new SessionBundleExportError( + 'session_active', + `Session has an invocation with no terminal event: ${sessionId}`, + ); + } +} + +function collectSubagentSessionTree(database: DatabaseSync, rootSessionId: string): string[] { + const ordered: string[] = []; + const seen = new Set([rootSessionId]); + const queue = [rootSessionId]; + const children = database.prepare( + 'SELECT session_id FROM session_metadata WHERE subagent_parent_session_id = ? ORDER BY session_id', + ); + while (queue.length > 0) { + const sessionId = queue.shift() as string; + ordered.push(sessionId); + for (const row of children.all(sessionId) as Array<{ session_id?: unknown }>) { + const childId = row.session_id; + if (typeof childId !== 'string' || seen.has(childId)) continue; + seen.add(childId); + queue.push(childId); + } + } + return ordered; +} + export function isArtifactPathForSession(relativePath: string, sessionId: string): boolean { const parts = relativePath.split(/[\\/]+/); return (