openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
315 lines (314 loc) • 13.5 kB
JavaScript
import { a as getNodeSqliteKysely, o as iterateSqliteQuerySync, r as executeSqliteQuerySync } from "../../kysely-sync-COmh4HWh.js";
import { w as settleOpenClawAgentDatabaseWorkerClose } from "../../openclaw-agent-db-CWtDoRbC.js";
import { n as withOpenClawAgentDatabaseReadOnly } from "../../openclaw-agent-db-readonly-CHjf8FxN.js";
import { n as sqliteSessionStateDeleteSnapshotsEqual, t as readSessionStateDeleteSnapshot } from "../../session-accessor.sqlite-delete-snapshot-Dc3XCL8u.js";
import { S as resolveSqliteTranscriptArchivePath, v as MAX_MATERIALIZED_ARCHIVE_BATCH_BYTES, x as publishEncodedSessionTranscriptArchive, y as hashSessionArchiveBytes } from "../../session-accessor.sqlite-lifecycle-state-BTh4yZ7R.js";
import { a as reclaimSqliteSessionInTransaction, l as markSqliteReclamationSettled, u as waitForSqliteReclamationCommit } from "../../session-accessor.sqlite-reclamation-CoML3XOn.js";
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import zlib from "node:zlib";
import { Transform } from "node:stream";
import { parentPort, workerData } from "node:worker_threads";
import { pipeline as pipeline$1 } from "node:stream/promises";
//#region src/config/sessions/session-accessor.sqlite-archive.worker.ts
/** Worker entrypoint for SQLite transcript archive materialization off the gateway event loop. */
const WORKER_CLOSE_MAX_ATTEMPTS = 3;
async function settleReclamationDatabase(pathname) {
const warnings = /* @__PURE__ */ new Set();
let outcome = {
errors: [],
settled: false
};
for (let attempt = 0; attempt < WORKER_CLOSE_MAX_ATTEMPTS; attempt += 1) {
outcome = settleOpenClawAgentDatabaseWorkerClose(pathname);
outcome.errors.forEach((error) => warnings.add(error.message));
if (outcome.settled) break;
if (attempt + 1 < WORKER_CLOSE_MAX_ATTEMPTS) await new Promise((resolve) => {
setTimeout(resolve, 25 * 2 ** attempt);
});
}
return {
cleanupWarnings: [...warnings],
settled: outcome.settled
};
}
function isSqliteTranscriptArchiveWorkerData(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && value.type === "sqlite-transcript-archive-v2";
}
function parsePublishWorkerPlans(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
const plans = value.plans;
if (!Array.isArray(plans)) return;
const parsed = [];
for (const planValue of plans) {
if (!planValue || typeof planValue !== "object" || Array.isArray(planValue)) return;
const plan = planValue;
if (typeof plan.agentId !== "string" || typeof plan.archiveDirectory !== "string" || typeof plan.databasePath !== "string" || typeof plan.generation !== "string" || typeof plan.sessionId !== "string") return;
parsed.push({
agentId: plan.agentId,
archiveDirectory: plan.archiveDirectory,
databasePath: plan.databasePath,
generation: plan.generation,
sessionId: plan.sessionId
});
}
return parsed;
}
function parseSessionStateDeleteSnapshot(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const snapshot = value;
if (typeof snapshot.acpParentStreamEventCount !== "number" || snapshot.generation !== null && typeof snapshot.generation !== "string" || snapshot.lastSeq !== null && typeof snapshot.lastSeq !== "number" || snapshot.sessionKey !== null && typeof snapshot.sessionKey !== "string" || snapshot.sessionUpdatedAt !== null && typeof snapshot.sessionUpdatedAt !== "number" || snapshot.trajectoryLastSeq !== null && typeof snapshot.trajectoryLastSeq !== "number" || snapshot.transcriptUpdatedAt !== null && typeof snapshot.transcriptUpdatedAt !== "number") return null;
return {
acpParentStreamEventCount: snapshot.acpParentStreamEventCount,
generation: snapshot.generation,
lastSeq: snapshot.lastSeq,
sessionKey: snapshot.sessionKey,
sessionUpdatedAt: snapshot.sessionUpdatedAt,
trajectoryLastSeq: snapshot.trajectoryLastSeq,
transcriptUpdatedAt: snapshot.transcriptUpdatedAt
};
}
function parseWorkerPlans(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
const plans = value.plans;
if (!Array.isArray(plans)) return;
const parsed = [];
for (const planValue of plans) {
if (!planValue || typeof planValue !== "object" || Array.isArray(planValue)) return;
const plan = planValue;
const snapshot = parseSessionStateDeleteSnapshot(plan.snapshot);
if (typeof plan.agentId !== "string" || typeof plan.archiveDirectory !== "string" || typeof plan.databasePath !== "string" || plan.reason !== "deleted" && plan.reason !== "reset" || typeof plan.sessionId !== "string" || !snapshot) return;
parsed.push({
agentId: plan.agentId,
archiveDirectory: plan.archiveDirectory,
databasePath: plan.databasePath,
reason: plan.reason,
sessionId: plan.sessionId,
snapshot
});
}
return parsed;
}
function stageTranscriptArchiveContent(database, sessionId, stagedPath) {
const fd = fs.openSync(stagedPath, "wx", 384);
let rowCount = 0;
try {
const db = getNodeSqliteKysely(database);
for (const row of iterateSqliteQuerySync(database, db.selectFrom("transcript_events").select("event_json").where("session_id", "=", sessionId).orderBy("seq", "asc"))) {
if (typeof row.event_json !== "string") throw new Error(`Invalid transcript event row for ${sessionId}`);
fs.writeFileSync(fd, row.event_json);
fs.writeFileSync(fd, "\n");
rowCount += 1;
}
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
return rowCount;
}
function createArchiveByteLimitTransform() {
let encodedBytes = 0;
return new Transform({ transform(chunk, encoding, callback) {
let chunkBytes;
if (typeof chunk === "string") chunkBytes = Buffer.byteLength(chunk, encoding);
else if (chunk instanceof Uint8Array) chunkBytes = chunk.byteLength;
else {
callback(/* @__PURE__ */ new TypeError("Archive encoder emitted an unsupported chunk type"));
return;
}
encodedBytes += chunkBytes;
if (encodedBytes > 268435456) {
callback(/* @__PURE__ */ new Error(`Archive exceeds ${MAX_MATERIALIZED_ARCHIVE_BATCH_BYTES} bytes during encoding`));
return;
}
callback(null, chunk);
} });
}
async function encodeStagedTranscriptArchive(params) {
const createdAt = Date.now();
const createZstdCompress = zlib.createZstdCompress;
const compressed = typeof createZstdCompress === "function";
const archivePath = `${resolveSqliteTranscriptArchivePath({
archiveDirectory: params.archiveDirectory,
generation: params.generation,
identityOwner: "registry",
reason: params.reason,
sessionId: params.sessionId,
nowMs: createdAt
})}${compressed ? ".zst" : ""}`;
const encodedPath = `${archivePath}.${randomUUID()}.stage`;
try {
if (compressed) await pipeline$1(fs.createReadStream(params.stagedPath), createZstdCompress.call(zlib), createArchiveByteLimitTransform(), fs.createWriteStream(encodedPath, {
flags: "wx",
mode: 384
}));
else await pipeline$1(fs.createReadStream(params.stagedPath), createArchiveByteLimitTransform(), fs.createWriteStream(encodedPath, {
flags: "wx",
mode: 384
}));
const bytes = fs.readFileSync(encodedPath);
return {
archiveName: path.basename(archivePath),
bytes,
createdAt,
encoding: compressed ? "zstd" : "identity",
sha256: hashSessionArchiveBytes(bytes)
};
} finally {
fs.rmSync(encodedPath, { force: true });
}
}
async function materializeTranscriptArchiveInWorker(plan) {
fs.mkdirSync(plan.archiveDirectory, {
recursive: true,
mode: 448
});
const stagedPath = `${resolveSqliteTranscriptArchivePath({
archiveDirectory: plan.archiveDirectory,
generation: plan.snapshot.generation ?? void 0,
identityOwner: "registry",
reason: plan.reason,
sessionId: plan.sessionId
})}.${randomUUID()}.jsonl-stage`;
try {
const opened = withOpenClawAgentDatabaseReadOnly((database) => {
let transactionOpen = false;
try {
database.db.exec("BEGIN");
transactionOpen = true;
const snapshot = readSessionStateDeleteSnapshot(database.db, plan.sessionId);
if (!sqliteSessionStateDeleteSnapshotsEqual(snapshot, plan.snapshot)) throw new Error(`SQLite session state changed before archive materialization for ${plan.sessionId}`);
const rowCount = stageTranscriptArchiveContent(database.db, plan.sessionId, stagedPath);
database.db.exec("COMMIT");
transactionOpen = false;
return {
rowCount,
snapshot
};
} catch (error) {
if (transactionOpen) database.db.exec("ROLLBACK");
throw error;
}
}, {
agentId: plan.agentId,
path: plan.databasePath
});
if (!opened.found) throw new Error(`Cannot archive SQLite transcript ${plan.sessionId}: ${opened.reason.replaceAll("-", " ")}`);
const generation = plan.snapshot.generation;
if (opened.value.rowCount > 0 && !generation) throw new Error(`Cannot archive SQLite transcript without a generation for ${plan.sessionId}`);
return {
archive: opened.value.rowCount > 0 && generation ? await encodeStagedTranscriptArchive({
archiveDirectory: plan.archiveDirectory,
generation,
reason: plan.reason,
sessionId: plan.sessionId,
stagedPath
}) : null,
sessionId: plan.sessionId
};
} finally {
fs.rmSync(stagedPath, { force: true });
}
}
function publishTranscriptArchiveInWorker(plan) {
try {
const opened = withOpenClawAgentDatabaseReadOnly((database) => {
const db = getNodeSqliteKysely(database.db);
return executeSqliteQuerySync(database.db, db.selectFrom("session_transcript_archives").select([
"archive_blob",
"archive_name",
"archive_sha256"
]).where("session_id", "=", plan.sessionId).where("generation", "=", plan.generation)).rows[0];
}, {
agentId: plan.agentId,
path: plan.databasePath
});
if (!opened.found || !opened.value) throw new Error(`Canonical SQLite transcript archive is missing for ${plan.sessionId}`);
if (hashSessionArchiveBytes(opened.value.archive_blob) !== opened.value.archive_sha256) throw new Error(`Canonical SQLite transcript archive is corrupt for ${plan.sessionId}`);
return {
archivedPath: publishEncodedSessionTranscriptArchive({
archiveDirectory: plan.archiveDirectory,
archiveName: opened.value.archive_name,
bytes: opened.value.archive_blob,
sha256: opened.value.archive_sha256
}),
generation: plan.generation,
sessionId: plan.sessionId
};
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
generation: plan.generation,
sessionId: plan.sessionId
};
}
}
async function runWorkerPort(port, plans) {
let materializedBytes = 0;
for (const plan of plans) {
const result = await materializeTranscriptArchiveInWorker(plan);
materializedBytes += result.archive?.bytes.byteLength ?? 0;
if (materializedBytes > 268435456) throw new Error(`Archive batch exceeds ${MAX_MATERIALIZED_ARCHIVE_BATCH_BYTES} bytes; use fewer sessions`);
port.postMessage({
type: "done",
results: [result]
});
}
port.close();
}
function runPublishWorkerPort(port, plans) {
const results = plans.map((plan) => publishTranscriptArchiveInWorker(plan));
port.postMessage({
type: "published",
results
});
port.close();
}
async function runReclamationWorkerPort(port, data) {
let result;
const commitGate = data.commitGate;
try {
let transactionDatabase;
try {
result = reclaimSqliteSessionInTransaction(data.plan, { onCommit: commitGate ? (database) => {
transactionDatabase = database.db;
waitForSqliteReclamationCommit(commitGate, () => port.postMessage({ type: "commit-request" }));
} : void 0 });
} finally {
if (transactionDatabase && (!transactionDatabase.isOpen || !transactionDatabase.isTransaction)) markSqliteReclamationSettled(commitGate);
}
} catch (error) {
const cleanup = await settleReclamationDatabase(data.plan.databaseOptions.path);
if (cleanup.settled) markSqliteReclamationSettled(commitGate);
else throw new AggregateError([error, ...cleanup.cleanupWarnings.map((warning) => new Error(warning))], "SQLite session reclamation failed and Worker cleanup is incomplete; restart OpenClaw before deleting the owning agent", { cause: error });
throw error;
}
const cleanup = await settleReclamationDatabase(data.plan.databaseOptions.path);
const workerResult = {
result,
...cleanup.cleanupWarnings.length > 0 ? { cleanupWarnings: cleanup.cleanupWarnings } : {},
...!cleanup.settled ? { cleanupIncomplete: true } : {}
};
port.postMessage({
type: "reclaimed",
results: [workerResult]
});
port.close();
}
if (isSqliteTranscriptArchiveWorkerData(workerData)) {
if (!parentPort) throw new Error("SQLite transcript archive worker requires a parent port");
const operation = workerData.operation;
if (operation === "materialize") {
const plans = parseWorkerPlans(workerData);
if (!plans) throw new Error("SQLite transcript archive worker requires valid materialization data");
await runWorkerPort(parentPort, plans);
} else if (operation === "publish") {
const plans = parsePublishWorkerPlans(workerData);
if (!plans) throw new Error("SQLite transcript archive worker requires valid publication data");
runPublishWorkerPort(parentPort, plans);
} else if (operation === "reclaim") await runReclamationWorkerPort(parentPort, workerData);
else throw new Error("SQLite transcript archive worker requires a supported operation");
}
//#endregion
export { materializeTranscriptArchiveInWorker, publishTranscriptArchiveInWorker };