blob: 2be7301e21c804cdc54931934cf64a1e487fd88c [file]
import { copyFile, lstat, mkdir, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type { ArtifactRecord } from '@maka/core/artifacts';
import { decodeArtifactRecordJsons } from './artifact-metadata-codec.js';
import { withArtifactWriterLock } from './artifact-writer-lock.js';
import {
acquireOperationalStateDatabase,
OPERATIONAL_STATE_DATABASE_NAME,
} from './operational-state-store.js';
export const SESSION_BUNDLE_STATE_ENTRIES = ['artifacts', OPERATIONAL_STATE_DATABASE_NAME] as const;
export const SESSION_BUNDLE_PROTECTED_ENTRIES = [] as const;
export type SessionBundleExportErrorCode =
| 'invalid_root'
| 'overlapping_roots'
| 'symlink'
| 'path_escape'
| 'unknown_entry'
| 'unsupported_entry'
| 'destination_not_empty';
export class SessionBundleExportError extends Error {
constructor(
readonly code: SessionBundleExportErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'SessionBundleExportError';
}
}
export interface SessionBundleRootLayoutInput {
stateRoot: string;
configRoot: string;
allowShared?: boolean;
}
export interface SessionBundleExportPlanEntry {
relativePath: string;
kind: 'file' | 'directory';
source: 'copy' | 'filtered_runtime_sqlite';
}
export interface SessionBundleExportPlan {
stateRoot: string;
configRoot: string;
destinationRoot: string;
sessionId: string;
includedEntries: string[];
excludedEntries: string[];
entries: SessionBundleExportPlanEntry[];
}
export interface SessionBundleExportInput extends SessionBundleRootLayoutInput {
destinationRoot: string;
sessionId: string;
}
export async function assertSessionBundleRootLayout(
input: SessionBundleRootLayoutInput,
): Promise<void> {
const stateRoot = await canonicalRoot(input.stateRoot, 'state');
const configRoot = await canonicalRoot(input.configRoot, 'config', true);
assertRootsSeparate(stateRoot, configRoot, input.allowShared === true);
}
export async function planSessionBundleExport(
input: SessionBundleExportInput,
): Promise<SessionBundleExportPlan> {
assertSafeSessionId(input.sessionId);
const stateRoot = await canonicalRoot(input.stateRoot, 'state');
const configRoot = await canonicalRoot(input.configRoot, 'config', true);
const destinationRoot = resolve(input.destinationRoot);
assertRootsSeparate(stateRoot, configRoot, input.allowShared === true);
assertRootsSeparate(stateRoot, destinationRoot, false);
assertRootsSeparate(configRoot, destinationRoot, false);
const databasePath = resolve(stateRoot, OPERATIONAL_STATE_DATABASE_NAME);
await assertRegularFile(databasePath, OPERATIONAL_STATE_DATABASE_NAME);
const database = new DatabaseSync(databasePath, { readOnly: true });
let artifacts: ArtifactRecord[];
try {
const session = database
.prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?')
.get(input.sessionId);
if (!session) {
throw new SessionBundleExportError(
'invalid_root',
`Session bundle session does not exist: ${input.sessionId}`,
);
}
const rows = database
.prepare(
'SELECT record_json FROM artifact_records WHERE session_id = ? ORDER BY created_at, storage_key',
)
.all(input.sessionId) as Array<{ record_json?: unknown }>;
artifacts = decodeArtifactRecordJsons(rows.map((row) => row.record_json));
} finally {
database.close();
}
const entries: SessionBundleExportPlanEntry[] = [
{
relativePath: OPERATIONAL_STATE_DATABASE_NAME,
kind: 'file',
source: 'filtered_runtime_sqlite',
},
];
const includedEntries = [OPERATIONAL_STATE_DATABASE_NAME];
if (artifacts.length > 0) {
entries.push({ relativePath: 'artifacts', kind: 'directory', source: 'copy' });
for (const artifact of artifacts) {
if (!isArtifactPathForSession(artifact.relativePath, input.sessionId)) {
throw new SessionBundleExportError(
'path_escape',
`Artifact path does not belong to session ${input.sessionId}: ${artifact.relativePath}`,
);
}
const relativePath = `artifacts/${artifact.relativePath}`;
await assertRegularFile(resolve(stateRoot, relativePath), relativePath);
entries.push({ relativePath, kind: 'file', source: 'copy' });
}
includedEntries.push('artifacts');
}
const allowed = new Set<string>([...SESSION_BUNDLE_STATE_ENTRIES]);
const excludedEntries = (await readdir(stateRoot)).filter((entry) => !allowed.has(entry)).sort();
return {
stateRoot,
configRoot,
destinationRoot,
sessionId: input.sessionId,
includedEntries,
excludedEntries,
entries,
};
}
export async function exportSessionBundleState(
input: SessionBundleExportInput,
): Promise<SessionBundleExportPlan> {
return withArtifactWriterLock(input.stateRoot, async (stateRoot) => {
const plan = await planSessionBundleExport({ ...input, stateRoot });
await assertDestinationMissing(plan.destinationRoot);
const stagingRoot = `${plan.destinationRoot}.${process.pid}.${randomUUID()}.tmp`;
try {
await mkdir(stagingRoot, { recursive: true });
for (const entry of plan.entries) {
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 mkdir(dirname(plan.destinationRoot), { recursive: true });
await rename(stagingRoot, plan.destinationRoot);
return plan;
} catch (error) {
await rm(stagingRoot, { recursive: true, force: true }).catch(() => {});
throw error;
}
});
}
async function exportFilteredDatabase(
stateRoot: string,
destinationPath: string,
sessionId: string,
): Promise<void> {
const lease = acquireOperationalStateDatabase(stateRoot);
try {
await lease.backup(destinationPath);
} finally {
lease.close();
}
const database = new DatabaseSync(destinationPath);
try {
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_%'")
.all() as Array<{ name?: unknown }>;
for (const row of tables) {
if (typeof row.name !== 'string' || PORTABLE_GLOBAL_TABLES.has(row.name)) continue;
const columns = database
.prepare(`PRAGMA table_info(${quoteIdentifier(row.name)})`)
.all() as Array<{ name?: unknown }>;
const names = new Set(
columns
.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),
);
if (sessionColumns.length > 0) {
const predicate = sessionColumns
.map((name) => `${quoteIdentifier(name)} <> ?`)
.join(' OR ');
database
.prepare(`DELETE FROM ${quoteIdentifier(row.name)} WHERE ${predicate}`)
.run(...sessionColumns.map(() => sessionId));
} else if (!PORTABLE_DERIVED_TABLES.has(row.name)) {
database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`);
}
}
database
.prepare(`
DELETE FROM tool_journal_events
WHERE NOT EXISTS (
SELECT 1 FROM runtime_events
WHERE runtime_events.invocation_id = tool_journal_events.invocation_id
)
`)
.run();
database
.prepare(`
DELETE FROM runtime_partial_segments
WHERE NOT EXISTS (
SELECT 1 FROM runtime_partial_snapshots
WHERE runtime_partial_snapshots.stream_key = runtime_partial_segments.stream_key
)
`)
.run();
database
.prepare(`
DELETE FROM tool_operations
WHERE NOT EXISTS (
SELECT 1 FROM runtime_events
WHERE runtime_events.invocation_id = tool_operations.invocation_id
)
`)
.run();
database
.prepare(`
DELETE FROM core_interaction_outcomes
WHERE NOT EXISTS (
SELECT 1 FROM core_interaction_requests
WHERE core_interaction_requests.request_id = core_interaction_outcomes.request_id
)
`)
.run();
database
.prepare(`
DELETE FROM core_message_host_epochs
WHERE NOT EXISTS (
SELECT 1 FROM core_message_receipts
WHERE core_message_receipts.host_epoch = core_message_host_epochs.host_epoch
)
`)
.run();
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}`);
} catch (error) {
try {
database.exec('ROLLBACK');
} catch {}
throw error;
} finally {
database.close();
}
}
const PORTABLE_GLOBAL_TABLES = new Set([
'operational_schema_migrations',
'session_metadata_schema',
'runtime_capabilities',
'session_catalog_state',
'automation_authority_state',
]);
const PORTABLE_DERIVED_TABLES = new Set([
'tool_journal_events',
'tool_operations',
'runtime_partial_segments',
'core_interaction_outcomes',
'core_message_host_epochs',
]);
export function isArtifactPathForSession(relativePath: string, sessionId: string): boolean {
const parts = relativePath.split(/[\\/]+/);
return (
parts.length >= 2 &&
parts[0] === sessionId &&
parts.every((part) => part.length > 0 && part !== '.' && part !== '..')
);
}
async function canonicalRoot(path: string, role: string, allowMissing = false): Promise<string> {
const requested = resolve(path);
try {
const metadata = await lstat(requested);
if (metadata.isSymbolicLink()) {
throw new SessionBundleExportError('symlink', `${role} root cannot be a symlink`);
}
if (!metadata.isDirectory()) {
throw new SessionBundleExportError('invalid_root', `${role} root is not a directory`);
}
return realpath(requested);
} catch (error) {
if (allowMissing && (error as NodeJS.ErrnoException).code === 'ENOENT') return requested;
if (error instanceof SessionBundleExportError) throw error;
throw new SessionBundleExportError('invalid_root', `${role} root does not exist`, {
cause: error,
});
}
}
async function assertRegularFile(path: string, label: string): Promise<void> {
const metadata = await lstat(path).catch((error) => {
throw new SessionBundleExportError('invalid_root', `Missing ${label}`, { cause: error });
});
if (metadata.isSymbolicLink()) {
throw new SessionBundleExportError('symlink', `${label} cannot be a symlink`);
}
if (!metadata.isFile()) {
throw new SessionBundleExportError('unsupported_entry', `${label} is not a regular file`);
}
}
async function assertDestinationMissing(path: string): Promise<void> {
try {
await lstat(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
throw new SessionBundleExportError(
'destination_not_empty',
`Session bundle destination already exists: ${path}`,
);
}
function resolveInside(root: string, path: string): string {
const candidate = resolve(root, path);
const rel = relative(root, candidate);
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new SessionBundleExportError('path_escape', `Path escapes bundle root: ${path}`);
}
return candidate;
}
function assertRootsSeparate(left: string, right: string, allowSame: boolean): void {
if (left === right) {
if (allowSame) return;
throw new SessionBundleExportError('overlapping_roots', 'Session bundle roots overlap');
}
const leftToRight = relative(left, right);
const rightToLeft = relative(right, left);
if (
(!leftToRight.startsWith('..') && !isAbsolute(leftToRight)) ||
(!rightToLeft.startsWith('..') && !isAbsolute(rightToLeft))
) {
throw new SessionBundleExportError('overlapping_roots', 'Session bundle roots overlap');
}
}
function assertSafeSessionId(sessionId: string): void {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) {
throw new SessionBundleExportError('invalid_root', `Invalid session id: ${sessionId}`);
}
}
function quoteIdentifier(value: string): string {
return `"${value.replaceAll('"', '""')}"`;
}