blob: cc375098de8cf059de7cc36a56701881c7707361 [file]
import { readFile, readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import type { RuntimeEvent, RuntimeEventStore } from '@maka/core';
import { createRuntimeEventStore } from './agent-run-store.js';
import type { SqliteRuntimeStore } from './sqlite-runtime-store.js';
import { createSqliteRuntimeStore } from './sqlite-runtime-store.js';
export const SQLITE_RUNTIME_DATABASE_NAME = 'runtime.sqlite';
const SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
export type RuntimeEventPersistence = {
kind: 'jsonl' | 'sqlite';
runtimeEventStore: RuntimeEventStore;
runtimeCommitStore?: SqliteRuntimeStore;
importReport?: LegacyRuntimeEventImportReport;
close(): void;
};
export interface RuntimeEventImportReport {
eventsRead: number;
eventsImported: number;
eventsExisting: number;
}
export interface LegacyRuntimeEventImportReport extends RuntimeEventImportReport {
filesScanned: number;
}
export async function openRuntimeEventPersistence(input: {
workspaceRoot: string;
sqliteCanonical: boolean;
}): Promise<RuntimeEventPersistence> {
const databasePath = join(input.workspaceRoot, SQLITE_RUNTIME_DATABASE_NAME);
if (!input.sqliteCanonical && !(await pathExists(databasePath))) {
return {
kind: 'jsonl',
runtimeEventStore: createRuntimeEventStore(input.workspaceRoot),
close: () => {},
};
}
const store = createSqliteRuntimeStore(databasePath);
try {
const importReport = await importLegacyRuntimeEventJsonlTree({
workspaceRoot: input.workspaceRoot,
destination: store,
});
return {
kind: 'sqlite',
runtimeEventStore: store,
runtimeCommitStore: store,
importReport,
close: () => store.close(),
};
} catch (error) {
store.close();
throw error;
}
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw error;
}
}
export async function exportRuntimeEventsToJsonl(
source: RuntimeEventStore,
sessionId: string,
runId: string,
): Promise<string> {
const events = source.readImmutableRuntimeEvents
? await source.readImmutableRuntimeEvents(sessionId, runId)
: await source.readRuntimeEvents(sessionId, runId);
return events.length === 0 ? '' : `${events.map((event) => JSON.stringify(event)).join('\n')}\n`;
}
export async function importRuntimeEventsFromJsonl(input: {
jsonl: string;
sessionId: string;
runId: string;
destination: SqliteRuntimeStore;
}): Promise<RuntimeEventImportReport> {
const events = parseRuntimeEventJsonl(input.jsonl, input.sessionId, input.runId);
return importRuntimeEvents(events, input.sessionId, input.runId, input.destination);
}
export async function importLegacyRuntimeEventJsonlTree(input: {
workspaceRoot: string;
destination: SqliteRuntimeStore;
}): Promise<LegacyRuntimeEventImportReport> {
const sessionsRoot = join(input.workspaceRoot, 'sessions');
const report: LegacyRuntimeEventImportReport = {
filesScanned: 0,
eventsRead: 0,
eventsImported: 0,
eventsExisting: 0,
};
for (const session of await directoryNames(sessionsRoot)) {
const runsRoot = join(sessionsRoot, session, 'runs');
for (const run of await directoryNames(runsRoot)) {
const sourcePath = join(runsRoot, run, 'runtime-events.jsonl');
const sourceStat = await stat(sourcePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined;
throw error;
});
if (!sourceStat) continue;
const fingerprint = `${sourceStat.size}:${sourceStat.mtimeMs}`;
if (await input.destination.isRuntimeImportSourceCurrent(sourcePath, fingerprint)) continue;
const events = parseLegacyRuntimeEventJsonl(
await readFile(sourcePath, 'utf8'),
session,
run,
).filter((event) => !isLegacyStreamPartialSnapshot(event));
report.filesScanned += 1;
const imported = await importRuntimeEvents(events, session, run, input.destination, {
path: sourcePath,
fingerprint,
});
report.eventsRead += imported.eventsRead;
report.eventsImported += imported.eventsImported;
report.eventsExisting += imported.eventsExisting;
}
}
return report;
}
async function importRuntimeEvents(
events: readonly RuntimeEvent[],
sessionId: string,
runId: string,
destination: SqliteRuntimeStore,
source?: { path: string; fingerprint: string },
): Promise<RuntimeEventImportReport> {
const report: RuntimeEventImportReport = {
eventsRead: events.length,
eventsImported: 0,
eventsExisting: 0,
};
for (const event of events) {
assertRuntimeEventImportIdentity(event, sessionId, runId);
}
const imported = await destination.importRuntimeEventsBatch({
sessionId,
runId,
events,
...(source ? { source } : {}),
});
for (const created of imported.created) {
if (created) report.eventsImported += 1;
else report.eventsExisting += 1;
}
return report;
}
function parseRuntimeEventJsonl(jsonl: string, sessionId: string, runId: string): RuntimeEvent[] {
const events: RuntimeEvent[] = [];
const lines = jsonl.split('\n');
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!line?.trim()) continue;
let event: RuntimeEvent;
try {
event = JSON.parse(line) as RuntimeEvent;
} catch (error) {
throw new Error(`Invalid RuntimeEvent JSONL line ${index + 1} for run ${runId}`, {
cause: error,
});
}
assertRuntimeEventImportIdentity(event, sessionId, runId);
if (event.partial === true) {
throw new Error(`Partial RuntimeEvent ${event.id} cannot be imported as immutable JSONL`);
}
events.push(event);
}
return events;
}
function parseLegacyRuntimeEventJsonl(
jsonl: string,
sessionId: string,
runId: string,
): RuntimeEvent[] {
const events: RuntimeEvent[] = [];
const lines = jsonl.split('\n');
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!line?.trim()) continue;
let event: RuntimeEvent;
try {
event = JSON.parse(line) as RuntimeEvent;
} catch (error) {
throw new Error(`Invalid legacy RuntimeEvent JSONL line ${index + 1} for run ${runId}`, {
cause: error,
});
}
assertRuntimeEventImportIdentity(event, sessionId, runId);
events.push(event);
}
return events;
}
function assertRuntimeEventImportIdentity(
event: RuntimeEvent,
sessionId: string,
runId: string,
): void {
if (
!event ||
typeof event !== 'object' ||
event.sessionId !== sessionId ||
event.runId !== runId
) {
throw new Error(`RuntimeEvent import identity mismatch for session ${sessionId}, run ${runId}`);
}
}
// Legacy JSONL logs may physically contain stream partial snapshots written by
// older versions. They are mutable projection state, not immutable facts: a
// completed stream leaves a separate durable final event, and a dangling
// partial is already handled by the replay boundary gates. Legacy tree import
// skips them; the strict importRuntimeEventsFromJsonl API still rejects them.
function isLegacyStreamPartialSnapshot(event: RuntimeEvent): boolean {
return event.partial === true && event.status === undefined && event.actions === undefined;
}
async function directoryNames(root: string): Promise<string[]> {
try {
const entries = await readdir(root, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory() && SAFE_ID_PATTERN.test(entry.name))
.map((entry) => entry.name)
.sort();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}