blob: 1e86d137de226ad8d5b1d455440c7e43ef18a5ec [file]
/*
* 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 type { StorageRootLease } from '@maka/storage/root-authority';
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<string, number>;
/** 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;
/**
* Storage Root authority the caller already holds.
*
* The Runtime Host takes it at startup and holds it for its lifetime, and
* the lock is an election that refuses a second hold -- so the Host can only
* do this by lending what it has. A caller with no authority of its own, the
* CLI included, omits it and the authority is elected as before.
*/
lease?: StorageRootLease<'interactive', 'write'>;
}
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<ExportSessionBundleResult> {
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,
...(input.lease ? { lease: input.lease } : {}),
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<boolean> {
return stat(path)
.then((metadata) => metadata.isDirectory())
.catch(() => false);
}
async function exists(path: string): Promise<boolean> {
return stat(path)
.then(() => true)
.catch(() => false);
}