openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,200 lines • 59.2 kB
JavaScript
import { m as shortenHomePath } from "./utils-P__uGsPB.js";
import { n as normalizeAgentId } from "./agent-id-CeT3w4ap.js";
import { t as formatCliCommand } from "./command-format-C7YfyMTd.js";
import { c as resolveAgentConfig } from "./agent-scope-config-DcbEhP0R.js";
import { w as resolveStateDir } from "./paths-D2sRr1a_.js";
import { O as parseAgentSessionKey } from "./session-key-BnWWjqNc.js";
import { t as isIncognitoSessionKey } from "./incognito-session-key-BwpD1Lwd.js";
import { n as replaceFileAtomic } from "./replace-file-BAJ-TWzD.js";
import { a as getNodeSqliteKysely, i as executeSqliteQueryTakeFirstSync, o as iterateSqliteQuerySync, r as executeSqliteQuerySync } from "./kysely-sync-COmh4HWh.js";
import { r as withExistingOpenClawStateDatabaseReadOnly } from "./openclaw-state-db-readonly-BRgmrGHt.js";
import { i as openOpenClawStateDatabase, s as runOpenClawStateWriteTransaction } from "./openclaw-state-db-BRTnL-D8.js";
import { o as resolveSessionStorePathCore } from "./paths-CXdaYWF_.js";
import { _ as resolveSessionAgentId } from "./agent-scope-DbtJyKUL.js";
import { b as closeOpenClawAgentDatabaseByPath, g as runOpenClawAgentWriteTransaction, p as openOpenClawAgentDatabase, u as isOpenClawAgentDatabaseOpen } from "./openclaw-agent-db-CWtDoRbC.js";
import { Wt as applySessionEntryLifecycleMutation, dt as listCanonicalSessionRepairFacts, ft as loadCanonicalSessionRepairEntries, mt as scanDoctorSessionEntriesTolerant, ut as rewriteDoctorSessionEntries } from "./session-accessor-YsytfDtG.js";
import { b as loadTranscriptEvents } from "./session-accessor.sqlite-transcript-store-B0zw3fAU.js";
import { n as withOpenClawAgentDatabaseReadOnly } from "./openclaw-agent-db-readonly-CHjf8FxN.js";
import { o as listSessionEntryKeysReadOnly } from "./session-accessor.sqlite-entry-CWk3jL7s.js";
import { C as copySessionNodeArtifactsForRepair, S as collectSessionStateIdsForEntry, T as deleteSessionMembersForRepair } from "./session-accessor.sqlite-entry-store-BxYl0nro.js";
import { l as stripRuntimeOnlySessionSkillsFields } from "./store-writer-state-C4OG_EQ4.js";
import { o as resolveDeliveryProvenCanonicalSessionKey } from "./store-entry-CzRELcpv.js";
import { u as resolveSqliteTranscriptArchiveDirectory } from "./session-accessor.sqlite-scope-2KfMzb44.js";
import { _ as setCanonicalSqliteSessionMainKey, v as publishSessionEntryCacheInvalidation } from "./session-accessor.sqlite-transcript-state-DwF2owZS.js";
import { i as preserveCreationStamp } from "./session-entry-provenance-jzrCUpdQ.js";
import { w as writeTranscriptArchive } from "./session-accessor.sqlite-lifecycle-state-BTh4yZ7R.js";
import { r as resolveAgentMainSessionKey } from "./main-session-Br0F9dzh.js";
import { a as resolveStoredSessionKeyForAgentStore, n as resolveSessionStoreAgentId } from "./session-store-key-8xEjWSNi.js";
import { a as rehomeSqliteSessionDeliveryReferencesForCanonicalRepair, n as ensureSqliteTranscriptGenerationsForCanonicalRepair, o as rehomeSqliteSessionDeliveryReferencesForCanonicalRepairBatch, r as listSqliteSessionGenerationIdsForCanonicalRepair, t as copySqliteSessionOwnedStateForCanonicalRepair } from "./session-accessor.sqlite-canonical-repair-ChKhq4_W.js";
import { t as resolveAgentSessionDirs } from "./session-dirs-SATsXf4g.js";
import { o as resolveAllAgentSessionStoreTargetsSync } from "./targets-Cknmo7YZ.js";
import { n as replaceSessionOwnerInTransaction } from "./session-accessor.sqlite-owner-DysLwyNh.js";
import { _ as normalizeExecSecurity, b as resolveExecModeFromPolicy, m as normalizeExecAsk, v as normalizeExecTarget } from "./exec-approvals-policy-CCFUzTzd.js";
import { t as SESSION_PERMISSION_BY_EXEC_MODE } from "./session-permission-exec-mode-CJHB47SR.js";
import { t as applyExecPolicyLayer } from "./exec-policy-D6Y68q04.js";
import { h as resolveExecTarget } from "./bash-tools.exec-runtime-CbUMPDGW.js";
import { t as note } from "./note-DqHk3fA1.js";
import { n as normalizeLegacySessionEntryDelivery } from "./state-migrations.legacy-session-store-CQJX6yZy.js";
import { i as selectActiveTranscriptEntries, n as normalizeLegacyOpenAICodexTranscriptMetadata, r as selectActivePath, t as hasBrokenPromptRewriteBranch } from "./legacy-transcript-repair-nrJd98DH.js";
import { i as withDoctorSqliteMaintenanceLock, t as DoctorSqliteMaintenanceLockUnavailableError } from "./doctor-sqlite-maintenance-lock-BDyGk9pJ.js";
import { d as resolveTargetSqlitePath, i as projectExistingAgentDatabaseTargets, r as listExistingAgentDatabaseTargets, u as resolveTargetSqliteOptions } from "./doctor-session-sqlite-readers-DxvNNHSA.js";
import { i as runPostSessionPluginDoctorStateRepairs } from "./state-migrations.plugin-doctor-D3WjbYIE.js";
import { t as runDoctorAgentDatabaseOperation } from "./doctor-agent-database-operation-DzkjHFCH.js";
import path from "node:path";
import fs from "node:fs/promises";
import { sql } from "kysely";
//#region src/config/sessions/transcript-jsonl.ts
function serializeJsonlLines(lines) {
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
}
//#endregion
//#region src/commands/doctor-session-canonical-owner-evidence.ts
/** Projects transcript-owner evidence through aliases and indexes every proven source key. */
function applyCanonicalOwnerEvidence(inventory) {
const bySessionKey = new Map(inventory.map((item) => [`${item.target.sqlitePath}\0${item.sessionKey}`, item]));
const resolveCanonicalKey = (item, seen = /* @__PURE__ */ new Set()) => {
if (!item.canonicalOwnerSessionKey) return item.canonicalKey;
const identity = `${item.target.sqlitePath}\0${item.sessionKey}`;
const owner = bySessionKey.get(`${item.target.sqlitePath}\0${item.canonicalOwnerSessionKey}`);
if (!owner || seen.has(identity)) return item.canonicalKey;
seen.add(identity);
return owner.canonicalOwnerSessionKey ? resolveCanonicalKey(owner, seen) : owner.canonicalKey;
};
const canonicalKeysByStoredKey = /* @__PURE__ */ new Map();
for (const item of inventory) {
item.canonicalKey = resolveCanonicalKey(item);
const ownerAgentId = parseAgentSessionKey(item.storedKey)?.agentId ?? item.target.agentId;
for (const key of [item.sessionKey, item.storedKey]) for (const sqlitePath of [item.target.sqlitePath, "*"]) {
const mappingKey = `${sqlitePath}\0${ownerAgentId}\0${key}`;
const mapped = canonicalKeysByStoredKey.get(mappingKey) ?? /* @__PURE__ */ new Set();
mapped.add(item.canonicalKey);
canonicalKeysByStoredKey.set(mappingKey, mapped);
}
}
return canonicalKeysByStoredKey;
}
//#endregion
//#region src/commands/doctor-session-canonical-candidates.ts
function listCanonicalSessionStores(params) {
return projectExistingAgentDatabaseTargets(resolveAllAgentSessionStoreTargetsSync(params.cfg, { env: params.env }), params.env);
}
function collectCanonicalSessionCandidateFacts(params, stores) {
const inventory = stores.flatMap((target) => listCanonicalSessionRepairFacts({
agentId: target.agentId,
storePath: target.storePath
}).map((inventoryFact) => {
const { canonicalOwnerSessionKey, sessionKey } = inventoryFact;
const storedKey = resolveStoredSessionKeyForAgentStore({
cfg: params.cfg,
agentId: target.agentId,
sessionKey
});
return {
canonicalKey: storedKey ? resolveDeliveryProvenCanonicalSessionKey(storedKey, inventoryFact) : resolveAgentMainSessionKey({
cfg: params.cfg,
agentId: target.agentId
}),
canonicalOwnerSessionKey,
inventoryFact,
sessionKey,
storedKey,
target
};
}));
const canonicalKeysByStoredKey = applyCanonicalOwnerEvidence(inventory);
return inventory.map(({ canonicalKey, canonicalOwnerSessionKey, inventoryFact, sessionKey, target }) => {
const canonicalAgentId = canonicalKey === "global" || canonicalKey === "unknown" ? target.agentId : resolveSessionStoreAgentId(params.cfg, canonicalKey);
const canonicalizeLineageKey = (value) => {
if (!value) return;
const storedKey = resolveStoredSessionKeyForAgentStore({
cfg: params.cfg,
agentId: canonicalAgentId,
sessionKey: value
});
const ownerAgentId = parseAgentSessionKey(storedKey)?.agentId ?? canonicalAgentId;
for (const key of [value, storedKey]) {
const sameStore = canonicalKeysByStoredKey.get(`${target.sqlitePath}\0${ownerAgentId}\0${key}`);
if (sameStore?.size === 1) return [...sameStore][0];
}
for (const key of [value, storedKey]) {
const crossStore = canonicalKeysByStoredKey.get(`*\0${ownerAgentId}\0${key}`);
if (crossStore?.size === 1) return [...crossStore][0];
}
return storedKey;
};
const parentSessionKey = canonicalizeLineageKey(inventoryFact.parentSessionKey);
const spawnedBy = canonicalizeLineageKey(inventoryFact.spawnedBy);
const forkSourceSessionKey = canonicalizeLineageKey(inventoryFact.forkSourceSessionKey);
return Object.assign({
agentId: target.agentId,
canonicalKey,
inventoryFact,
lineageRepairRequired: parentSessionKey !== inventoryFact.parentSessionKey || spawnedBy !== inventoryFact.spawnedBy || forkSourceSessionKey !== inventoryFact.forkSourceSessionKey,
ownerEvidenceOnly: canonicalOwnerSessionKey !== void 0,
sessionKey,
sqlitePath: target.sqlitePath,
storePath: target.storePath
}, forkSourceSessionKey ? { normalizedForkSourceSessionKey: forkSourceSessionKey } : {}, parentSessionKey ? { normalizedParentSessionKey: parentSessionKey } : {}, spawnedBy ? { normalizedSpawnedBy: spawnedBy } : {});
});
}
function resolveCanonicalSessionDestination(params) {
const agentId = params.canonicalKey === "global" || params.canonicalKey === "unknown" ? normalizeAgentId(params.sourceAgentId ?? resolveSessionStoreAgentId(params.cfg, params.canonicalKey)) : resolveSessionStoreAgentId(params.cfg, params.canonicalKey);
const storePath = resolveSessionStorePathCore(params.cfg.session?.store, {
agentId,
env: params.env
});
return {
agentId,
storePath,
sqlitePath: resolveTargetSqlitePath({
agentId,
storePath
})
};
}
function groupRepairCandidates(candidates, params) {
const byCanonicalKey = /* @__PURE__ */ new Map();
for (const candidate of candidates) {
const sentinelOwner = candidate.canonicalKey === "global" || candidate.canonicalKey === "unknown" ? candidate.agentId : "";
const groupKey = `${candidate.canonicalKey}\0${sentinelOwner}`;
const group = byCanonicalKey.get(groupKey) ?? [];
group.push(candidate);
byCanonicalKey.set(groupKey, group);
}
return [...byCanonicalKey.values()].flatMap((group) => {
const first = group[0];
const destination = resolveCanonicalSessionDestination({
canonicalKey: first.canonicalKey,
cfg: params.cfg,
env: params.env,
sourceAgentId: first.agentId
});
if (!(group.length > 1 || group.some((candidate) => candidate.inventoryFact.rawCompareRequired || candidate.lineageRepairRequired || candidate.sessionKey !== candidate.canonicalKey || candidate.sqlitePath !== destination.sqlitePath))) return [];
const canonicalRowSurvives = group.some((candidate) => candidate.sqlitePath === destination.sqlitePath && candidate.sessionKey === candidate.canonicalKey);
return [{
candidates: group,
removedRows: group.length - (canonicalRowSurvives ? 1 : 0)
}];
});
}
function collectCanonicalSessionRepairGroups(params, stores) {
return groupRepairCandidates(collectCanonicalSessionCandidateFacts(params, stores), params);
}
//#endregion
//#region src/commands/doctor-session-canonical-keys.ts
function createCanonicalRepairRemoval(candidate, params) {
const removal = {
archiveRemovedTranscript: params.archiveRemovedTranscript,
deleteOwnedWindows: params.deleteOwnedWindows,
...params.deliveryCleanupKeys ? { deliveryCleanupKeys: params.deliveryCleanupKeys } : {},
exactStoredKey: true,
expectedEntry: candidate.expectedEntry,
sessionKey: candidate.sessionKey
};
return candidate.rawEntryJson === void 0 ? removal : Object.assign(removal, { expectedRawEntryJson: candidate.rawEntryJson });
}
const CANONICAL_SESSION_REPAIR_BATCH_GROUP_LIMIT = 64;
function hydrateCanonicalSessionCandidate(fact, loaded) {
const entry = { ...loaded.entry };
if (fact.normalizedParentSessionKey) entry.parentSessionKey = fact.normalizedParentSessionKey;
else delete entry.parentSessionKey;
if (fact.normalizedSpawnedBy) entry.spawnedBy = fact.normalizedSpawnedBy;
else delete entry.spawnedBy;
if (entry.forkSource && fact.normalizedForkSourceSessionKey) entry.forkSource = {
...entry.forkSource,
sessionKey: fact.normalizedForkSourceSessionKey
};
else if (entry.forkSource?.sessionKey !== void 0) {
const { sessionKey: _invalidSessionKey, ...forkProvenance } = entry.forkSource;
entry.forkSource = forkProvenance;
}
return {
agentId: fact.agentId,
canonicalKey: fact.canonicalKey,
entry,
expectedEntry: loaded.entry,
ownerEvidenceOnly: fact.ownerEvidenceOnly,
...loaded.rawEntryJson !== void 0 ? { rawEntryJson: loaded.rawEntryJson } : {},
sessionKey: fact.sessionKey,
sqlitePath: fact.sqlitePath,
storePath: fact.storePath
};
}
function hydrateCanonicalSessionCandidates(facts) {
const loaded = /* @__PURE__ */ new Map();
const byStore = /* @__PURE__ */ new Map();
for (const fact of facts) {
const key = `${fact.agentId}\0${fact.storePath}`;
byStore.set(key, [...byStore.get(key) ?? [], fact]);
}
for (const group of byStore.values()) {
const first = group[0];
if (!first) continue;
const entries = loadCanonicalSessionRepairEntries({
agentId: first.agentId,
storePath: first.storePath
}, group.map((fact) => fact.inventoryFact));
group.forEach((fact, index) => loaded.set(fact, entries[index]));
}
return facts.map((fact) => hydrateCanonicalSessionCandidate(fact, loaded.get(fact)));
}
function mergeCanonicalSessionEntryCandidates(candidates) {
let selected;
for (const candidate of candidates) {
const incomingUpdatedAt = typeof candidate.entry.updatedAt === "number" && Number.isFinite(candidate.entry.updatedAt) ? candidate.entry.updatedAt : 0;
const selectedUpdatedAt = typeof selected?.entry.updatedAt === "number" && Number.isFinite(selected.entry.updatedAt) ? selected.entry.updatedAt : 0;
if (!selected || incomingUpdatedAt > selectedUpdatedAt || incomingUpdatedAt === selectedUpdatedAt && (candidate.preferred === true ? !selected.preferred : !selected.preferred && Buffer.compare(Buffer.from(JSON.stringify(candidate.entry), "utf8"), Buffer.from(JSON.stringify(selected.entry), "utf8")) > 0)) selected = {
entry: structuredClone(candidate.entry),
preferred: candidate.preferred === true,
winner: candidate.value
};
}
return selected;
}
function selectCanonicalSessionCandidate(candidates, params) {
const first = candidates[0];
if (!first) return;
const destination = resolveCanonicalSessionDestination({
canonicalKey: first.canonicalKey,
cfg: params.cfg,
env: params.env,
sourceAgentId: first.agentId
});
const rankedCandidates = candidates.toSorted((left, right) => Buffer.compare(Buffer.from(`${left.sqlitePath}\0${left.sessionKey}`, "utf8"), Buffer.from(`${right.sqlitePath}\0${right.sessionKey}`, "utf8"))).map((candidate) => ({
entry: candidate.entry,
preferred: candidate.sqlitePath === destination.sqlitePath && candidate.sessionKey === candidate.canonicalKey,
value: candidate
}));
const metadataCandidates = rankedCandidates.filter(({ value }) => !value.ownerEvidenceOnly);
const selected = mergeCanonicalSessionEntryCandidates(metadataCandidates.length > 0 ? metadataCandidates : rankedCandidates);
if (!selected) return;
const requiredCandidates = rankedCandidates.filter(({ entry }) => entry.sandbox === "required");
const authoritativeStamp = requiredCandidates.find(({ preferred }) => preferred)?.entry ?? mergeCanonicalSessionEntryCandidates(requiredCandidates)?.entry;
return {
...selected,
entry: preserveCreationStamp(selected.entry, authoritativeStamp),
destination
};
}
function resolveSingleDatabaseCanonicalRepairGroup(candidates, params) {
const selected = selectCanonicalSessionCandidate(candidates, params);
if (!selected || selected.winner.sqlitePath !== selected.destination.sqlitePath || candidates.some((candidate) => candidate.sqlitePath !== selected.destination.sqlitePath)) return;
return {
candidates,
selected
};
}
function createCanonicalDestinationRemovals(candidates, selected) {
const relatedSessionIds = new Set([selected.entry.sessionId, selected.entry.previousSessionId].filter((value) => typeof value === "string" && value.length > 0));
return candidates.filter((candidate) => candidate.sessionKey !== selected.winner.canonicalKey || candidate.rawEntryJson !== void 0).map((candidate) => createCanonicalRepairRemoval(candidate, {
archiveRemovedTranscript: !relatedSessionIds.has(candidate.entry.sessionId),
deleteOwnedWindows: false
}));
}
function listCanonicalDestinationAliasKeys(destinationStore, winner) {
return destinationStore.map((candidate) => candidate.sessionKey).filter((sessionKey) => sessionKey !== winner.canonicalKey);
}
function applyCanonicalDestinationArtifacts(params) {
replaceSessionOwnerInTransaction(params.database, params.winner.canonicalKey, params.winner.entry.owner);
const destinationAliasKeys = listCanonicalDestinationAliasKeys(params.destinationStore, params.winner);
if (destinationAliasKeys.length > 0) {
if (params.rehomeDeliveries) rehomeSqliteSessionDeliveryReferencesForCanonicalRepair(params.database, params.winner.canonicalKey, destinationAliasKeys);
copySessionNodeArtifactsForRepair(params.database, params.database, destinationAliasKeys, params.winner.canonicalKey, { includeMembers: false });
}
if (!params.copyWinnerAlias || params.winner.sessionKey === params.winner.canonicalKey) return;
deleteSessionMembersForRepair(params.database, params.winner.canonicalKey);
copySessionNodeArtifactsForRepair(params.database, params.database, [params.winner.sessionKey], params.winner.canonicalKey, { includeParticipants: false });
}
async function repairCanonicalSessionGroupsInSingleDatabase(groups) {
const first = groups[0];
if (!first) return [];
await ensureSqliteTranscriptGenerationsForCanonicalRepair(groups.flatMap((group) => group.candidates));
const destination = first.selected.destination;
return (await applySessionEntryLifecycleMutation({
agentId: destination.agentId,
allowCanonicalRepair: true,
afterUpsertsInTransaction: (database) => {
rehomeSqliteSessionDeliveryReferencesForCanonicalRepairBatch(database, groups.map((group) => ({
canonicalKey: group.selected.winner.canonicalKey,
previousKeys: listCanonicalDestinationAliasKeys(group.candidates, group.selected.winner)
})));
for (const group of groups) applyCanonicalDestinationArtifacts({
copyWinnerAlias: true,
database,
destinationStore: group.candidates,
rehomeDeliveries: false,
winner: group.selected.winner
});
},
removals: groups.flatMap((group) => createCanonicalDestinationRemovals(group.candidates, group.selected)),
skipMaintenance: true,
storePath: destination.storePath,
upserts: groups.map((group) => ({
entry: group.selected.entry,
sessionKey: group.selected.winner.canonicalKey
}))
})).archivedTranscriptDirectories;
}
async function repairCanonicalSessionGroup(candidates, params) {
const selected = selectCanonicalSessionCandidate(candidates, params);
if (!selected) return [];
await ensureSqliteTranscriptGenerationsForCanonicalRepair(candidates);
const winner = selected.winner;
const destination = selected.destination;
const byDatabase = /* @__PURE__ */ new Map();
for (const candidate of candidates) {
const group = byDatabase.get(candidate.sqlitePath) ?? [];
group.push(candidate);
byDatabase.set(candidate.sqlitePath, group);
}
const destinationStore = byDatabase.get(destination.sqlitePath) ?? [];
const preArchivedDirectories = [];
if (winner.sqlitePath !== destination.sqlitePath) {
const generationIds = /* @__PURE__ */ new Set([...listSqliteSessionGenerationIdsForCanonicalRepair({
agentId: winner.agentId,
canonicalKey: winner.canonicalKey,
sourceKeys: [winner.sessionKey],
storePath: winner.storePath
}), ...collectSessionStateIdsForEntry(winner.entry)]);
for (const sessionId of generationIds) {
if (!sessionId) continue;
const destinationCollision = destinationStore.find((candidate) => candidate.entry.sessionId === sessionId);
const [destinationEvents, sourceEvents] = await Promise.all([loadTranscriptEvents({
agentId: destinationCollision?.agentId ?? destination.agentId,
sessionId,
sessionKey: destinationCollision?.sessionKey ?? winner.canonicalKey,
storePath: destinationCollision?.storePath ?? destination.storePath
}), loadTranscriptEvents({
agentId: winner.agentId,
sessionId,
sessionKey: winner.sessionKey,
storePath: winner.storePath
})]);
const destinationContent = serializeJsonlLines(destinationEvents.map((event) => JSON.stringify(event)));
const sourceContent = serializeJsonlLines(sourceEvents.map((event) => JSON.stringify(event)));
if (!destinationContent || destinationContent === sourceContent) continue;
const archiveDirectory = resolveSqliteTranscriptArchiveDirectory({
agentId: destination.agentId,
env: params.env,
path: destination.sqlitePath
});
writeTranscriptArchive({
archiveDirectory,
content: destinationContent,
reason: "deleted",
sessionId
});
if (!preArchivedDirectories.includes(archiveDirectory)) preArchivedDirectories.push(archiveDirectory);
}
}
setCanonicalSqliteSessionMainKey(openOpenClawAgentDatabase({
agentId: destination.agentId,
path: destination.sqlitePath
}), params.cfg.session?.mainKey);
const winnerResult = await applySessionEntryLifecycleMutation({
agentId: destination.agentId,
allowCanonicalRepair: true,
afterUpsertsInTransaction: (destinationDatabase) => {
applyCanonicalDestinationArtifacts({
copyWinnerAlias: winner.sqlitePath === destination.sqlitePath,
database: destinationDatabase,
destinationStore,
rehomeDeliveries: true,
winner
});
if (winner.sqlitePath !== destination.sqlitePath) copySqliteSessionOwnedStateForCanonicalRepair({
canonicalKey: winner.canonicalKey,
destinationDatabase,
preferredEntry: selected.entry,
preferredSessionKey: winner.sessionKey,
source: winner,
sourceEntries: [winner.entry],
sourceKeys: [winner.sessionKey]
});
},
removals: createCanonicalDestinationRemovals(destinationStore, selected),
skipMaintenance: true,
storePath: destination.storePath,
upserts: [{
entry: selected.entry,
sessionKey: winner.canonicalKey
}]
});
const archivedDirectories = /* @__PURE__ */ new Set([...preArchivedDirectories, ...winnerResult.archivedTranscriptDirectories]);
for (const [sqlitePath, storeCandidates] of byDatabase) {
if (sqlitePath === destination.sqlitePath) continue;
const [storeCandidate] = storeCandidates;
if (!storeCandidate) continue;
const result = await applySessionEntryLifecycleMutation({
agentId: storeCandidate.agentId,
allowCanonicalRepair: true,
removals: storeCandidates.map((candidate) => createCanonicalRepairRemoval(candidate, {
archiveRemovedTranscript: true,
deleteOwnedWindows: true,
deliveryCleanupKeys: [winner.canonicalKey]
})),
skipMaintenance: true,
storePath: storeCandidate.storePath
});
for (const directory of result.archivedTranscriptDirectories) archivedDirectories.add(directory);
}
return [...archivedDirectories];
}
/** Doctor-owned durable repair; process-held incognito databases are intentionally excluded. */
async function repairCanonicalSessionKeys(params) {
const env = params.env ?? process.env;
const stores = listCanonicalSessionStores({
cfg: params.cfg,
env
});
const archivedTranscriptDirectories = /* @__PURE__ */ new Set();
let repairBatches = 0;
let repairedGroups = 0;
if (params.apply) for (const store of stores) setCanonicalSqliteSessionMainKey(openOpenClawAgentDatabase({
agentId: store.agentId,
path: store.sqlitePath
}), params.cfg.session?.mainKey);
let repairGroups = collectCanonicalSessionRepairGroups({
cfg: params.cfg,
env
}, stores);
const foundGroups = repairGroups.length;
const removedRows = repairGroups.reduce((total, group) => total + group.removedRows, 0);
if (params.apply) while (repairGroups.length > 0) {
if (!repairGroups[0]) break;
const candidateGroups = repairGroups.slice(0, CANONICAL_SESSION_REPAIR_BATCH_GROUP_LIMIT);
const hydrated = hydrateCanonicalSessionCandidates(candidateGroups.flatMap((candidateGroup) => candidateGroup.candidates));
let hydratedOffset = 0;
const hydratedGroups = candidateGroups.map((candidateGroup) => {
const candidates = hydrated.slice(hydratedOffset, hydratedOffset + candidateGroup.candidates.length);
hydratedOffset += candidateGroup.candidates.length;
return candidates;
});
const candidates = hydratedGroups[0];
const singleDatabaseGroup = resolveSingleDatabaseCanonicalRepairGroup(candidates, {
cfg: params.cfg,
env
});
if (!singleDatabaseGroup) {
for (const directory of await repairCanonicalSessionGroup(candidates, {
cfg: params.cfg,
env
})) archivedTranscriptDirectories.add(directory);
repairBatches += 1;
repairedGroups += 1;
repairGroups = collectCanonicalSessionRepairGroups({
cfg: params.cfg,
env
}, stores);
continue;
}
const batch = [singleDatabaseGroup];
for (const nextCandidates of hydratedGroups.slice(1)) {
const nextSingleDatabaseGroup = resolveSingleDatabaseCanonicalRepairGroup(nextCandidates, {
cfg: params.cfg,
env
});
if (!nextSingleDatabaseGroup || nextSingleDatabaseGroup.selected.destination.sqlitePath !== singleDatabaseGroup.selected.destination.sqlitePath) break;
batch.push(nextSingleDatabaseGroup);
}
for (const directory of await repairCanonicalSessionGroupsInSingleDatabase(batch)) archivedTranscriptDirectories.add(directory);
repairBatches += 1;
repairedGroups += batch.length;
repairGroups = collectCanonicalSessionRepairGroups({
cfg: params.cfg,
env
}, stores);
}
return {
archivedTranscriptDirectories: [...archivedTranscriptDirectories].toSorted(),
foundGroups,
repairBatches,
removedRows,
repairedGroups,
scannedStores: stores.length
};
}
//#endregion
//#region src/commands/doctor-session-delivery-state.ts
/** Scan or rewrite legacy delivery fields inside existing session row JSON. */
function repairCanonicalSessionDeliveryStates(params) {
return repairCanonicalSessionEntries({
...params,
transform: normalizeLegacySessionEntryDelivery,
updateDeliveryProjection: true
});
}
/** Removes runtime-only skill catalogs from previously persisted session rows. */
function repairCanonicalSessionResolvedSkills(params) {
return repairCanonicalSessionEntries({
...params,
transform: stripRuntimeOnlySessionSkillsFields,
updateDeliveryProjection: false
});
}
function repairCanonicalSessionEntries(params) {
const targets = listExistingAgentDatabaseTargets(params.cfg, params.env);
let found = 0;
let repaired = 0;
for (const target of targets) {
const sessionKeys = [];
const operation = runDoctorAgentDatabaseOperation({
agentId: target.agentId,
path: target.sqlitePath,
run: () => {
scanDoctorSessionEntriesTolerant({
agentId: target.agentId,
env: params.env,
storePath: target.storePath
}, ({ entry, recoveredFromProjections, sessionKey }) => {
if (!recoveredFromProjections && params.transform(entry, sessionKey, "scan") !== entry) sessionKeys.push(sessionKey);
});
return sessionKeys.length;
}
});
if (!operation.ok) continue;
found += operation.value;
if (!params.apply || operation.value === 0) continue;
const wasOpen = isOpenClawAgentDatabaseOpen(target.sqlitePath);
try {
repaired += rewriteDoctorSessionEntries({
scope: {
agentId: target.agentId,
env: params.env,
storePath: target.storePath
},
sessionKeys,
transform: (entry, sessionKey) => params.transform(entry, sessionKey, "repair"),
updateDeliveryProjection: params.updateDeliveryProjection
});
} finally {
if (!wasOpen) closeOpenClawAgentDatabaseByPath(target.sqlitePath);
}
}
return {
found,
repaired,
scannedStores: targets.length
};
}
//#endregion
//#region src/commands/doctor-session-exec-policy.ts
/** Retires session exec overrides without granting the full-mode approval-floor bypass. */
function repairLegacySessionExecPolicy(params) {
const messages = [];
repairCanonicalSessionEntries({
...params,
updateDeliveryProjection: false,
transform(entry, sessionKey, phase) {
const { execSecurity, execAsk, ...next } = entry;
if (execSecurity === void 0 && execAsk === void 0) return entry;
let ask = normalizeExecAsk(execAsk);
if (!next.permissionMode) {
const agentId = resolveSessionAgentId({
sessionKey,
config: params.cfg
});
const globalExec = params.cfg.tools?.exec;
const agentExec = resolveAgentConfig(params.cfg, agentId)?.tools?.exec;
const { effectiveHost } = resolveExecTarget({
configuredTarget: normalizeExecTarget(entry.execHost) ?? agentExec?.host ?? globalExec?.host,
elevatedRequested: false,
sandboxRequired: entry.sandbox === "required",
sandboxAvailable: true
});
const base = applyExecPolicyLayer(applyExecPolicyLayer({
security: effectiveHost === "sandbox" ? "deny" : "full",
ask: "off"
}, globalExec), agentExec);
ask ??= base.ask;
const mode = resolveExecModeFromPolicy({
security: normalizeExecSecurity(execSecurity) ?? base.security,
ask
});
next.permissionMode = ask === "always" ? "read-only" : mode === "full" ? void 0 : SESSION_PERMISSION_BY_EXEC_MODE[mode];
}
if (phase === (params.apply ? "repair" : "scan")) {
const outcome = next.permissionMode ? `${entry.permissionMode ? "kept" : "set"} permissionMode=${next.permissionMode}${ask === "always" && !entry.permissionMode ? " (ask=always has no mode equivalent; choose guarded or workspace to allow commands again)" : ""}` : "config default applies; full permission mode was not granted";
messages.push(`- ${sessionKey}: ${params.apply ? "removed" : "would remove"} legacy exec policy; ${outcome}.`);
}
return next;
}
});
if (messages.length > 0) {
if (!params.apply) messages.push("- Run \"openclaw doctor --fix\" to migrate legacy session exec policy.");
note(messages.join("\n"), "Session exec policy");
}
}
//#endregion
//#region src/commands/doctor-session-incognito-key-repair-state.ts
const REPAIR_JOURNAL_SCOPE = "doctor-session-key-migration";
const REPAIR_JOURNAL_KEY = "reserved-incognito-v1";
function listTuiLastSessionStateRows(database) {
const db = getNodeSqliteKysely(database);
return executeSqliteQuerySync(database, db.selectFrom("config_machine_state").select(["state_key", "value_json"]).where("state_key", "like", "tui.lastSession.%")).rows;
}
function sqliteSchemaIdentifier(value) {
return sql.id(value);
}
function listSharedStateSessionKeyColumns(database) {
const db = getNodeSqliteKysely(database);
return executeSqliteQuerySync(database, db.selectFrom("sqlite_schema").select("name").where("type", "=", "table").where("name", "not like", "sqlite_%")).rows.flatMap(({ name: table }) => {
return executeSqliteQuerySync(database, db.selectFrom(sql`pragma_table_info(${table})`.as("pragma_columns")).select(sql`name`.as("name"))).rows.flatMap(({ name: column }) => {
if (column === "session_key" || column.endsWith("_session_key")) return [{
table,
column,
json: false
}];
return column.endsWith("_session_keys_json") ? [{
table,
column,
json: true
}] : [];
});
});
}
function collectSharedStateSessionKeys(database) {
const db = getNodeSqliteKysely(database);
const keys = /* @__PURE__ */ new Set();
for (const { table, column, json } of listSharedStateSessionKeyColumns(database)) {
const columnId = sqliteSchemaIdentifier(column);
const rows = executeSqliteQuerySync(database, db.selectFrom(sql`${sqliteSchemaIdentifier(table)}`.as("session_key_table")).select(columnId.as("value")).where(columnId, "is not", null)).rows;
for (const { value } of rows) if (!json && typeof value === "string") keys.add(value);
else if (json && typeof value === "string") try {
collectJsonStringValues(JSON.parse(value), keys);
} catch {}
}
for (const row of listTuiLastSessionStateRows(database)) {
const sessionKey = JSON.parse(row.value_json);
if (typeof sessionKey === "string") keys.add(sessionKey);
}
return keys;
}
function rewriteSharedStateSessionKeys(database, renames) {
const db = getNodeSqliteKysely(database);
const rowId = sql`rowid`;
for (const { table, column, json } of listSharedStateSessionKeyColumns(database)) {
const tableId = sqliteSchemaIdentifier(table);
const columnId = sqliteSchemaIdentifier(column);
if (!json) {
for (const [from, to] of renames) executeSqliteQuerySync(database, db.updateTable(sql`${tableId}`.as("session_key_table")).set(columnId, to).where(columnId, "=", from));
continue;
}
const rows = executeSqliteQuerySync(database, db.selectFrom(sql`${tableId}`.as("session_key_table")).select([rowId.as("rowid"), columnId.as("value")])).rows;
for (const row of rows) {
if (typeof row.value !== "string") continue;
let parsed;
try {
parsed = JSON.parse(row.value);
} catch {
continue;
}
const rewritten = JSON.stringify(replaceSessionKeyReferences(parsed, renames));
if (rewritten !== row.value) executeSqliteQuerySync(database, db.updateTable(sql`${tableId}`.as("session_key_table")).set(columnId, rewritten).where(rowId, "=", row.rowid));
}
}
const stateDb = getNodeSqliteKysely(database);
for (const row of listTuiLastSessionStateRows(database)) {
const sessionKey = JSON.parse(row.value_json);
const renamedKey = typeof sessionKey === "string" ? renames.get(sessionKey) : void 0;
if (renamedKey) executeSqliteQuerySync(database, stateDb.updateTable("config_machine_state").set({ value_json: JSON.stringify(renamedKey) }).where("state_key", "=", row.state_key));
}
}
function collectJsonStringValues(value, values) {
if (typeof value === "string") {
values.add(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) collectJsonStringValues(item, values);
return;
}
if (!value || typeof value !== "object") return;
for (const item of Object.values(value)) collectJsonStringValues(item, values);
}
function readRepairJournal(database) {
const db = getNodeSqliteKysely(database);
const row = executeSqliteQueryTakeFirstSync(database, db.selectFrom("state_leases").select("payload_json").where("scope", "=", REPAIR_JOURNAL_SCOPE).where("lease_key", "=", REPAIR_JOURNAL_KEY));
if (!row?.payload_json) return [];
const parsed = JSON.parse(row.payload_json);
if (parsed.version !== 1 || !Array.isArray(parsed.renames)) throw new Error("Invalid reserved incognito session key repair journal");
return parsed.renames.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item) || typeof item.from !== "string" || typeof item.to !== "string") throw new Error("Invalid reserved incognito session key repair journal entry");
return {
from: item.from,
to: item.to
};
});
}
function readRepairJournalReadOnly(env) {
return withExistingOpenClawStateDatabaseReadOnly((database) => readRepairJournal(database.db), { env }) ?? [];
}
function writeRepairJournal(database, renames) {
const now = Date.now();
const db = getNodeSqliteKysely(database);
executeSqliteQuerySync(database, db.insertInto("state_leases").values({
scope: REPAIR_JOURNAL_SCOPE,
lease_key: REPAIR_JOURNAL_KEY,
owner: "openclaw-doctor",
expires_at: null,
heartbeat_at: null,
payload_json: JSON.stringify({
version: 1,
renames
}),
created_at: now,
updated_at: now
}).onConflict((conflict) => conflict.columns(["scope", "lease_key"]).doUpdateSet({
owner: "openclaw-doctor",
payload_json: JSON.stringify({
version: 1,
renames
}),
updated_at: now
})));
}
function deleteRepairJournal(database) {
const db = getNodeSqliteKysely(database);
executeSqliteQuerySync(database, db.deleteFrom("state_leases").where("scope", "=", REPAIR_JOURNAL_SCOPE).where("lease_key", "=", REPAIR_JOURNAL_KEY));
}
function replaceSessionKeyReferences(value, renames) {
if (typeof value === "string") return renames.get(value) ?? value;
if (Array.isArray(value)) return value.map((item) => replaceSessionKeyReferences(item, renames));
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, replaceSessionKeyReferences(item, renames)]));
}
//#endregion
//#region src/commands/doctor-session-incognito-key-repair.ts
function repairReservedIncognitoSessionKeys(params) {
const targets = listExistingAgentDatabaseTargets(params.cfg, params.env).map((target) => ({
target,
databaseOptions: resolveTargetSqliteOptions(target, params.env)
}));
const reservedKeys = /* @__PURE__ */ new Set();
const sharedDatabase = params.apply ? openOpenClawStateDatabase({ env: params.env }) : void 0;
const journalRenames = sharedDatabase ? readRepairJournal(sharedDatabase.db) : readRepairJournalReadOnly(params.env);
for (const { target, databaseOptions } of targets) {
const operation = runDoctorAgentDatabaseOperation({
agentId: target.agentId,
path: target.sqlitePath,
run: () => withOpenClawAgentDatabaseReadOnly((database) => listReservedIncognitoKeys(database.db), databaseOptions)
});
if (!operation.ok || !operation.value.found) continue;
for (const key of operation.value.value) reservedKeys.add(key);
}
const pendingKeys = new Set(reservedKeys);
for (const rename of journalRenames) pendingKeys.add(rename.from);
if (!params.apply) return {
found: pendingKeys.size,
repaired: 0
};
if (reservedKeys.size === 0 && journalRenames.length === 0) return {
found: 0,
repaired: 0
};
const occupiedKeys = sharedDatabase ? collectSharedStateSessionKeys(sharedDatabase.db) : /* @__PURE__ */ new Set();
for (const { target, databaseOptions } of targets) {
const operation = runDoctorAgentDatabaseOperation({
agentId: target.agentId,
path: target.sqlitePath,
run: () => withOpenClawAgentDatabaseReadOnly((database) => collectOccupiedSessionKeys(database.db), databaseOptions)
});
if (operation.ok && operation.value.found) for (const key of operation.value.value) occupiedKeys.add(key);
}
for (const rename of journalRenames) occupiedKeys.add(rename.to);
const journalSources = new Set(journalRenames.map((rename) => rename.from));
const newRenames = planReservedIncognitoKeyRenames([...reservedKeys].filter((key) => !journalSources.has(key)).toSorted(), occupiedKeys);
const renames = [...journalRenames, ...newRenames];
const renameMap = new Map(renames.map((item) => [item.from, item.to]));
runOpenClawStateWriteTransaction((database) => writeRepairJournal(database.db, renames), { env: params.env }, { operationLabel: "doctor.journal-reserved-incognito-session-keys" });
runOpenClawStateWriteTransaction((database) => rewriteSharedStateSessionKeys(database.db, renameMap), { env: params.env }, { operationLabel: "doctor.rename-reserved-incognito-shared-state-keys" });
for (const { target, databaseOptions } of targets) {
const wasOpen = isOpenClawAgentDatabaseOpen(target.sqlitePath);
try {
runOpenClawAgentWriteTransaction((database) => applyReservedIncognitoKeyRenameColumns(database, renames), databaseOptions, { operationLabel: "doctor.rename-reserved-incognito-session-keys" });
rewriteDoctorSessionEntries({
scope: {
agentId: target.agentId,
env: params.env,
storePath: target.storePath
},
sessionKeys: listSessionEntryKeysReadOnly({
agentId: target.agentId,
env: params.env,
storePath: target.storePath
}),
transform: (entry) => rewriteSessionEntryKeyFields(entry, renameMap)
});
} finally {
if (!wasOpen) closeOpenClawAgentDatabaseByPath(target.sqlitePath);
}
}
runOpenClawStateWriteTransaction((database) => deleteRepairJournal(database.db), { env: params.env }, { operationLabel: "doctor.complete-reserved-incognito-session-keys" });
return {
found: pendingKeys.size,
repaired: renames.length
};
}
function planReservedIncognitoKeyRenames(keys, occupied) {
return keys.map((key) => {
const base = legacyIncognitoSessionKey(key);
if (parseAgentSessionKey(key)?.rest.startsWith("internal-session-effects:") && occupied.has(base)) throw new Error(`Cannot repair internal session key because ${base} already exists`);
let candidate = base;
let suffix = 1;
while (occupied.has(candidate)) {
candidate = `${base}-${suffix}`;
suffix += 1;
}
occupied.add(candidate);
return {
from: key,
to: candidate
};
});
}
function applyReservedIncognitoKeyRenameColumns(database, renames) {
if (renames.length === 0) return;
database.db.exec("PRAGMA defer_foreign_keys = ON;");
for (const rename of renames) updateSessionKeyColumns(database.db, rename);
publishSessionEntryCacheInvalidation(database);
}
function legacyIncognitoSessionKey(sessionKey) {
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed || !isIncognitoSessionKey(sessionKey)) throw new Error(`Cannot rename non-incognito session key: ${sessionKey}`);
return `agent:${parsed.agentId}:${parsed.rest.replace(":incognito-", ":legacy-incognito-")}`;
}
function listReservedIncognitoKeys(database) {
const db = getNodeSqliteKysely(database);
const keys = /* @__PURE__ */ new Set();
for (const row of executeSqliteQuerySync(database, db.selectFrom("session_nodes").select("session_key")).rows) keys.add(row.session_key);
for (const row of executeSqliteQuerySync(database, db.selectFrom("session_windows").select("session_key")).rows) keys.add(row.session_key);
return [...keys].filter(isIncognitoSessionKey).toSorted();
}
function collectOccupiedSessionKeys(database) {
const db = getNodeSqliteKysely(database);
const keys = /* @__PURE__ */ new Set();
const collect = (values) => {
for (const value of values) if (value) keys.add(value);
};
collect(executeSqliteQuerySync(database, db.selectFrom("session_windows").select([
"session_key",
"parent_session_key",
"spawned_by"
])).rows.flatMap((row) => [
row.session_key,
row.parent_session_key,
row.spawned_by
]));
collect(executeSqliteQuerySync(database, db.selectFrom("session_nodes").select([
"session_key",
"parent_session_key",
"spawned_by",
"fork_source_session_key"
])).rows.flatMap((row) => [
row.session_key,
row.parent_session_key,
row.spawned_by,
row.fork_source_session_key
]));
collect(executeSqliteQuerySync(database, db.selectFrom("conversation_deliveries").select("source_session_key")).rows.map((row) => row.source_session_key));
for (const row of iterateSqliteQuerySync(database, db.selectFrom("session_nodes").select("entry_json"))) try {
collectSessionEntryKeyFields(JSON.parse(row.entry_json), keys);
} catch {}
collect(executeSqliteQuerySync(database, db.selectFrom("board_tabs").select("session_key")).rows.map((row) => row.session_key));
collect(executeSqliteQuerySync(database, db.selectFrom("board_widgets").select("session_key")).rows.map((row) => row.session_key));
collect(executeSqliteQuerySync(database, db.selectFrom("heartbeat_outcomes").select(["session_key", "run_session_key"])).rows.flatMap((row) => [row.session_key, row.run_session_key]));
return keys;
}
function updateSessionKeyColumns(database, rename) {
const db = getNodeSqliteKysely(database);
const update = (query) => executeSqliteQuerySync(database, query);
update(db.updateTable("session_windows").set({ session_key: rename.to }).where("session_key", "=", rename.from));
update(db.updateTable("session_windows").set({ parent_session_key: rename.to }).where("parent_session_key", "=", rename.from));
update(db.updateTable("session_windows").set({ spawned_by: rename.to }).where("spawned_by", "=", rename.from));
update(db.updateTable("session_nodes").set({ session_key: rename.to }).where("session_key", "=", rename.from));
update(db.updateTable("session_nodes").set({ parent_session_key: rename.to }).where("parent_session_key", "=", rename.from));
update(db.updateTable("session_nodes").set({ spawned_by: rename.to }).where("spawned_by", "=", rename.from));
update(db.updateTable("session_nodes").set({ fork_source_session_key: rename.to }).where("fork_source_session_key", "=", rename.from));
update(db.updateTable("conversation_deliveries").set({ source_session_key: rename.to }).where("source_session_key", "=", rename.from));
update(db.updateTable("session_members").set({ session_key: rename.to }).where("session_key", "=", rename.from));
update(db.updateTable("board_tabs").set({ session_key: rename.to }).where("session_key", "=", rename.from));
update(db.updateTable("board_widgets").set({ session_key: rename.to }).where("session_key", "=", rename.from));
update(db.updateTable("heartbeat_outcomes").set({ session_key: rename.to }).where("session_key", "=", rename.from));
update(db.updateTable("heartbeat_outcomes").set({ run_session_key: rename.to }).where("run_session_key", "=", rename.from));
}
function rewriteSessionEntryKeyFields(value, renames) {
visitSessionEntryKeyFields(value, (record, key) => {
const current = record[key];
if (typeof current === "string") record[key] = renames.get(current) ?? current;
});
return value;
}
function collectSessionEntryKeyFields(value, keys) {
visitSessionEntryKeyFields(value, (record, key) => {
const current = record[key];
if (typeof current === "string") keys.add(current);
});
}
function visitSessionEntryKeyFields(value, visit) {
if (!value || typeof value !== "object" || Array.isArray(value)) return;
const entry = value;
for (const key of [
"heartbeatIsolatedBaseSessionKey",
"spawnedBy",
"completionOwnerSessionKey",
"parentSessionKey"
]) visit(entry, key);
if (entry.forkSource && typeof entry.forkSource === "object" && !Array.isArray(entry.forkSource)) {
const forkSource = entry.forkSource;
visit(forkSource, "sessionKey");
}
if (Array.isArray(entry.compactionCheckpoints)) for (const checkpoint of entry.compactionCheckpoints) {
if (!checkpoint || typeof checkpoint !== "object" || Array.isArray(checkpoint)) continue;
visit(checkpoint, "sessionKey");
}
if (entry.systemPromptReport && typeof entry.systemPromptReport === "object" && !Array.isArray(entry.systemPromptReport)) {
const report = entry.systemPromptReport;
visit(report, "sessionKey");
}
}
//#endregion
//#region src/commands/doctor-session-transcripts.ts
const SESSION_TRANSCRIPTS_CHECK_ID = "core/doctor/session-transcripts";
function parseTranscriptEntries(raw) {
const entries = [];
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) entries.push(parsed);
} catch {
return [];
}
}
return entries;
}
/** Repairs one transcript file by keeping the active branch and backing up the original file. */
async function repairBrokenSessionTranscriptFile(params) {
const result = {
filePath: params.filePath,
broken: false,
repaired: false,
originalEntries: 0,
activeEntries: 0,
legacyOpenAICodexEntries: 0
};
try {
if (!params.shouldRepair && (await fs.stat(params.filePath)).size > 1048576) {
result.deferred = true;
result.reason = "Detailed branch/provider classification deferred to offline staged import.";
return result;
}
const entries = parseTranscriptEntries(await fs.readFile(params.filePath, "utf-8"));
result.originalEntries = entries.length;
result.legacyOpenAICodexEntries = normalizeLegacyOpenAICodexTranscriptMetadata(entries);
const activePath = selectActivePath(entries);
result.activeEntries = activePath?.entries.length ?? 0;
const brokenBranch = activePath ? hasBrokenPromptRewriteBranch(entries, activePath.entries) : false;
result.broken = brokenBranch || result.legacyOpenAICodexEntries > 0;
if (!activePath) result.reason = "no active branch";
if (!result.broken || !params.shouldRepair) return result;
const content = `${(brokenBranch && activePath ? selectActiveTranscriptEntries({
entries,
activePath
}) : entries).map((entry) => JSON.stringify(entry)).join("\n")}\n`;
const repairKind = brokenBranch ? "branch" : "openai-codex";
const backupPath = `${params.filePath}.pre-doctor-${repairKind}-repair-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
await fs.copyFile(params.filePath, backupPath);
result.backupPath = backupPath;
const dirMode = (await fs.stat(path.dirname(params.filePath))).mode & 4095;
await replaceFileAtomic({
filePath: params.filePath,
content,
dirMode,
preserveExistingMode: true,
copyFallbackOnPermissionError: false,
syncTempFile: true,
syncParentDir: true
});
result.repaired = true;
} catch (err) {
result.reason = String(err);
}
return result;
}
async function listSessionTranscriptFiles(sessionDirs) {
const files = [];
for (const sessionsDir of sessionDirs) {
let entries;
try {
entries = await fs.readdir(sessionsDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path.join(sessionsDir, entry.name));
}
return files.toSorted((a, b) => a.localeCompare(b));
}
async function detectSessionTranscriptHealthIssues(params) {
let sessionDirs = params?.sessionDirs;
try {
sessionDirs ??= await resolveAgentSessionDirs(resolveStateDir(process.env));
} catch {
return [];
}
const files = await listSessionTranscriptFiles(sessionDirs);
const issues = [];
for (const filePath of files) {
const result = await repairBrokenSessionTranscriptFile({
filePath,
shouldRepair: false
});
if (result.broken || result.deferred) issues.push(result);
}
return issues;
}
function sessionTranscriptIssueToHealthFinding(issue) {
const metadata = issue.legacyOpenAICodexEntries > 0 ? ` ${issue.legacyOpenAICodexEntries} legacy OpenAI Codex metadata entr${issue.legacyOpenAICodexEntries === 1 ? "y" : "ies"}` : "";
return {
checkId: SESSION_TRANSCRIPTS_CHECK_ID,
severity: "info",
message: issue.deferred ? issue.reason : `Session transcript has legacy branch or provider metadata that can be cleaned up.${metadata}`,
path: issue.filePath,
fixHint: "To clean up the advisory artifact, run `openclaw doctor --fix` to rewrite affected transcripts to their active branch."
};
}
function sessionTranscriptIssueToRepairEffect(issue) {
return {
kind: "file",
action: "would-rewrite-session-transcript",
target: issue.filePath,
dryRunSafe: false
};
}
/** Scans session transcript files and reports or repairs legacy/broken transcript state. */
async function noteSessionTranscriptHealth(params) {
if (params?.sessionDirs === void 0 || params.sessionSqlite === true) {
await noteSessionSqliteMigrationHealth({
cfg: params?.cfg,
env: params?.env ?? process.env,
shouldRepair: params?.shouldRepair === true
});
return;
}
const shouldRepair = params?.shouldRepair === true;
let sessionDirs = params?.sessionDirs;
try {
sessionDirs ??= await resolveAgentSessionDirs(resolveStateDir(process.env));
} catch (err) {
note(`- Failed to inspect session transcripts: ${String(err)}`, "Session transcripts");
return;
}
const results = [];
const files = await listSessionTranscriptFiles(sessionDirs);
if (files.length > 0 && shouldRepair) for (const filePath of files) results.push(await repairBrokenSessionTranscriptFile({
filePath,
shouldRepair
}));
else if (files.length > 0) results.push(...await detectSessionTranscriptHealthIssues({ sessionDirs }));
const broken = results.filter((result) => result.broken);
if (broken.length > 0) {
const repairedCount = broken.filter((result) => result.repaired).length;
const lines = [`- Found ${broken.length} transcript file${broken.length === 1 ? "" : "s"} with legacy state.`, ...broken.slice(0, 20).map((result) => {
const backup = result.backupPath ? ` backup=${shortenHomePath(result.backupPath)}` : "";
const status = result.repaired ? "repaired" : shouldRepair ? "repair failed" : "needs repair";
const error = shouldRepair && !result.repaired && result.reason ? ` error=${result.reason}` : "";
const metadata = result.legacyOpenAICodexEntries > 0 ? ` openai-codex=${result.legacyOpenAICodexEntries}` : "";
return `- ${shortenHomePath(result.filePath)} ${status} entries=${result.originalEntries}->${result.activeEntries + 1}${metadata}${backup}${error}`;
})];
if (broken.length > 20) lines.push(`- ...and ${broken.length - 20} more.`);
if (!shouldRepair) lines.push("- Run \"openclaw doctor --fix\" to rewrite affected files to their active branch.");
else if (repairedCount > 0) lines.push(`- Repaired ${repairedCount} transcript file${repairedCount === 1 ? "" : "s"}.`);
note(lines.join("\n"), "Session transcripts");
}
}
async function noteSessionSqliteMigrationHealth(params) {
const { runDoctorSessionSqlite } = await import("./doctor-session-sqlite-DM2cPm22.js");
let reservedKeyReport = {
found: 0,
repaired: 0
};
let deliveryReport = {
found: 0,
repaired: 0,
scannedStores: 0
};
let resolvedSkillsReport = {
found: 0,
repaired: 0,
scannedStores: 0
};
let canonicalKeyReport = {
archivedTranscriptDirectories: [],
foundGroups: 0,
repairBatches: 0,
removedRows: 0,
repairedGroups: 0,
scannedStores: 0
};
let legacyMainSessionResult;
const runSessionSqlite = async (maintenanceAuthority) => {
const report = await runDoctorSessionSqlite({
allAgents: true,
...params.cfg ? { cfg: params.cfg } : {},
env: params.env,
mode: params.shouldRepair ? "import" : "dry-run"
});
const { migrateLegacyMainSessionKeys } = await import("./legacy-main-session-migration-L2j4sPD2.js");
legacyMainSessionResult = await migrateLegacyMainSessionKeys({
cfg: params.cfg ?? {},
env: params.env,
mode: params.shouldRepair ? "doctor-fix" : "detect"
});
const repairParams = {
apply: params.shouldRepair,
cfg: params.cfg ?? {},
env: params.env
};
canonicalKeyReport = await repairCanonicalSessionKeys(repairParams);
resolvedSkillsReport = repairCanonicalSessionResolvedSkills(repairParams);
reservedKeyReport = repairReservedIncognitoSessionKeys(repairParams);
deliveryReport = repairCanonicalSessionDeliveryStates(repairParams);
repairLegacySessionExecPolicy(repairParams);
const pluginRepair = await runPostSessionPluginDoctorStateRepairs({
config: params.cfg ?? {},
env: params.env,
maintenanceAuthority
});
const pluginMessages = [...pluginRepair.changes, ...pluginRepair.warnings];
if (pluginMessages.length > 0) note(pluginMessages.join("\n"), "Plugin session repair");
return report;
};
let report;
try {
report = params.shouldRepair ? await withDoctorSqliteMaintenanceLock({
env: params.env,
operation: "session SQLite import",
run: runSessionSqlite
}) : await runSessionSqlite();
} catch (error) {
if (!(error instanceof DoctorSqliteMaintenanceLockUnavailableError)) throw error;
note(`- Skipped: Gateway or another SQLite maintenance command owns the state directory. Stop the Gateway, then run "${formatCliCommand("openclaw doctor --fix", params.env)}" for session-store maintenance.`, "Session SQLite");
return;
}
if (reservedKeyReport.found > 0) note(params.shouldRepair ? `- Renamed ${reservedKeyReport.repaired} durable session key(s) that collided with the reserved incognito namespace.` : `- Found ${reservedKeyReport.found} durable session key(s) that collide with the reserved incognito namespace. Run "openclaw doctor --fix" to rename them.`, "Session SQLite");
if (canonicalKeyReport.foundGroups > 0) note(params.shouldRepair ? `- Canonicalized ${canonicalKeyReport.repairedGroups} session-key group(s) in ${canonicalKeyReport.repairBatches} transaction batch(es), removed ${canonicalKeyReport.removedRows} duplicate or alias row(s), and preserved cross-store history in ${canonicalKeyReport.archivedTranscriptDirectories.length} archive director${canonicalKeyReport.archivedTranscriptDirectories.length === 1 ? "y" : "ies"}.` : `- Found ${canonicalKeyReport.foundGroups} non-canonical or duplicate session-key group(s). Run "openclaw doctor --fix" to preserve their history and canonicalize the rows.`, "Session SQLite");
if (deliveryReport.found > 0) note(params.shouldRepair ? `- Canonicalized delivery state for ${deliveryReport.repaired} durable session row(s).` : `- Found ${deliveryReport.found} durable session row(s) with legacy delivery fields. Run "openclaw doctor --fix" to canonicalize them.`, "Session SQLite");
if (resolvedSkillsReport.found > 0) note(params.shouldRepair ? `- Stripped the runtime-only skills catalog from ${resolvedSkillsReport.repaired} durable session row(s). Logical SQLite pages are freed; shrinking the on-disk database requires "openclaw doctor --session-sqlite compact --session-sqlite-all-agents".` : `- Found ${resolvedSkillsReport.found} durable session row(s) carrying a runtime-only skills catalog. Run "openclaw doctor --fix" to strip it.`, "Session SQLite");
if (legacyMainSessionResult && (legacyMainSessionResult.changes.length > 0 || legacyMainSessionResult.warnings.length > 0)) note([...legacyMainSessionResult.changes.map((change) => `- ${change}`), ...legacyMainSessionResult.warnings.map((warning) => `- ${warning}`)].join("\n"), "Legacy main sessions");
if (report.totals.legacyEntries === 0 && report.totals.unreferencedJsonlFiles === 0 && report.totals.issues === 0) return;
const lines = [`- Legacy entries: ${report.totals.legacyEntries}; SQLite entries: ${report.totals.sqliteEntries}.`, `- Transcript events: imported=${report.totals.importedTranscriptEvents}; validated=${report.totals.validatedTranscriptEvents}.`];
if (report.totals.archivedTranscriptFiles > 0) lines.push(`- Archived ${report.totals.archivedTranscriptFiles} legacy transcript artifact(s).`);
if (report.totals.archivedUnreferencedJsonlFiles > 0) lines.push(`- Archived ${report.totals.archivedUnreferencedJsonlFiles} unreferenced JSONL artifact(s).`);
if (report.totals.issues > 0) lines.push(`- Found ${report.totals.issues} session SQLite issue(s). Inspect with "${formatCliCommand("openclaw doctor --session-sqlite dry-run --session-sqlite-all-agents", params.env)}".`);
if (!params.shouldRepair) lines.push("- Run \"openclaw doctor --fix\" to migrate legacy session metadata/transcripts to SQLite.");
if (params.shouldRepair && report.migrationRun && report.totals.archivedTranscriptFiles > 0) lines.push(`- After verifying the upgrade, preview rollback retirement with "${formatCliCommand("openclaw update cleanup --dry-run", params.env)}" for state ${resolveStateDir(params.env)}. Keep the same OPENCLAW_STATE_DIR and OPENCLAW_CONFIG_PATH overrides.`);
note(lines.join("\n"), "Session SQLite");
}
//#endregion
export { detectSessionTranscriptHealthIssues, noteSessionTranscriptHealth, sessionTranscriptIssueToHealthFinding, sessionTranscriptIssueToRepairEffect };