/** SQLite-backed ACP session metadata storage keyed through session-store entries. */ import type { DatabaseSync } from "node:sqlite"; import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { Insertable } from "kysely"; import { getRuntimeConfig } from "../../config/config.js"; import { patchSessionEntryWithKey } from "../../config/sessions/session-accessor.js"; import { readLegacyAcpMigrationContext } from "../../config/sessions/session-accessor.sqlite-acp-provenance.js"; import { mergeSessionEntry, type AcpSessionRuntimeOptions, type SessionAcpIdentity, type SessionAcpMeta, type SessionEntry, } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import { legacyAcpMigrationBindingMatches, recordLegacyAcpMigrationCompletion, } from "../../infra/legacy-acp-migration-source.js"; import { withExistingOpenClawStateDatabaseReadOnly } from "../../state/openclaw-state-db-readonly.js"; import { type OpenClawStateDatabaseOptions, runOpenClawStateWriteTransaction, } from "../../state/openclaw-state-db.js"; import { acpSessionRowMatchesEntry, type AcpSessionEntryBinding, type AcpSessionRow, type AcpSessionsTable, buildAcpDatabaseSessionKey, getAcpSessionKysely, legacyAcpDatabaseSessionKeys, parseAcpDatabaseSessionKeyCandidates, resolveReadableAcpSessionRow, selectAcpSessionRow, selectAcpSessionRowForStoreEntry, } from "./session-meta-keys.js"; import { clearLegacyEmbeddedAcpMetadata } from "./session-meta-legacy-cleanup.js"; import { readSessionEntryFromStore, resolveSessionStorePathForAcp, resolveStoreEntryForSessionKey, } from "./session-meta-store.js"; /** ACP metadata joined with its legacy session-store row and config context. */ export { resolveSessionStorePathForAcp } from "./session-meta-store.js"; export type AcpSessionStoreEntry = { cfg: OpenClawConfig; agentId?: string; storePath: string; sessionKey: string; storeSessionKey: string; entry?: SessionEntry; acp?: SessionAcpMeta; storeReadFailed?: boolean; }; export function rowToAcpSessionMeta(row: AcpSessionRow): SessionAcpMeta { const identity = safeParseJsonRecord(row.identity_json ?? "") as SessionAcpIdentity | undefined; const runtimeOptions = safeParseJsonRecord(row.runtime_options_json ?? "") as | AcpSessionRuntimeOptions | undefined; return { backend: row.backend, agent: row.agent, runtimeSessionName: row.runtime_session_name, ...(identity ? { identity } : {}), mode: row.mode === "oneshot" ? "oneshot" : "persistent", ...(runtimeOptions ? { runtimeOptions } : {}), ...(row.cwd != null ? { cwd: row.cwd } : {}), state: row.state === "running" || row.state === "error" ? row.state : "idle", lastActivityAt: row.last_activity_at, ...(row.last_error != null ? { lastError: row.last_error } : {}), }; } function bindAcpSessionMeta(params: { sessionKey: string; sessionId?: string; lifecycleRevision?: string; meta: SessionAcpMeta; updatedAt: number; }): Insertable { return { session_key: params.sessionKey, // Kept in the existing column for schema neutrality. New rows prefer the // lifecycle revision; pre-revision entries retain the session-id fence. session_id: params.lifecycleRevision ?? params.sessionId ?? null, backend: params.meta.backend, agent: params.meta.agent, runtime_session_name: params.meta.runtimeSessionName, identity_json: params.meta.identity ? JSON.stringify(params.meta.identity) : null, mode: params.meta.mode, runtime_options_json: params.meta.runtimeOptions ? JSON.stringify(params.meta.runtimeOptions) : null, cwd: params.meta.cwd ?? null, state: params.meta.state, last_activity_at: params.meta.lastActivityAt, last_error: params.meta.lastError ?? null, updated_at: params.updatedAt, }; } export function readAcpSessionMeta(params: { sessionKey: string; agentId?: string; cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; databasePath?: string; }): SessionAcpMeta | undefined { return readAcpSessionEntry({ ...params, sessionKey: params.sessionKey.trim(), clone: false, })?.acp; } export function readAcpSessionMetaForEntry(params: { sessionKey: string; agentId?: string; cfg?: OpenClawConfig; entry: AcpSessionEntryBinding | undefined; env?: NodeJS.ProcessEnv; databasePath?: string; }): SessionAcpMeta | undefined { const sessionKey = params.sessionKey.trim(); if (!sessionKey) { return undefined; } const row = withExistingOpenClawStateDatabaseReadOnly( ({ db }) => resolveReadableAcpSessionRow({ row: selectAcpSessionRowForStoreEntry( db, sessionKey, params.agentId, params.cfg, params.entry, ), entry: params.entry, }), { env: params.env, path: params.databasePath }, ); if (!row) { return undefined; } return rowToAcpSessionMeta(row); } export function readAcpSessionMetaBatch(params: { entries: ReadonlyArray<{ sessionKey: string; agentId?: string; entry: SessionEntry; }>; env?: NodeJS.ProcessEnv; databasePath?: string; cfg?: OpenClawConfig; }): Map { const result = new Map(); const entriesByKey = new Map< string, Array<{ entry: SessionEntry; rawSessionKey: string; legacyKeys: string[] }> >(); for (const item of params.entries) { const rawSessionKey = item.sessionKey.trim(); const sessionKey = buildAcpDatabaseSessionKey(rawSessionKey, item.agentId); if (item.entry?.acp) { result.set(item.entry, item.entry.acp); continue; } const legacyKeys = legacyAcpDatabaseSessionKeys(rawSessionKey, item.agentId, params.cfg); const entries = entriesByKey.get(sessionKey) ?? []; entries.push({ entry: item.entry, rawSessionKey, legacyKeys }); entriesByKey.set(sessionKey, entries); } if (entriesByKey.size === 0) { return result; } withExistingOpenClawStateDatabaseReadOnly( ({ db: database }) => { // Chunked IN keeps each statement under SQLite's bind-variable cap, matching the // sharing-store membership precedent; one statement per 500 keys instead of per row. const db = getAcpSessionKysely(database); const requestedKeySet = new Set(); for (const [sessionKey, entries] of entriesByKey) { requestedKeySet.add(sessionKey); for (const item of entries) { for (const legacyKey of item.legacyKeys) { requestedKeySet.add(legacyKey); } } } const requestedKeys = [...requestedKeySet]; const keyChunks: string[][] = []; for (let index = 0; index < requestedKeys.length; index += 500) { keyChunks.push(requestedKeys.slice(index, index + 500)); } const rows = keyChunks.flatMap( (chunk) => executeSqliteQuerySync( database, db.selectFrom("acp_sessions").selectAll().where("session_key", "in", chunk), ).rows, ); const rowsByKey = new Map(rows.map((row) => [row.session_key, row])); for (const [sessionKey, entries] of entriesByKey) { for (const item of entries) { const row = [sessionKey, ...item.legacyKeys] .map((key) => rowsByKey.get(key)) .map((candidateRow) => resolveReadableAcpSessionRow({ row: candidateRow, entry: item.entry }), ) .find((candidateRow) => candidateRow !== undefined); result.set(item.entry, row ? rowToAcpSessionMeta(row) : undefined); } } }, { env: params.env, path: params.databasePath }, ); return result; } function selectAcpSessionRows(options: OpenClawStateDatabaseOptions = {}): AcpSessionRow[] { return ( withExistingOpenClawStateDatabaseReadOnly( ({ db }) => executeSqliteQuerySync( db, getAcpSessionKysely(db) .selectFrom("acp_sessions") .selectAll() .orderBy("last_activity_at", "desc") .orderBy("session_key", "asc"), ).rows, options, ) ?? [] ); } export function writeAcpSessionMetaForMigration(params: { sessionKey: string; sessionId?: string; lifecycleRevision?: string; meta: SessionAcpMeta; env?: NodeJS.ProcessEnv; database?: OpenClawStateDatabaseOptions["database"]; databasePath?: string; now?: () => number; }): void { const sessionKey = params.sessionKey.trim(); if (!sessionKey) { return; } const row = bindAcpSessionMeta({ sessionKey, sessionId: params.sessionId, lifecycleRevision: params.lifecycleRevision, meta: params.meta, updatedAt: params.now?.() ?? Date.now(), }); runOpenClawStateWriteTransaction( (database) => { upsertAcpSessionMetaRow(database.db, row); }, { database: params.database, env: params.env, path: params.databasePath }, ); } export function repairAcpSessionMetaKeyForMigration(params: { sessionKey: string; candidateSessionKeys?: Iterable; entry?: AcpSessionEntryBinding; env?: NodeJS.ProcessEnv; databasePath?: string; now?: () => number; }): boolean { const sessionKey = params.sessionKey.trim(); if (!sessionKey) { return false; } let repaired = false; runOpenClawStateWriteTransaction( (database) => { const currentRow = selectAcpSessionRow(database.db, sessionKey); if (currentRow && acpSessionRowMatchesEntry(currentRow, params.entry)) { return; } const normalizedSessionKey = normalizeLowercaseStringOrEmpty(sessionKey); const candidateKeys = new Set(); candidateKeys.add(normalizedSessionKey); for (const candidate of params.candidateSessionKeys ?? []) { const trimmed = typeof candidate === "string" ? candidate.trim() : ""; if ( trimmed && trimmed !== sessionKey && normalizeLowercaseStringOrEmpty(trimmed) === normalizedSessionKey ) { candidateKeys.add(trimmed); } } let row: AcpSessionRow | undefined; for (const candidateKey of candidateKeys) { const candidateRow = selectAcpSessionRow(database.db, candidateKey); if (candidateRow && acpSessionRowMatchesEntry(candidateRow, params.entry)) { row = candidateRow; break; } } row ??= executeSqliteQuerySync( database.db, getAcpSessionKysely(database.db) .selectFrom("acp_sessions") .selectAll() .where((eb) => eb.fn("lower", ["session_key"]), "=", normalizedSessionKey) .orderBy("last_activity_at", "desc") .orderBy("session_key", "asc"), ).rows.find( (candidate) => candidate.session_key !== sessionKey && acpSessionRowMatchesEntry(candidate, params.entry), ); if (!row) { return; } upsertAcpSessionMetaRow(database.db, { ...row, session_key: sessionKey, updated_at: params.now?.() ?? Date.now(), }); executeSqliteQuerySync( database.db, getAcpSessionKysely(database.db) .deleteFrom("acp_sessions") .where("session_key", "=", row.session_key), ); repaired = true; }, { env: params.env, path: params.databasePath }, ); return repaired; } function upsertAcpSessionMetaRow(db: DatabaseSync, row: Insertable): void { executeSqliteQuerySync( db, getAcpSessionKysely(db) .insertInto("acp_sessions") .values(row) .onConflict((conflict) => conflict.column("session_key").doUpdateSet({ session_id: (eb) => eb.ref("excluded.session_id"), backend: (eb) => eb.ref("excluded.backend"), agent: (eb) => eb.ref("excluded.agent"), runtime_session_name: (eb) => eb.ref("excluded.runtime_session_name"), identity_json: (eb) => eb.ref("excluded.identity_json"), mode: (eb) => eb.ref("excluded.mode"), runtime_options_json: (eb) => eb.ref("excluded.runtime_options_json"), cwd: (eb) => eb.ref("excluded.cwd"), state: (eb) => eb.ref("excluded.state"), last_activity_at: (eb) => eb.ref("excluded.last_activity_at"), last_error: (eb) => eb.ref("excluded.last_error"), updated_at: (eb) => eb.ref("excluded.updated_at"), }), ), ); } export function readAcpSessionEntry(params: { sessionKey: string; agentId?: string; cfg?: OpenClawConfig; clone?: boolean; env?: NodeJS.ProcessEnv; databasePath?: string; }): AcpSessionStoreEntry | null { const sessionKey = params.sessionKey.trim(); if (!sessionKey) { return null; } const storeEntry = readSessionEntryFromStore(params); if (!storeEntry.storePath) { return null; } const acp = readAcpSessionMetaForEntry({ sessionKey: storeEntry.storeSessionKey, agentId: storeEntry.agentId, cfg: storeEntry.cfg, entry: storeEntry.entry, env: params.env, databasePath: params.databasePath, }); return { cfg: storeEntry.cfg, agentId: storeEntry.agentId, storePath: storeEntry.storePath, sessionKey, storeSessionKey: storeEntry.storeSessionKey, entry: storeEntry.entry, acp, storeReadFailed: storeEntry.storeReadFailed, }; } export async function listAcpSessionEntries(params: { cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; clone?: boolean; databasePath?: string; }): Promise { const cfg = params.cfg ?? getRuntimeConfig(); const rows = selectAcpSessionRows({ env: params.env, path: params.databasePath, }); const entries: AcpSessionStoreEntry[] = []; for (const row of rows) { for (const databaseIdentity of parseAcpDatabaseSessionKeyCandidates(row.session_key)) { const sessionKey = databaseIdentity.storeSessionKey; const { agentId, storePath } = resolveSessionStorePathForAcp({ sessionKey, agentId: databaseIdentity.agentId, cfg, env: params.env, }); if (!storePath) { continue; } let storeSessionKey: string; let entry: SessionEntry | undefined; try { ({ storeSessionKey, entry } = resolveStoreEntryForSessionKey({ ...(agentId ? { agentId } : {}), storePath, sessionKey, ...(params.clone === false ? { clone: false } : {}), })); } catch { continue; } const readableRow = resolveReadableAcpSessionRow({ row, entry, }); if (!entry || !readableRow) { continue; } entries.push({ cfg, agentId, storePath, sessionKey, storeSessionKey, entry, acp: rowToAcpSessionMeta(readableRow), }); break; } } return entries; } function mergeAcpForReturn(entry: SessionEntry | undefined, acp: SessionAcpMeta): SessionEntry { return mergeSessionEntry(entry, { acp }); } function sessionStoreUpdateOptions(params: { sessionKey: string; skipMaintenance?: boolean; takeCacheOwnership?: boolean; }) { return { activeSessionKey: normalizeLowercaseStringOrEmpty(params.sessionKey), ...(params.skipMaintenance === true ? { skipMaintenance: true } : {}), ...(params.takeCacheOwnership === true ? { takeCacheOwnership: true } : {}), }; } function consumeLegacyAcpMigrationSources(params: { database: DatabaseSync; agentId?: string; storePath: string; sessionKey: string; entry: SessionEntry | undefined; env?: NodeJS.ProcessEnv; now: number; }): void { if (!params.entry) { return; } const current = readLegacyAcpMigrationContext(params); if (current.sources.length === 0) { return; } if ( current.entry?.sessionId !== params.entry.sessionId || current.entry.lifecycleRevision !== params.entry.lifecycleRevision ) { throw new Error("Canonical ACP session changed before legacy source consumption."); } for (const source of current.sources) { if (legacyAcpMigrationBindingMatches(source, current.entry)) { recordLegacyAcpMigrationCompletion(params.database, source, params.now); } } } export async function upsertAcpSessionMeta(params: { assertCommitAllowed?: () => void; sessionKey: string; agentId?: string; cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; databasePath?: string; now?: () => number; skipMaintenance?: boolean; takeCacheOwnership?: boolean; mutate: ( current: SessionAcpMeta | undefined, entry: SessionEntry | undefined, ) => SessionAcpMeta | null | undefined; }): Promise { const sessionKey = params.sessionKey.trim(); if (!sessionKey) { return null; } const storeEntry = readSessionEntryFromStore({ sessionKey, agentId: params.agentId, cfg: params.cfg, env: params.env, clone: false, }); if (!storeEntry.storePath) { return null; } const { entry, storePath } = storeEntry; const storageSessionKey = storeEntry.storeSessionKey; const databaseSessionKey = buildAcpDatabaseSessionKey(storageSessionKey, storeEntry.agentId); let current: SessionAcpMeta | undefined; let currentRowKey: string | undefined; let nextMeta: SessionAcpMeta | null | undefined; let preparedEntry: SessionEntry | undefined; const updatedAt = params.now?.() ?? Date.now(); runOpenClawStateWriteTransaction( (database) => { const currentRow = selectAcpSessionRowForStoreEntry( database.db, storageSessionKey, storeEntry.agentId, storeEntry.cfg, entry, ); currentRowKey = currentRow?.session_key; current = currentRow ? rowToAcpSessionMeta(currentRow) : undefined; preparedEntry = mergeSessionEntry(entry, { updatedAt }); nextMeta = params.mutate( current, current ? mergeAcpForReturn(preparedEntry, current) : entry, ); }, { env: params.env, path: params.databasePath }, ); const metaToPersist = nextMeta; if (metaToPersist === undefined) { return current ? mergeAcpForReturn(entry, current) : (entry ?? null); } if (metaToPersist === null) { const patched = entry ? await patchSessionEntryWithKey( { ...(storeEntry.agentId ? { agentId: storeEntry.agentId } : {}), storePath: storeEntry.storePath, sessionKey: storageSessionKey, }, (currentEntry) => { const next = { ...currentEntry }; delete next.acp; return next; }, { ...sessionStoreUpdateOptions({ ...params, sessionKey: storageSessionKey }), replaceEntry: true, assertCommitAllowed: params.assertCommitAllowed, }, ) : null; runOpenClawStateWriteTransaction( (database) => { params.assertCommitAllowed?.(); consumeLegacyAcpMigrationSources({ database: database.db, agentId: storeEntry.agentId, storePath, sessionKey: patched?.sessionKey ?? storageSessionKey, entry: patched?.entry ?? entry, env: params.env, now: updatedAt, }); const sessionKeysToDelete = new Set([databaseSessionKey]); if (currentRowKey) { sessionKeysToDelete.add(currentRowKey); } if (patched?.sessionKey) { sessionKeysToDelete.add( buildAcpDatabaseSessionKey(patched.sessionKey, storeEntry.agentId), ); } for (const key of sessionKeysToDelete) { executeSqliteQuerySync( database.db, getAcpSessionKysely(database.db) .deleteFrom("acp_sessions") .where("session_key", "=", key), ); } }, { env: params.env, path: params.databasePath }, ); await clearLegacyEmbeddedAcpMetadata({ agentId: storeEntry.agentId, storePath: storeEntry.storePath, sessionKeys: [storageSessionKey, patched?.sessionKey], }); return patched?.entry ?? null; } const persisted = await patchSessionEntryWithKey( { ...(storeEntry.agentId ? { agentId: storeEntry.agentId } : {}), storePath: storeEntry.storePath, sessionKey: storageSessionKey, }, (currentEntry) => { const next = mergeSessionEntry(currentEntry, { updatedAt, }); delete next.acp; return next; }, { ...sessionStoreUpdateOptions({ ...params, sessionKey: storageSessionKey }), fallbackEntry: preparedEntry, replaceEntry: true, assertCommitAllowed: params.assertCommitAllowed, }, ); if (!persisted) { return null; } await clearLegacyEmbeddedAcpMetadata({ agentId: storeEntry.agentId, storePath: storeEntry.storePath, sessionKeys: [storageSessionKey, persisted.sessionKey], }); runOpenClawStateWriteTransaction( (database) => { // The entry patch and legacy cleanup await before this authoritative publication. params.assertCommitAllowed?.(); consumeLegacyAcpMigrationSources({ database: database.db, agentId: storeEntry.agentId, storePath, sessionKey: persisted.sessionKey, entry: persisted.entry, env: params.env, now: updatedAt, }); const persistedDatabaseSessionKey = buildAcpDatabaseSessionKey( persisted.sessionKey, storeEntry.agentId, ); upsertAcpSessionMetaRow( database.db, bindAcpSessionMeta({ sessionKey: persistedDatabaseSessionKey, sessionId: persisted.entry.sessionId, lifecycleRevision: persisted.entry.lifecycleRevision, meta: metaToPersist, updatedAt: persisted.entry.updatedAt, }), ); if (persistedDatabaseSessionKey !== databaseSessionKey) { executeSqliteQuerySync( database.db, getAcpSessionKysely(database.db) .deleteFrom("acp_sessions") .where("session_key", "=", databaseSessionKey), ); } if (currentRowKey && currentRowKey !== persistedDatabaseSessionKey) { executeSqliteQuerySync( database.db, getAcpSessionKysely(database.db) .deleteFrom("acp_sessions") .where("session_key", "=", currentRowKey), ); } if (persistedDatabaseSessionKey !== persisted.sessionKey) { const legacyRow = selectAcpSessionRow(database.db, persisted.sessionKey); if (legacyRow && acpSessionRowMatchesEntry(legacyRow, persisted.entry)) { executeSqliteQuerySync( database.db, getAcpSessionKysely(database.db) .deleteFrom("acp_sessions") .where("session_key", "=", persisted.sessionKey), ); } } }, { env: params.env, path: params.databasePath }, ); return mergeAcpForReturn(persisted.entry, metaToPersist); }