openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
458 lines (457 loc) • 19.6 kB
JavaScript
import { u as toStringifiedError } from "./error-coercion-D_-xJ90S.js";
import "./path-guards-Cp-mGr3-.js";
import { n as ok, t as err } from "./result-BQGgYouL.js";
import { w as resolveStateDir } from "./paths-D2sRr1a_.js";
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js";
import { l as runSqliteDeferredTransactionSync } from "./node-sqlite-BpQX3W0e.js";
import { i as executeSqliteQueryTakeFirstSync } from "./kysely-sync-COmh4HWh.js";
import { t as runtimeProcessEntrypoints } from "./runtime-process-entrypoints-CEMkwAJ_.js";
import { r as resolveRuntimeWorkerUrl } from "./runtime-worker-url-CpdriB1D.js";
import { o as isGatewayExternallySupervised } from "./gateway-supervision-D7p37rG2.js";
import { D as isIncognitoOpenClawAgentSqlitePath, k as resolveOpenClawAgentSqlitePath } from "./openclaw-agent-db-lease-Djvd6LWN.js";
import { c as getOpenClawAgentDatabaseIfOpen, g as runOpenClawAgentWriteTransaction, l as isIncognitoOpenClawAgentDatabase, n as borrowOpenClawAgentDatabase, v as withOpenClawAgentDatabaseAsync } from "./openclaw-agent-db-CWtDoRbC.js";
import { n as withOpenClawAgentDatabaseReadOnly } from "./openclaw-agent-db-readonly-CHjf8FxN.js";
import { d as resolveSqliteTranscriptReadScope, i as getSessionKysely, m as toDatabaseOptions, p as runExclusiveSqliteSessionWrite } from "./session-accessor.sqlite-scope-2KfMzb44.js";
import { d as appendPreparedSessionTranscriptProjectionChunkInTransaction, f as claimPreparedSessionTranscriptProjectionInTransaction, h as finalizePreparedSessionTranscriptProjectionInTransaction, i as deleteOrphanedTranscriptIndexRowsInTransaction, l as sessionTranscriptIndexNeedsReconcile, o as listSessionsNeedingTranscriptIndexReconcile, p as deletePreparedSessionTranscriptProjectionChunkInTransaction } from "./session-transcript-index-DmCW_GtW.js";
import { randomInt, randomUUID } from "node:crypto";
import { sql } from "kysely";
import { Worker } from "node:worker_threads";
import { setTimeout } from "node:timers/promises";
//#region src/config/sessions/session-transcript-reconcile-memory.ts
const SOURCE_FRAME_BYTES = 262144;
function readSnapshot(database, sessionId) {
const db = getSessionKysely(database.db);
const row = executeSqliteQueryTakeFirstSync(database.db, db.selectFrom("session_windows as window").leftJoin("transcript_rewrite_watermarks as rewrite", "rewrite.session_id", "window.session_id").select((eb) => [
"window.transcript_updated_at",
"rewrite.generation",
eb.selectFrom("transcript_events").select("seq").where("session_id", "=", sessionId).orderBy("seq", "desc").limit(1).as("max_seq")
]).where("window.session_id", "=", sessionId));
return row && row.max_seq !== null ? {
sessionId,
transcriptUpdatedAt: row.transcript_updated_at,
maxSeq: row.max_seq,
generation: row.generation
} : void 0;
}
/** The parent alone owns memory state; a worker receives bytes, never its sentinel path. */
function createMemoryTranscriptProjectionSource(database, options) {
let snapshot;
let afterSeq = -1;
let offset = 0;
let row;
const assertCurrentOwner = () => {
if (!database.db.isOpen || getOpenClawAgentDatabaseIfOpen(options) !== database) throw new Error("Incognito transcript database was disposed during reconciliation");
};
const snapshotMatches = () => {
if (!snapshot) return false;
const current = readSnapshot(database, snapshot.sessionId);
return current?.generation === snapshot.generation && current?.maxSeq === snapshot.maxSeq && current?.transcriptUpdatedAt === snapshot.transcriptUpdatedAt;
};
return {
assertCurrentOwner,
clear() {
snapshot = void 0;
row = void 0;
},
isCurrentPlan(plan) {
return snapshot?.sessionId === plan.sessionId && snapshot.maxSeq === plan.sourceIndexedSeq && snapshot.transcriptUpdatedAt === plan.sourceTranscriptUpdatedAt && snapshotMatches();
},
read(sessionId) {
assertCurrentOwner();
return runSqliteDeferredTransactionSync(database.db, () => {
if (snapshot?.sessionId !== sessionId) {
snapshot = readSnapshot(database, sessionId);
row = void 0;
afterSeq = -1;
offset = 0;
}
if (!snapshotMatches()) {
row = void 0;
snapshot = void 0;
return { type: "source-unavailable" };
}
if (!row) {
row = executeSqliteQueryTakeFirstSync(database.db, getSessionKysely(database.db).selectFrom("transcript_events").select([
"seq",
"created_at",
sql`CAST(event_json AS BLOB)`.as("bytes")
]).where("session_id", "=", sessionId).where("seq", ">", afterSeq).orderBy("seq", "asc").limit(1));
offset = 0;
}
if (!row) return {
type: "source-end",
snapshot
};
const bytes = Uint8Array.from(row.bytes.subarray(offset, offset + SOURCE_FRAME_BYTES));
offset += bytes.byteLength;
const frame = {
type: "source-frame",
seq: row.seq,
createdAt: row.created_at,
bytes,
final: offset === row.bytes.byteLength
};
if (frame.final) {
afterSeq = row.seq;
row = void 0;
}
return frame;
}, {
databaseLabel: database.path,
operationLabel: "sessions.transcript-index.memory-source"
});
}
};
}
//#endregion
//#region src/config/sessions/session-transcript-reconcile.ts
const log = createSubsystemLogger("sessions/transcript-index");
const PROJECTION_WRITE_CHUNK_ROWS = 512;
const PROJECTION_READY_POLL_MS = 10;
const runningReconciles = /* @__PURE__ */ new Map();
function prepareReconcileParams(params) {
return {
...params,
env: { ...params.env ?? process.env }
};
}
function reconcileKey(params) {
return resolveOpenClawAgentSqlitePath(params);
}
function captureMemorySource(params) {
const database = getOpenClawAgentDatabaseIfOpen(params);
return database && isIncognitoOpenClawAgentDatabase(database) ? createMemoryTranscriptProjectionSource(database, {
...params,
path: database.path
}) : void 0;
}
function yieldToGateway() {
return new Promise((resolve) => {
setImmediate(resolve);
});
}
function nextProjectionClaimId() {
return -randomInt(1, 2 ** 47);
}
function continueProjectionWorker(worker, accepted) {
worker.postMessage({
accepted,
type: "continue"
}, []);
}
function observeWorkerLeaseRelease(worker) {
let released = false;
let releaseFailed = false;
let failure;
worker.on("message", (message) => {
if (message.type === "lease-released") released = true;
else if (message.type === "lease-release-failed") {
releaseFailed = true;
failure = new Error(message.error);
}
});
worker.once("error", (error) => {
failure ??= toStringifiedError(error);
});
return new Promise((resolve) => {
worker.once("exit", (code) => {
if (code !== 0 || !released) failure ??= /* @__PURE__ */ new Error(`transcript worker exited with code ${code} before lease release`);
resolve({
released,
releaseFailed,
failure
});
});
});
}
async function runProjectionWrite(databaseOptions, operationLabel, operation, memorySource) {
return await runExclusiveSqliteSessionWrite(databaseOptions, async () => {
const write = () => {
memorySource?.assertCurrentOwner();
return runOpenClawAgentWriteTransaction(operation, databaseOptions, { operationLabel });
};
return !isIncognitoOpenClawAgentSqlitePath(databaseOptions.path, databaseOptions) && !getOpenClawAgentDatabaseIfOpen(databaseOptions) ? withOpenClawAgentDatabaseAsync(databaseOptions, write) : write();
});
}
async function claimPreparedSessionTranscriptProjection(databaseOptions, plan, memorySource) {
const claimId = nextProjectionClaimId();
if (!await runProjectionWrite(databaseOptions, "sessions.transcript-index.claim", (database) => (!memorySource || memorySource.isCurrentPlan(plan)) && claimPreparedSessionTranscriptProjectionInTransaction(database.db, plan, claimId), memorySource)) return;
let deleteResult = {
hasMore: true,
owned: true
};
while (deleteResult.hasMore && deleteResult.owned) {
deleteResult = await runProjectionWrite(databaseOptions, "sessions.transcript-index.delete-chunk", (database) => deletePreparedSessionTranscriptProjectionChunkInTransaction(database.db, {
maxRowsPerTable: PROJECTION_WRITE_CHUNK_ROWS,
sessionId: plan.sessionId,
claimId
}), memorySource);
await yieldToGateway();
}
if (!deleteResult.owned) return;
return {
claimId,
plan
};
}
function decodeFtsChunk(chunk) {
const decoder = new TextDecoder();
return chunk.rows.map((row) => ({
messageId: row.messageId,
role: row.role,
text: decoder.decode(chunk.textBytes.subarray(row.textByteOffset, row.textByteOffset + row.textByteLength)),
timestamp: row.timestamp
}));
}
async function appendPreparedProjectionChunk(databaseOptions, active, rows, memorySource) {
const owned = await runProjectionWrite(databaseOptions, "activeRows" in rows ? "sessions.transcript-index.active-chunk" : "sessions.transcript-index.fts-chunk", (database) => appendPreparedSessionTranscriptProjectionChunkInTransaction(database.db, {
...rows,
claimId: active.claimId,
sessionId: active.plan.sessionId
}), memorySource);
await yieldToGateway();
return owned;
}
async function finalizePreparedProjection(databaseOptions, active, memorySource) {
return await runProjectionWrite(databaseOptions, "sessions.transcript-index.finalize", (database) => (!memorySource || memorySource.isCurrentPlan(active.plan)) && finalizePreparedSessionTranscriptProjectionInTransaction(database.db, active.plan, active.claimId), memorySource);
}
/** Prepares full trees off-thread, then commits bounded chunks through the runtime writer owner. */
async function reconcileSessionTranscriptIndexes(params) {
return reconcilePreparedTranscriptIndexes(prepareReconcileParams(params));
}
async function reconcilePreparedTranscriptIndexes(params) {
const databasePath = resolveOpenClawAgentSqlitePath(params);
const databaseOptions = {
agentId: params.agentId,
env: params.env,
path: databasePath
};
let releaseDatabase;
const memorySource = captureMemorySource(databaseOptions);
let memorySessionIds = [];
try {
await runProjectionWrite(databaseOptions, "sessions.transcript-index.preflight", (database) => {
deleteOrphanedTranscriptIndexRowsInTransaction(database.db);
const sessionIds = listSessionsNeedingTranscriptIndexReconcile(database.db);
if (sessionIds.length > 0) {
releaseDatabase = borrowOpenClawAgentDatabase(databaseOptions).release;
if (memorySource) {
const preferred = params.preferredSessionId;
memorySessionIds = preferred && sessionIds.includes(preferred) ? [preferred, ...sessionIds.filter((sessionId) => sessionId !== preferred)] : sessionIds;
}
}
}, memorySource);
if (!releaseDatabase) return { reconciledSessions: 0 };
const workerUrl = resolveRuntimeWorkerUrl(runtimeProcessEntrypoints.sessionTranscriptReconcile);
const sourceWorkerExecArgv = workerUrl.pathname.endsWith(".ts") ? ["--import", "tsx"] : void 0;
const input = memorySource ? {
mode: "memory",
sessionIds: memorySessionIds
} : {
mode: "disk",
leaseId: randomUUID(),
agentId: params.agentId,
path: databasePath,
stateDir: resolveStateDir(params.env),
externallySupervised: isGatewayExternallySupervised(params.env),
...params.preferredSessionId ? { preferredSessionId: params.preferredSessionId } : {}
};
const createWorker = params.createWorker ?? ((filename, options) => new Worker(filename, options));
let worker;
try {
worker = createWorker(workerUrl, {
workerData: input,
execArgv: sourceWorkerExecArgv
});
} catch (error) {
throw toStringifiedError(error);
}
const leaseRelease = observeWorkerLeaseRelease(worker);
let handlingMessage;
let terminalReceived = false;
let outcome;
try {
const value = await new Promise((resolve, reject) => {
let active;
let reconciledSessions = 0;
let settled = false;
const settle = (finish) => {
if (settled) return;
settled = true;
finish();
};
const handleMessage = async (message) => {
if (message.type === "failed") {
terminalReceived = true;
settle(() => reject(new Error(message.error)));
return;
}
if (message.type === "done") {
terminalReceived = true;
if (active) {
settle(() => reject(/* @__PURE__ */ new Error("session transcript reconcile worker ended mid-plan")));
return;
}
try {
await runProjectionWrite(databaseOptions, "sessions.transcript-index.orphan-sweep", (database) => deleteOrphanedTranscriptIndexRowsInTransaction(database.db), memorySource);
} catch (error) {
settle(() => reject(toStringifiedError(error)));
return;
}
settle(() => resolve({ reconciledSessions }));
return;
}
try {
if (message.type === "source-read") {
if (!memorySource || !memorySessionIds.includes(message.sessionId)) throw new Error("session transcript worker requested an unavailable memory source");
const frame = memorySource.read(message.sessionId);
await yieldToGateway();
worker.postMessage(frame, frame.type === "source-frame" ? [frame.bytes.buffer] : []);
return;
}
if (message.type === "plan-start") {
if (active) throw new Error("session transcript reconcile worker started overlapping plans");
active = await claimPreparedSessionTranscriptProjection(databaseOptions, message.plan, memorySource);
continueProjectionWorker(worker, active !== void 0);
return;
}
if (!active || active.plan.sessionId !== message.sessionId) throw new Error("session transcript reconcile worker sent a chunk for no active plan");
if (message.type === "plan-finish") {
const finalized = await finalizePreparedProjection(databaseOptions, active, memorySource);
active = void 0;
if (finalized) reconciledSessions += 1;
continueProjectionWorker(worker, finalized);
return;
}
const owned = await appendPreparedProjectionChunk(databaseOptions, active, message.type === "active-chunk" ? { activeRows: message.rows } : { ftsRows: decodeFtsChunk(message.chunk) }, memorySource);
if (!owned) active = void 0;
continueProjectionWorker(worker, owned);
} catch (error) {
settle(() => reject(toStringifiedError(error)));
}
};
worker.on("message", (message) => {
if (settled || message.type === "lease-released" || message.type === "lease-release-failed") return;
handlingMessage = handleMessage(message);
});
worker.once("error", (error) => {
settle(() => reject(toStringifiedError(error)));
});
worker.once("exit", (code) => {
if (input.mode === "memory" && terminalReceived && code === 0) return;
settle(() => reject(/* @__PURE__ */ new Error(`session transcript reconcile worker exited with code ${code}`)));
});
});
outcome = ok(value);
} catch (error) {
outcome = err(error);
}
let plannerFailure;
try {
const termination = input.mode !== "disk" || !terminalReceived ? worker.terminate() : void 0;
await handlingMessage;
if (input.mode === "disk" && terminalReceived && worker.threadId !== -1) worker.postMessage({ type: "release" }, []);
else await (termination ?? worker.terminate());
const plannerRelease = await leaseRelease;
if (input.mode === "disk") {
let cleanup = plannerRelease;
if (!cleanup.released && !cleanup.releaseFailed) {
const releaseWorker = createWorker(workerUrl, {
workerData: {
mode: "release",
leaseId: input.leaseId,
stateDir: input.stateDir,
externallySupervised: input.externallySupervised
},
execArgv: sourceWorkerExecArgv
});
try {
cleanup = await observeWorkerLeaseRelease(releaseWorker);
} finally {
await releaseWorker.terminate();
releaseWorker.removeAllListeners();
}
}
if (cleanup.failure) throw cleanup.failure;
if (outcome.ok && plannerRelease.failure) plannerFailure = plannerRelease.failure;
}
} catch (error) {
const failure = new Error(`Transcript lease cleanup incomplete; restart OpenClaw before deleting this agent: ${toStringifiedError(error).message}`, { cause: error });
throw outcome.ok ? failure : new AggregateError([outcome.error, failure], failure.message, { cause: failure });
} finally {
worker.removeAllListeners();
}
if (!outcome.ok) throw outcome.error;
if (plannerFailure) throw plannerFailure;
return outcome.value;
} finally {
memorySource?.clear();
releaseDatabase?.();
}
}
/** Starts one deferred reconcile. No transcript rows are read on the caller's stack. */
function startSessionTranscriptIndexReconcile(input) {
const params = prepareReconcileParams(input);
const key = reconcileKey(params);
const running = runningReconciles.get(key);
if (running) {
running.pending = true;
running.preferredSessionId ??= params.preferredSessionId;
return;
}
const state = {
pending: false,
...params.preferredSessionId ? { preferredSessionId: params.preferredSessionId } : {}
};
const memorySource = captureMemorySource(params);
state.promise = yieldToGateway().then(async () => {
let reconciledSessions = 0;
while (true) {
memorySource?.assertCurrentOwner();
state.pending = false;
const preferredSessionId = state.preferredSessionId;
delete state.preferredSessionId;
const result = await reconcilePreparedTranscriptIndexes({
...params,
...preferredSessionId ? { preferredSessionId } : {}
});
reconciledSessions += result.reconciledSessions;
if (state.pending) continue;
if (runningReconciles.get(key) === state) runningReconciles.delete(key);
return { reconciledSessions };
}
}).catch(async (error) => {
log.warn(`session transcript reconcile failed agent=${params.agentId} error=${error instanceof Error ? error.message : String(error)}`);
const shouldHandoff = state.pending;
const preferredSessionId = state.preferredSessionId;
if (runningReconciles.get(key) === state) runningReconciles.delete(key);
if (shouldHandoff && (!memorySource || captureMemorySource(params))) {
startSessionTranscriptIndexReconcile({
...params,
...preferredSessionId ? { preferredSessionId } : {}
});
await waitForSessionTranscriptIndexReconcile(params);
}
return { reconciledSessions: 0 };
});
runningReconciles.set(key, state);
}
function isSessionTranscriptIndexReconcileRunning(params) {
return runningReconciles.has(reconcileKey(params));
}
/** Test and maintenance wait hook for an already-scheduled reconcile. */
async function waitForSessionTranscriptIndexReconcile(params) {
await runningReconciles.get(reconcileKey(params))?.promise;
}
/** Waits only until the requested session's scheduled projection rebuild settles. */
async function waitForSessionTranscriptProjection(scope, abortSignal) {
const resolved = resolveSqliteTranscriptReadScope(scope);
const databaseOptions = toDatabaseOptions(resolved);
while (isSessionTranscriptIndexReconcileRunning(databaseOptions)) {
const pending = withOpenClawAgentDatabaseReadOnly(({ db }) => sessionTranscriptIndexNeedsReconcile(db, resolved.sessionId), databaseOptions, { throwOnMissingTable: true });
if (!pending.found || !pending.value) break;
await setTimeout(PROJECTION_READY_POLL_MS, void 0, abortSignal ? { signal: abortSignal } : void 0);
}
}
//#endregion
export { waitForSessionTranscriptProjection as a, waitForSessionTranscriptIndexReconcile as i, reconcileSessionTranscriptIndexes as n, startSessionTranscriptIndexReconcile as r, isSessionTranscriptIndexReconcileRunning as t };