From 8d1d7aafd30e036cab5ea15a6c24d46a85cdc71f Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 10 Sep 2026 00:09:29 +0800 Subject: [PATCH 1/5] feat(storage): let the Session export carry a subtree and a portable scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `exportSessionBundleState()` already prepares a Session for a bundle: a consistent database snapshot through `lease.backup()`, the context and artifact writer locks around it, a filter that deletes what the Session does not own rather than selecting what it does, the context-offload closure, and `PRAGMA foreign_key_check` to prove the result. Nothing calls it for a portable bundle yet, and four things are missing before something can. **Subtree** (`includeSubtree`). A subagent child holds the result of a tool call its parent made, so a bundle of the parent alone is a conversation with a hole in it. The walk tracks membership: `subagent_parent_session_id` is an ordinary column and nothing stops a row from naming itself or an ancestor. **Quiescence** (`requireQuiescent`). A partial stream snapshot or an invocation with no terminal event refuses the export, and one active child refuses the tree. The check runs on the backed-up copy rather than the live database, because the locks held here do not stop a turn from starting and `lease.backup()` is what actually freezes the content — this is the only place where what was checked and what ships are the same bytes. **Diagnostics** (`omitDiagnostics`). `core_agent_run_events` rows that describe a request rather than the conversation are dropped. In a real Session they are 98% of that table and none of them reach the model. The two kinds that decide what the model reads — `history_compact_checkpoint_recorded` and `model_projection_transition_recorded` — are deliberately not on that list, and neither is any type this build has never seen: an export moves rows, it does not interpret them. **Schema validation.** 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 registry the database keeps about itself is now checked before the plan is made, and its versions are what the plan reports. Two corrections to the filter itself, both of which applied before this change: - `SESSION_OWNING_COLUMNS` names each spelling the schema uses. The filter recognised three, and `subagent_spawns` uses `parent_session_id` and `child_session_id` — so it looked Session-less and was emptied wholesale, taking with it the record of which tool call spawned each child. Nullable columns count only when set. - A missing planned entry reports `missing_entry` rather than `invalid_root`. A record naming bytes the workspace does not have and a state root that is not there are different mistakes. Every option defaults to this function's previous behaviour, so `production-session-snapshot` is unchanged. Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- packages/storage/package.json | 5 +- .../storage/src/context-offload-snapshot.ts | 22 +- packages/storage/src/session-bundle-policy.ts | 315 ++++++++++++++++-- 3 files changed, 316 insertions(+), 26 deletions(-) diff --git a/packages/storage/package.json b/packages/storage/package.json index 202f195ecf..6bde3f2993 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", @@ -61,7 +63,8 @@ "./work-board-store": "./dist/work-board-store.js", "./workspace-identity": "./dist/workspace-identity.js", "./workspace-root": "./dist/workspace-root.js", - "./write-queue": "./dist/write-queue.js" + "./write-queue": "./dist/write-queue.js", + "./artifact-metadata-codec": "./dist/artifact-metadata-codec.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", 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..7b92c5044e 100644 --- a/packages/storage/src/session-bundle-policy.ts +++ b/packages/storage/src/session-bundle-policy.ts @@ -37,7 +37,13 @@ import { import { acquireOperationalStateDatabase, OPERATIONAL_STATE_DATABASE_NAME, + OPERATIONAL_STATE_SCHEMA_VERSION, } from './operational-state-store.js'; +import { TERMINAL_RUNTIME_EVENT_SQL } from './runtime-transcript-query.js'; +import { SQLITE_ARTIFACT_SCHEMA_VERSION } from './sqlite-artifact-schema.js'; +import { SQLITE_CORE_EXECUTION_SCHEMA_VERSION } from './sqlite-core-execution-schema.js'; +import { SQLITE_RUNTIME_SCHEMA_VERSION } from './sqlite-runtime-schema.js'; +import { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from './sqlite-session-metadata-schema.js'; import { isSafeStorageId } from './storage-id.js'; export const SESSION_BUNDLE_STATE_ENTRIES = [ @@ -55,7 +61,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 +97,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 +122,34 @@ export interface SessionBundleExportPlan { export interface SessionBundleExportInput extends SessionBundleRootLayoutInput { destinationRoot: string; sessionId: 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( @@ -118,6 +175,9 @@ export async function planSessionBundleExport( 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 +188,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 +223,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 +258,9 @@ export async function planSessionBundleExport( configRoot, destinationRoot, sessionId: input.sessionId, + sessionIds, + sourceSchema, + connection, includedEntries, excludedEntries, entries, @@ -201,10 +288,13 @@ export async function exportSessionBundleState( if (entry.source === 'copy') { await copyFile(resolveInside(plan.stateRoot, entry.relativePath), destination); } else { - await exportFilteredDatabase(plan.stateRoot, destination, plan.sessionId); + await exportFilteredDatabase(plan.stateRoot, destination, plan.sessionIds, { + omitDiagnostics: input.omitDiagnostics === true, + requireQuiescent: input.requireQuiescent === true, + }); } } - await copyContextSnapshot(stateRoot, stagingRoot, contextLocked, plan.sessionId); + await copyContextSnapshot(stateRoot, stagingRoot, contextLocked, plan.sessionIds); await validateContextSnapshot(stagingRoot); await mkdir(dirname(plan.destinationRoot), { recursive: true }); await rename(stagingRoot, plan.destinationRoot); @@ -220,7 +310,8 @@ export async function exportSessionBundleState( async function exportFilteredDatabase( stateRoot: string, destinationPath: string, - sessionId: string, + sessionIds: readonly string[], + options: { omitDiagnostics: boolean; requireQuiescent: boolean }, ): Promise { const lease = acquireOperationalStateDatabase(stateRoot); try { @@ -230,6 +321,15 @@ async function exportFilteredDatabase( } 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 +344,40 @@ 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 = SESSION_OWNING_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) => + `(${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 +417,12 @@ 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}`); + 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 +434,63 @@ 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; + +/** Schema scopes whose tables a bundle carries, and the versions this build reads. */ +const PORTABLE_SOURCE_SCHEMA: ReadonlyMap = new Map([ + ['runtime', SQLITE_RUNTIME_SCHEMA_VERSION], + ['session_metadata', SQLITE_SESSION_METADATA_SCHEMA_VERSION], + ['core_execution', SQLITE_CORE_EXECUTION_SCHEMA_VERSION], + ['artifact', SQLITE_ARTIFACT_SCHEMA_VERSION], + ['operational', OPERATIONAL_STATE_SCHEMA_VERSION], +]); + +/** + * Columns that say which Session a row belongs to. + * + * A row survives the filter only when every one of these it carries names an + * exported Session. The list has to name each spelling the schema actually + * uses: a table whose ownership column is called something else looks + * Session-less to the filter and is emptied wholesale, which is how + * `subagent_spawns` -- the record of which tool call spawned a child -- was + * being dropped from a subtree bundle whose child Sessions were carried. + * + * Nullable columns are matched only when set, so a row with no parent is not + * deleted for failing to name one. + */ +const SESSION_OWNING_COLUMNS = [ + 'session_id', + '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 +505,104 @@ 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 below runs `DELETE` over whatever tables the database happens to + * have. On a schema this build does not know, that produces a bundle whose + * shape does not match what its manifest will claim -- a false compatibility + * signal for whoever imports it. The registry is the database's own account of + * itself, so it is what gets checked. + */ +function assertPortableSourceSchema(database: DatabaseSync): Record { + const registered = new Map(); + for (const row of database + .prepare('SELECT scope, version FROM operational_schema_migrations') + .all() as Array<{ scope?: unknown; version?: unknown }>) { + if (typeof row.scope === 'string' && typeof row.version === 'number') { + registered.set(row.scope, row.version); + } + } + for (const [scope, supported] of PORTABLE_SOURCE_SCHEMA) { + const version = registered.get(scope); + if (version !== supported) { + throw new SessionBundleExportError( + 'schema_unsupported', + `Session bundle source schema ${scope} is ${version ?? 'absent'}, expected ${supported}`, + ); + } + } + return Object.fromEntries(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}`, + ); + } + 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 ( From 0a32c74e79718f8a1cbbe81487950aa2881956c9 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 10 Sep 2026 00:09:47 +0800 Subject: [PATCH 2/5] feat(runtime): export one Session as a portable bundle file Seal a prepared Session into a single `.maka-session` file, and expose it as `maka session-export` so the round trip can be exercised before any surface commits to it. The preparation is `exportSessionBundleState()`; this is the thin part on top of it. What it adds is the manifest the codec carries as the bundle's state identity: the Sessions inside, the schema versions the SOURCE database registers, the state entries the policy included and the ones it left behind, and the connection by slug and model. No credential is carried. The importing side resolves the slug against its own catalog, and where it cannot, the existing stale-connection state already says so. This means application configuration, not that no secret can appear anywhere in a bundle: history is carried byte for byte, and a user message or tool output may contain one. Failures are distinct because a script should be able to tell them apart: a directory that was never a workspace reports `workspace_not_found` rather than `session_not_found`, since a workspace holding no Sessions still has a database and a mistyped path must not read as an empty catalog. Extracting this archive is not importing a Session, so nothing here claims model-history equivalence. That acceptance test belongs with the import PR. Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- packages/cli/src/cli-core.ts | 7 + packages/cli/src/session-export-command.ts | 72 ++ packages/runtime/package.json | 1 + .../src/__tests__/session-export.test.ts | 742 ++++++++++++++++++ packages/runtime/src/session-export.ts | 240 ++++++ 5 files changed, 1062 insertions(+) create mode 100644 packages/cli/src/session-export-command.ts create mode 100644 packages/runtime/src/__tests__/session-export.test.ts create mode 100644 packages/runtime/src/session-export.ts 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..e2ad96c4aa --- /dev/null +++ b/packages/runtime/src/__tests__/session-export.test.ts @@ -0,0 +1,742 @@ +/* + * 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 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(); + } + }), +); + +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]); + }), +); diff --git a/packages/runtime/src/session-export.ts b/packages/runtime/src/session-export.ts new file mode 100644 index 0000000000..8c5aa40db1 --- /dev/null +++ b/packages/runtime/src/session-export.ts @@ -0,0 +1,240 @@ +/* + * 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 } }; + } + 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); +} From 727137cee2e8a004aaaa89ac808a029c61db17fc Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 10 Sep 2026 00:25:31 +0800 Subject: [PATCH 3/5] fix(storage): separate row ownership from a lineage pointer Migration starts wherever it is pointed and takes the subtree hanging below, so the named Session is often not a top-level one. That exposed a conflation in the filter: `session_id` says whose row this is, while `parent_session_id` on `session_metadata` names a DIFFERENT Session it descends from. Reading the second as ownership deleted the very Session being exported. A branch Session whose source lay outside the subtree failed its own export -- its row was dropped for naming a Session the bundle does not contain, which is exactly what a bundle rooted here is supposed to leave behind. Ownership is `session_id` when the table has one. The other columns are Session columns only on tables that have no `session_id`: link tables, whose whole content is the pair they join and which mean nothing when one end is missing. `subagent_spawns` is the one that matters, and it is why those columns are read at all. Covered by exporting from four kinds of node -- a top-level parent, a mid-tree child whose parent stays behind, a leaf, and a branch Session whose source stays behind -- each carrying exactly its own subtree. Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- .../src/__tests__/session-export.test.ts | 100 ++++++++++++++++++ packages/storage/src/session-bundle-policy.ts | 31 +++--- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/packages/runtime/src/__tests__/session-export.test.ts b/packages/runtime/src/__tests__/session-export.test.ts index e2ad96c4aa..2ec857a748 100644 --- a/packages/runtime/src/__tests__/session-export.test.ts +++ b/packages/runtime/src/__tests__/session-export.test.ts @@ -165,6 +165,43 @@ function exportedArtifactPath(hydration: SessionBundleHydration, relativePath: s 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, @@ -608,6 +645,69 @@ test( }), ); +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[] = []; diff --git a/packages/storage/src/session-bundle-policy.ts b/packages/storage/src/session-bundle-policy.ts index 7b92c5044e..0553569b8e 100644 --- a/packages/storage/src/session-bundle-policy.ts +++ b/packages/storage/src/session-bundle-policy.ts @@ -344,7 +344,9 @@ async function exportFilteredDatabase( .map((column) => column.name) .filter((name): name is string => typeof name === 'string'), ); - const sessionColumns = SESSION_OWNING_COLUMNS.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 @@ -470,20 +472,25 @@ const PORTABLE_SOURCE_SCHEMA: ReadonlyMap = new Map([ ]); /** - * Columns that say which Session a row belongs to. + * Ownership, which is not the same thing as naming a Session. * - * A row survives the filter only when every one of these it carries names an - * exported Session. The list has to name each spelling the schema actually - * uses: a table whose ownership column is called something else looks - * Session-less to the filter and is emptied wholesale, which is how - * `subagent_spawns` -- the record of which tool call spawned a child -- was - * being dropped from a subtree bundle whose child Sessions were carried. + * `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. * - * Nullable columns are matched only when set, so a row with no parent is not - * deleted for failing to name one. + * 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_OWNING_COLUMNS = [ - 'session_id', +const SESSION_ROW_OWNER_COLUMN = 'session_id'; +const SESSION_LINK_COLUMNS = [ 'source_session_id', 'target_session_id', 'parent_session_id', From f576eade4299c8d9b9d616087aa0d3d44fea701f Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 10 Sep 2026 00:46:18 +0800 Subject: [PATCH 4/5] fix(storage): stop publishing an entrypoint nothing imports `./artifact-metadata-codec` was exported for a throwaway probe while chasing an artifact that would not appear in a bundle. The probe is gone; the promise it needed should not outlive it. Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- packages/storage/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/storage/package.json b/packages/storage/package.json index 6bde3f2993..4fc69998f1 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -63,8 +63,7 @@ "./work-board-store": "./dist/work-board-store.js", "./workspace-identity": "./dist/workspace-identity.js", "./workspace-root": "./dist/workspace-root.js", - "./write-queue": "./dist/write-queue.js", - "./artifact-metadata-codec": "./dist/artifact-metadata-codec.js" + "./write-queue": "./dist/write-queue.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", From dd30dc313d3680b4cea672d73036ae947ced6801 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Thu, 10 Sep 2026 10:59:15 +0800 Subject: [PATCH 5/5] fix(storage): derive the whole export from one private copy Six findings from review, five of them blocking. All but one share a root: the plan was read from the live database while a different, later copy was what actually shipped. **One moment, not two.** The backup is taken first, and the subtree, schema, artifact list, quiescence check and manifest are all derived from that copy. The artifact and context locks do not fence ordinary Session and runtime writers, so a child Session created between the plan and the backup produced a database, a set of files and a manifest describing different instants. **A read-only export no longer migrates its source.** The default open upgrades the database in place; the backup now demands a current schema instead. And the schema check is the operational store's own inspector rather than a private list, which was missing two of the seven scopes and could report versions the source did not have. **Deleted bytes leave the file.** `DELETE` frees pages without erasing them, so the bundle shipped a database whose freelist still held excluded Sessions' content -- invisible to SQL, plain in the bytes. The filtered copy is vacuumed. **A row whose owner is NULL owns nothing.** The nullable guard was meant for link endpoints, where naming no counterpart is not the same as naming one outside the bundle. Applied to the owner column it kept unattributable rows. **Symlinks are refused along the whole path.** `artifacts/` can itself be a link, and `copyFile` follows ancestors, so an artifact record that decodes perfectly could pull in a file from outside the workspace. Every segment is checked and the bytes are read from a descriptor opened with `O_NOFOLLOW`. Node has no `openat`, so a segment swapped between its check and the open is narrowed, not closed; the comment says so. **An unsettled tool operation is not a complete Session.** An invocation can carry a terminal `failed` event while its operation is still prepared, so an invocation check does not see it. Quiescence now uses the same predicate the runtime store uses, so the word means one thing. The remaining finding is in the runtime layer: the packer publishes atomically and can lose the race the precheck cannot cover, and its `destination_exists` was being flattened into a generic IO failure, giving the CLI a different exit code for the same outcome. Five adversarial tests, one per blocking finding, each verified to fail when its fix is removed. The freelist one inspects the bundled file's bytes rather than querying it, because that is the difference the finding is about. --- .../src/__tests__/session-export.test.ts | 195 +++++++++++++++ packages/runtime/src/session-export.ts | 6 + packages/storage/src/session-bundle-policy.ts | 235 ++++++++++++++---- 3 files changed, 382 insertions(+), 54 deletions(-) diff --git a/packages/runtime/src/__tests__/session-export.test.ts b/packages/runtime/src/__tests__/session-export.test.ts index 2ec857a748..bcd89d488e 100644 --- a/packages/runtime/src/__tests__/session-export.test.ts +++ b/packages/runtime/src/__tests__/session-export.test.ts @@ -840,3 +840,198 @@ test( 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 index 8c5aa40db1..fdf4b7dc04 100644 --- a/packages/runtime/src/session-export.ts +++ b/packages/runtime/src/session-export.ts @@ -188,6 +188,12 @@ export async function exportSessionBundle( 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); diff --git a/packages/storage/src/session-bundle-policy.ts b/packages/storage/src/session-bundle-policy.ts index 0553569b8e..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,14 +48,10 @@ import { } from './sqlite-context-offload-store.js'; import { acquireOperationalStateDatabase, + inspectOperationalStateSchema, OPERATIONAL_STATE_DATABASE_NAME, - OPERATIONAL_STATE_SCHEMA_VERSION, } from './operational-state-store.js'; import { TERMINAL_RUNTIME_EVENT_SQL } from './runtime-transcript-query.js'; -import { SQLITE_ARTIFACT_SCHEMA_VERSION } from './sqlite-artifact-schema.js'; -import { SQLITE_CORE_EXECUTION_SCHEMA_VERSION } from './sqlite-core-execution-schema.js'; -import { SQLITE_RUNTIME_SCHEMA_VERSION } from './sqlite-runtime-schema.js'; -import { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from './sqlite-session-metadata-schema.js'; import { isSafeStorageId } from './storage-id.js'; export const SESSION_BUNDLE_STATE_ENTRIES = [ @@ -122,6 +130,12 @@ 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. * @@ -171,7 +185,12 @@ 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[]; @@ -272,28 +291,34 @@ 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.sessionIds, { - omitDiagnostics: input.omitDiagnostics === true, - requireQuiescent: input.requireQuiescent === true, - }); - } + await copyArtifactFile(plan.stateRoot, entry.relativePath, destination); } + 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 }); @@ -307,18 +332,98 @@ 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, - sessionIds: readonly string[], - options: { omitDiagnostics: boolean; requireQuiescent: boolean }, + 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. @@ -355,9 +460,15 @@ async function exportFilteredDatabase( // there -- the reference is what makes it a link. const placeholders = sessionIds.map(() => '?').join(', '); const predicate = sessionColumns - .map( - (name) => - `(${quoteIdentifier(name)} IS NOT NULL AND ${quoteIdentifier(name)} NOT IN (${placeholders}))`, + .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 @@ -419,6 +530,10 @@ 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'); + // 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 = ?') @@ -462,15 +577,6 @@ export const SESSION_BUNDLE_OMITTED_EVENT_TYPES = [ 'trace_write_failed', ] as const; -/** Schema scopes whose tables a bundle carries, and the versions this build reads. */ -const PORTABLE_SOURCE_SCHEMA: ReadonlyMap = new Map([ - ['runtime', SQLITE_RUNTIME_SCHEMA_VERSION], - ['session_metadata', SQLITE_SESSION_METADATA_SCHEMA_VERSION], - ['core_execution', SQLITE_CORE_EXECUTION_SCHEMA_VERSION], - ['artifact', SQLITE_ARTIFACT_SCHEMA_VERSION], - ['operational', OPERATIONAL_STATE_SCHEMA_VERSION], -]); - /** * Ownership, which is not the same thing as naming a Session. * @@ -523,31 +629,33 @@ const PORTABLE_DERIVED_TABLES = new Set([ /** * Refuse a source whose schema this build does not read. * - * The filter below runs `DELETE` over whatever tables the database happens to - * have. On a schema this build does not know, that produces a bundle whose - * shape does not match what its manifest will claim -- a false compatibility - * signal for whoever imports it. The registry is the database's own account of - * itself, so it is what gets checked. + * 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 { - const registered = new Map(); + // 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') + .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.set(row.scope, row.version); - } - } - for (const [scope, supported] of PORTABLE_SOURCE_SCHEMA) { - const version = registered.get(scope); - if (version !== supported) { - throw new SessionBundleExportError( - 'schema_unsupported', - `Session bundle source schema ${scope} is ${version ?? 'absent'}, expected ${supported}`, - ); + registered[row.scope] = row.version; } } - return Object.fromEntries(registered); + return registered; } /** @@ -569,6 +677,25 @@ function assertSessionQuiescent(database: DatabaseSync, sessionId: string): void `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 (