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