openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
466 lines (465 loc) • 20.7 kB
JavaScript
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { r as replaceFileAtomicSync } from "./replace-file-BAJ-TWzD.js";
import { h as clearNodeSqliteKyselyCacheForDatabase, l as runSqliteDeferredTransactionSync, t as openNodeSqliteDatabase, u as runSqliteImmediateTransactionSync } from "./node-sqlite-BpQX3W0e.js";
import { a as getNodeSqliteKysely, r as executeSqliteQuerySync } from "./kysely-sync-COmh4HWh.js";
import { o as OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db-contract-DYCYxE4w.js";
import { t as VERSION } from "./version-v1kuAkGj.js";
import { a as readSqliteUserVersion } from "./sqlite-user-version-DFJCxX41.js";
import { Wt as repairCanonicalSqliteIndexes } from "./openclaw-state-db-BRTnL-D8.js";
import { _ as readSessionArchiveContentSync, h as encodeSessionArchiveContent, m as decodeSessionArchiveBytes, p as SESSION_ARCHIVE_ZSTD_SUFFIX } from "./artifacts-DMDO9PHy.js";
import { Y as OPENCLAW_AGENT_SCHEMA_SQL, d as registerOpenClawAgentDatabase, j as withLegacySessionParticipantsSchema, n as assertOpenClawAgentDatabaseOwner, o as ensureOpenClawAgentDatabaseSchema, s as migrateOpenClawAgentDatabaseToMediaPrerequisiteSchema, w as assertOpenClawAgentSchemaContains } from "./openclaw-agent-db-maintenance-wTIy-jt-.js";
import "./openclaw-agent-db-contract-CGTyjij4.js";
import { _ as withAgentDatabaseMaintenanceLease } from "./openclaw-agent-db-CWtDoRbC.js";
import { i as hasMeaningfulRetiredMediaCarrier, r as canonicalizePersistedUserMessageMedia } from "./media-facts-8Sl_nPCc.js";
import { u as rewriteSqliteTranscriptEventRowsInTransaction } from "./session-accessor.sqlite-transcript-store-B0zw3fAU.js";
import { u as resolveSqliteTranscriptArchiveDirectory } from "./session-accessor.sqlite-scope-2KfMzb44.js";
import { c as repairGatewayAgentMediaMigrationStartupFailures } from "./gateway-boot-lifecycle-JblQ4jbl.js";
import { n as listTranscriptArchives, r as resolveAgentDatabaseMigrationTargets } from "./state-migrations.media-persistence-targets-D45MAuwp.js";
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
//#region src/infra/state-migrations.media-persistence.ts
const PREVIOUS_MEDIA_SCHEMA_VERSION = 16;
const ARCHIVE_TEMP_MARKER = ".media-retirement";
const MEDIA_MIGRATION_ROW_BATCH_SIZE = 64;
function transformTranscriptEvent(event) {
if (!isRecord(event) || event.type !== "message" || !isRecord(event.message)) return {
changed: false,
event
};
const canonical = canonicalizePersistedUserMessageMedia(event.message);
return canonical.changed ? {
changed: true,
event: {
...event,
message: canonical.message
}
} : {
changed: false,
event
};
}
function parseTranscriptEvent(raw, owner) {
try {
return JSON.parse(raw);
} catch (error) {
throw new Error(`${owner} contains invalid transcript JSON: ${String(error)}`, { cause: error });
}
}
function eventIdentity(event) {
if (!isRecord(event)) return JSON.stringify({
id: null,
parentId: null,
type: null
});
return JSON.stringify({
id: typeof event.id === "string" ? event.id : null,
parentId: typeof event.parentId === "string" ? event.parentId : null,
type: typeof event.type === "string" ? event.type : null
});
}
function assertEventIdentitiesUnchanged(before, after, owner) {
if (before.length !== after.length) throw new Error(`${owner} event count changed during media migration`);
for (let index = 0; index < before.length; index += 1) if (eventIdentity(before[index]) !== eventIdentity(after[index])) throw new Error(`${owner} event identity changed at index ${index}`);
}
function forEachMediaEventBatch(params) {
const db = getNodeSqliteKysely(params.database);
let cursor;
while (true) {
let query = db.selectFrom(params.table).select([
"session_id",
"seq",
"event_json"
]).orderBy("session_id", "asc").orderBy("seq", "asc").limit(MEDIA_MIGRATION_ROW_BATCH_SIZE);
const after = cursor;
if (after) query = query.where((expression) => expression(expression.refTuple("session_id", "seq"), ">", expression.tuple(after.sessionId, after.seq)));
const rows = executeSqliteQuerySync(params.database, query).rows;
const last = rows.at(-1);
if (!last) return;
params.visit(rows);
cursor = {
seq: last.seq,
sessionId: last.session_id
};
}
}
function scanTranscriptRows(params) {
const { database, pathname, writer } = params;
const db = getNodeSqliteKysely(database);
let lastChangedSessionId;
let changedSessions = 0;
forEachMediaEventBatch({
database,
table: "transcript_events",
visit: (rows) => {
const sessionIds = [...new Set(rows.map((row) => row.session_id))];
const sessionKeys = new Map(executeSqliteQuerySync(database, db.selectFrom("session_windows").select(["session_id", "session_key"]).where("session_id", "in", sessionIds)).rows.map((row) => [row.session_id, row.session_key]));
for (const sessionId of sessionIds) if (!sessionKeys.has(sessionId)) throw new Error(`${pathname}:${sessionId} has transcript rows without a session window`);
const rewritesBySession = /* @__PURE__ */ new Map();
for (const row of rows) {
const owner = `${pathname}:${row.session_id}:${row.seq}`;
const event = parseTranscriptEvent(row.event_json, owner);
const transformed = transformTranscriptEvent(event);
if (!transformed.changed) continue;
if (eventIdentity(event) !== eventIdentity(transformed.event)) throw new Error(`${owner} event identity changed during media migration`);
if (lastChangedSessionId !== row.session_id) {
lastChangedSessionId = row.session_id;
changedSessions += 1;
}
const rewrites = rewritesBySession.get(row.session_id) ?? [];
rewrites.push({
event: transformed.event,
expectedEventJson: row.event_json,
seq: row.seq
});
rewritesBySession.set(row.session_id, rewrites);
}
if (writer) for (const [sessionId, rewrites] of rewritesBySession) {
const sessionKey = sessionKeys.get(sessionId);
if (!sessionKey) throw new Error(`${pathname}:${sessionId} has transcript rows without a session window`);
rewriteSqliteTranscriptEventRowsInTransaction(writer, {
agentId: writer.agentId,
path: pathname,
sessionId,
sessionKey
}, rewrites);
}
}
});
return changedSessions;
}
function rewriteTrajectoryEventJson(eventJson, owner) {
let event;
try {
event = JSON.parse(eventJson);
} catch (error) {
throw new Error(`${owner} contains invalid trajectory JSON: ${String(error)}`, { cause: error });
}
if (!isRecord(event) || !isRecord(event.data) || !Array.isArray(event.data.messagesSnapshot)) return eventJson;
let changed = false;
const messagesSnapshot = event.data.messagesSnapshot.map((message) => {
if (!isRecord(message) || !hasMeaningfulRetiredMediaCarrier(message)) return message;
const canonical = canonicalizePersistedUserMessageMedia(message);
changed ||= canonical.changed;
return canonical.message;
});
return changed ? JSON.stringify({
...event,
data: {
...event.data,
messagesSnapshot
}
}) : eventJson;
}
function scanTrajectoryRows(params) {
const { database, pathname, rewrite } = params;
const db = getNodeSqliteKysely(database);
let changedRows = 0;
forEachMediaEventBatch({
database,
table: "trajectory_runtime_events",
visit: (rows) => {
for (const row of rows) {
const rewrittenEventJson = rewriteTrajectoryEventJson(row.event_json, `${pathname}:${row.session_id}:${row.seq}`);
if (rewrittenEventJson === row.event_json) continue;
changedRows += 1;
if (rewrite) executeSqliteQuerySync(database, db.updateTable("trajectory_runtime_events").set({ event_json: rewrittenEventJson }).where("session_id", "=", row.session_id).where("seq", "=", row.seq));
}
}
});
return changedRows;
}
function readMediaSourceVersion(database) {
const dataVersionRow = database.prepare("PRAGMA data_version").get();
const counts = database.prepare(`SELECT
(SELECT COUNT(*) FROM transcript_events) AS transcript_rows,
(SELECT COALESCE(SUM(LENGTH(event_json)), 0) FROM transcript_events) AS transcript_bytes,
(SELECT CAST(COALESCE(SUM(created_at), 0) AS TEXT) FROM transcript_events) AS transcript_created_at,
(SELECT COUNT(*) FROM trajectory_runtime_events) AS trajectory_rows,
(SELECT COALESCE(SUM(LENGTH(event_json)), 0) FROM trajectory_runtime_events) AS trajectory_bytes`).get();
const number = (value) => typeof value === "bigint" ? Number(value) : typeof value === "number" ? value : 0;
const count = (key) => number(isRecord(counts) ? counts[key] : void 0);
return {
dataVersion: number(isRecord(dataVersionRow) ? dataVersionRow.data_version : void 0),
trajectoryBytes: count("trajectory_bytes"),
trajectoryRows: count("trajectory_rows"),
transcriptBytes: count("transcript_bytes"),
transcriptCreatedAt: String((isRecord(counts) ? counts.transcript_created_at : void 0) ?? "0"),
transcriptRows: count("transcript_rows")
};
}
function mediaSourceDriftMessage(pathname, expected, current) {
if (expected.transcriptRows !== current.transcriptRows || expected.transcriptBytes !== current.transcriptBytes || expected.transcriptCreatedAt !== current.transcriptCreatedAt) return `${pathname} transcript source changed before migration commit`;
if (expected.trajectoryRows !== current.trajectoryRows || expected.trajectoryBytes !== current.trajectoryBytes) return `${pathname} trajectory source changed before migration commit`;
return `${pathname} source changed before migration transaction`;
}
function createMigrationDatabaseHandle(database, agentId, pathname) {
return {
agentId,
db: database,
path: pathname,
walMaintenance: {
checkpoint: () => false,
close: () => false
}
};
}
function refreshAgentDatabasePlannerStatistics(database) {
database.exec("PRAGMA analysis_limit=1000; ANALYZE main;");
}
function migrateAgentDatabase(params) {
const database = openNodeSqliteDatabase(params.pathname);
try {
database.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`);
let metadata = assertOpenClawAgentDatabaseOwner(database, {
agentId: params.agentId,
pathname: params.pathname
});
let userVersion = readSqliteUserVersion(database);
const initialVersion = userVersion;
if (userVersion <= PREVIOUS_MEDIA_SCHEMA_VERSION) {
migrateOpenClawAgentDatabaseToMediaPrerequisiteSchema(database, {
agentId: params.agentId,
path: params.pathname
});
metadata = assertOpenClawAgentDatabaseOwner(database, {
agentId: params.agentId,
pathname: params.pathname
});
userVersion = readSqliteUserVersion(database);
}
if (metadata.schemaVersion !== userVersion) throw new Error(`${params.pathname} metadata schema version ${metadata.schemaVersion ?? "invalid"} does not match ${userVersion}`);
if (userVersion >= 17) {
ensureOpenClawAgentDatabaseSchema(database, {
agentId: params.agentId,
path: params.pathname
});
userVersion = readSqliteUserVersion(database);
}
const schemaMode = userVersion < 19 ? "legacy" : "current";
const schemaSql = schemaMode === "legacy" ? withLegacySessionParticipantsSchema(OPENCLAW_AGENT_SCHEMA_SQL) : OPENCLAW_AGENT_SCHEMA_SQL;
if (userVersion === PREVIOUS_MEDIA_SCHEMA_VERSION) repairCanonicalSqliteIndexes(database, params.pathname, schemaSql, { validateAfterRepair: () => assertOpenClawAgentSchemaContains(database, params.pathname, schemaSql, schemaMode) });
assertOpenClawAgentSchemaContains(database, params.pathname, schemaSql, schemaMode);
const mediaSchemaUpgrade = userVersion === PREVIOUS_MEDIA_SCHEMA_VERSION;
if (!mediaSchemaUpgrade) {
const detected = runSqliteDeferredTransactionSync(database, () => ({
rewrittenSessions: scanTranscriptRows({
database,
pathname: params.pathname
}),
rewrittenTrajectoryRows: scanTrajectoryRows({
database,
pathname: params.pathname,
rewrite: false
})
}), {
databaseLabel: params.pathname,
operationLabel: "media-persistence-detection"
});
if (detected.rewrittenSessions === 0 && detected.rewrittenTrajectoryRows === 0) {
refreshAgentDatabasePlannerStatistics(database);
return {
...detected,
initialVersion,
finalVersion: userVersion
};
}
}
const sourceVersion = readMediaSourceVersion(database);
params.beforeTransaction?.();
const owner = createMigrationDatabaseHandle(database, params.agentId, params.pathname);
const rewritten = runSqliteImmediateTransactionSync(database, () => {
const currentSourceVersion = readMediaSourceVersion(database);
if (currentSourceVersion.dataVersion !== sourceVersion.dataVersion) throw new Error(mediaSourceDriftMessage(params.pathname, sourceVersion, currentSourceVersion));
const rewrittenSessions = scanTranscriptRows({
database,
pathname: params.pathname,
writer: owner
});
const rewrittenTrajectoryRows = scanTrajectoryRows({
database,
pathname: params.pathname,
rewrite: true
});
if (mediaSchemaUpgrade) {
const db = getNodeSqliteKysely(database);
database.exec(`PRAGMA user_version = 17;`);
executeSqliteQuerySync(database, db.updateTable("schema_meta").set({
app_version: VERSION,
schema_version: 17,
updated_at: Date.now()
}).where("meta_key", "=", "primary"));
}
return {
rewrittenSessions,
rewrittenTrajectoryRows
};
}, {
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: params.pathname,
operationLabel: "media-persistence-retirement"
});
ensureOpenClawAgentDatabaseSchema(database, {
agentId: params.agentId,
path: params.pathname
});
refreshAgentDatabasePlannerStatistics(database);
return {
...rewritten,
initialVersion,
finalVersion: readSqliteUserVersion(database)
};
} finally {
clearNodeSqliteKyselyCacheForDatabase(database);
database.close();
}
}
function readArchiveSourceSnapshot(filePath) {
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${filePath} is not a regular archive file`);
const bytes = fs.readFileSync(filePath);
return {
dev: stat.dev,
ino: stat.ino,
mtimeMs: stat.mtimeMs,
sha256: createHash("sha256").update(bytes).digest("hex"),
size: stat.size
};
}
function archiveSourceMatches(filePath, expected) {
try {
const current = readArchiveSourceSnapshot(filePath);
return current.dev === expected.dev && current.ino === expected.ino && current.mtimeMs === expected.mtimeMs && current.sha256 === expected.sha256 && current.size === expected.size;
} catch {
return false;
}
}
function parseArchiveContent(content, filePath) {
if (content === "") return [];
return (content.endsWith("\n") ? content.slice(0, -1).split("\n") : content.split("\n")).map((line, index) => {
if (!line) throw new Error(`${filePath} contains a blank JSONL record at line ${index + 1}`);
return parseTranscriptEvent(line, `${filePath}:${index + 1}`);
});
}
function serializeArchiveEvents(events, trailingNewline) {
if (events.length === 0) return "";
return `${events.map((event) => JSON.stringify(event)).join("\n")}${trailingNewline ? "\n" : ""}`;
}
function migrateTranscriptArchive(filePath, options = {}) {
const source = readArchiveSourceSnapshot(filePath);
const content = readSessionArchiveContentSync(filePath);
let nulTailStart = content.length;
while (nulTailStart > 0 && content.charCodeAt(nulTailStart - 1) === 0) nulTailStart -= 1;
const hasTerminalNulSuffix = nulTailStart < content.length;
if (hasTerminalNulSuffix && nulTailStart === 0) throw new Error(`${filePath} contains no JSONL records before its terminal NUL suffix`);
const recoveredContent = hasTerminalNulSuffix ? content.slice(0, nulTailStart) : content;
const events = parseArchiveContent(recoveredContent, filePath);
let mediaChanged = false;
const transformed = events.map((event) => {
const result = transformTranscriptEvent(event);
mediaChanged ||= result.changed;
return result.event;
});
if (!hasTerminalNulSuffix && !mediaChanged) return false;
assertEventIdentitiesUnchanged(events, transformed, filePath);
const rewritten = mediaChanged ? serializeArchiveEvents(transformed, recoveredContent.endsWith("\n")) : recoveredContent;
const compressed = filePath.endsWith(SESSION_ARCHIVE_ZSTD_SUFFIX);
const encoded = compressed ? encodeSessionArchiveContent(rewritten) : {
bytes: Buffer.from(rewritten, "utf8"),
suffix: ""
};
if (compressed && encoded.suffix !== ".zst") throw new Error(`${filePath} could not be re-encoded with its zstd codec`);
options.beforeReplace?.();
replaceFileAtomicSync({
filePath,
content: encoded.bytes,
preserveExistingMode: true,
syncParentDir: true,
syncTempFile: true,
tempPrefix: `${path.basename(filePath)}${ARCHIVE_TEMP_MARKER}`,
beforeRename: ({ tempPath }) => {
if (!archiveSourceMatches(filePath, source)) throw new Error(`${filePath} changed before atomic media migration replacement`);
const staged = decodeSessionArchiveBytes(fs.readFileSync(tempPath), compressed);
if (staged !== rewritten) throw new Error(`${filePath} failed codec readback before replacement`);
assertEventIdentitiesUnchanged(events, parseArchiveContent(staged, tempPath), filePath);
}
});
if (readSessionArchiveContentSync(filePath) !== rewritten) throw new Error(`${filePath} failed codec readback after replacement`);
return true;
}
/** Doctor-only migration from top-level Media* transcript fields to canonical facts. */
async function migrateLegacyMediaPersistence(params = {}) {
const env = params.env ?? process.env;
const changes = [];
const warnings = [];
try {
await withAgentDatabaseMaintenanceLease({ env }, async () => {
const targets = resolveAgentDatabaseMigrationTargets({
changes,
configuredAgentDatabaseTargets: params.configuredAgentDatabaseTargets ?? [],
env,
warnings
});
const seenPaths = /* @__PURE__ */ new Set();
let databaseMigrationFailed = false;
const archiveDirectories = /* @__PURE__ */ new Set();
for (const entry of targets) {
const pathname = entry.path;
archiveDirectories.add(resolveSqliteTranscriptArchiveDirectory({
agentId: entry.agentId,
path: pathname
}));
if (seenPaths.has(entry.realPath)) continue;
seenPaths.add(entry.realPath);
try {
const result = migrateAgentDatabase({
agentId: entry.agentId,
beforeTransaction: params.hooks?.beforeDatabaseTransaction ? () => params.hooks?.beforeDatabaseTransaction?.(pathname) : void 0,
pathname
});
const schemaAdvanced = result.finalVersion > result.initialVersion;
if (entry.source !== "registry" || schemaAdvanced) registerOpenClawAgentDatabase({
agentId: entry.agentId,
env,
path: pathname
});
if (schemaAdvanced) changes.push(`Upgraded agent database schema in ${pathname}: v${result.initialVersion} -> v${result.finalVersion}.`);
if (result.rewrittenSessions > 0 || result.rewrittenTrajectoryRows > 0) changes.push(`Migrated media persistence in ${pathname}: ${result.rewrittenSessions} transcript session(s), ${result.rewrittenTrajectoryRows} trajectory row(s), schema v19.`);
} catch (error) {
databaseMigrationFailed = true;
warnings.push(`Skipped agent database migration for ${pathname}: ${String(error)}`);
}
}
if (!databaseMigrationFailed && seenPaths.size > 0) {
const repairedFailures = repairGatewayAgentMediaMigrationStartupFailures({
databasePaths: [...seenPaths],
env
});
if (repairedFailures > 0) changes.push(`Repaired ${repairedFailures} gateway startup failure ${repairedFailures === 1 ? "record" : "records"} after media migration.`);
}
for (const directory of archiveDirectories) {
let archives;
try {
archives = listTranscriptArchives(directory);
} catch (error) {
warnings.push(`Could not enumerate transcript archives in ${directory}: ${String(error)}`);
continue;
}
for (const archive of archives) try {
if (migrateTranscriptArchive(archive, { beforeReplace: params.hooks?.beforeArchiveReplace ? () => params.hooks?.beforeArchiveReplace?.(archive) : void 0 })) changes.push(`Migrated archived transcript media in ${archive}.`);
} catch (error) {
warnings.push(`Skipped archived transcript media migration for ${archive}: ${String(error)}`);
}
}
});
} catch (error) {
warnings.push(`Agent database maintenance deferred: ${formatErrorMessage(error)}`);
}
return {
changes,
warnings
};
}
//#endregion
export { migrateLegacyMediaPersistence as t };