fix(doctor): recover a zero-byte retained transcript from its backup siblings (#162946)

A retained legacy session transcript truncated to 0 bytes (an earlier process left two .jsonl.bak-<pid>-<timestamp> backups beside it) put Doctor into an unresolvable retained_plugin_source_conflict loop: with the file present recovery reported the source incomplete and protected, with it moved recovery reported the source changed, and every Gateway start logged a degraded state. The retained-source verifier required a transcript header and ignored the backup siblings. Recovery now verifies those backups, imports missing history through the existing importer with identity and ownership checks, and reversibly archives the empty original; canonical edits and deletions remain authoritative, and unrecoverable sources stay protected with exact manual restoration instructions.

Closes #162817
This commit is contained in:
Peter Steinberger 2026-10-01 12:25:12 -07:00 • committed by GitHub
parent 4f575823aa
commit 57396d3cfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 526 additions and 127 deletions

View file

@ -173,6 +173,17 @@ core import completed. Doctor owns the repair and the Gateway keeps serving SQLi
Recovery reports include every remaining issue code and distinguish unresolved
findings from completed validation.
For a zero-byte retained transcript, recovery lists its `.jsonl.bak-<pid>-<timestamp>`
siblings and verifies the largest backup against the canonical session. Missing
suffix events use the existing historical importer; current session settings and
deleted sessions are not replayed. All backup candidates must be covered before
Doctor archives the empty original with a recoverable warning. The backup files
remain untouched. Without backups, an identified canonical session with transcript
rows can establish that the empty source is superseded. If neither source proves
the history, Doctor preserves the file and names the exact transcript and database
paths to restore from a verified backup before retrying recovery. Keep moved
transcripts and their backups together at the reported original paths.
When both a recorded legacy index and its archive are missing, Doctor verifies
the remaining transcripts against canonical SQLite before reporting that the
canonical transcripts are complete and the legacy index entries are informational.

View file

@ -57,15 +57,13 @@ import type {
} from "./doctor-session-sqlite-types.js";
/** Receipt recovery belongs to offline Doctor; canonical session data is never replayed. */
export function prepareRetainedSessionImport(
params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
target: SessionStoreTarget;
export async function prepareRetainedSessionImport(
params: Parameters<typeof archiveConflictingRetainedSessionSources>[0] & {
mode: DoctorSessionSqliteMode;
},
issues: DoctorSessionSqliteIssue[],
report: DoctorSessionSqliteTargetReport,
) {
const issues = report.issues;
const isSqliteStore = params.target.storePath.endsWith(".sqlite");
const sqlitePath = resolveTargetSqlitePath(params.target, params.env);
if (!isSqliteStore && (params.mode === "import" || params.mode === "recover")) {
@ -96,12 +94,14 @@ export function prepareRetainedSessionImport(
}
let retainedImport: DeferredPluginSessionImport | undefined;
const sourceConflicts = new Map<string, string>();
const emptySources = new Map<string, string>();
const sourceVerification = {
...prepareSessionSourceVerification({
...params,
sqlitePath,
}),
allowMissingIndex: true,
onEmptySource: (sourcePath: string, reason: string) => emptySources.set(sourcePath, reason),
onSourceConflict: (sourcePath: string, artifactPath = sourcePath, error?: unknown) => {
if (sourceConflicts.has(artifactPath)) {
return;
@ -116,7 +116,7 @@ export function prepareRetainedSessionImport(
code: fs.existsSync(params.target.storePath)
? "retained_plugin_source_conflict"
: "historical_transcript_deferred",
message: `${artifactPath}: ${reason} Canonical SQLite sessions remain authoritative. Run openclaw doctor --fix to preserve the conflicting input in the migration archive.`,
message: `${artifactPath}: ${reason} Canonical SQLite sessions remain authoritative. Preserve this input and its backups. Restore the verified original at ${sourcePath}, then run openclaw doctor --session-sqlite recover --session-sqlite-all-agents against the same state/config.`,
});
},
};
@ -124,7 +124,7 @@ export function prepareRetainedSessionImport(
try {
if (
(params.mode === "import" || params.mode === "recover") &&
rebuildDeferredPluginSessionSourceIndex(sourceVerification)
(await rebuildDeferredPluginSessionSourceIndex(sourceVerification))
) {
issues.push({
code: "retained_plugin_source_index_rebuilt",
@ -155,6 +155,14 @@ export function prepareRetainedSessionImport(
) {
appendRetainedIndexComparison(params, issues);
}
if (emptySources.size) {
await archiveConflictingRetainedSessionSources(
{ ...params, verifiedEmpty: true },
emptySources,
report,
);
sourceVerification.verification.clear();
}
return { retainedImport, sourceConflicts, sourceVerification, retainedIndexPath };
}
@ -229,6 +237,7 @@ export async function archiveConflictingRetainedSessionSources(
protectedPaths?: ReadonlySet<string>;
expectedIndexIdentity?: MigrationArtifactIdentity;
targets?: readonly SessionSqliteMigrationTargetInput[];
verifiedEmpty?: boolean;
},
sourceConflicts: Map<string, string>,
report: DoctorSessionSqliteTargetReport,
@ -286,6 +295,9 @@ export async function archiveConflictingRetainedSessionSources(
dependencies: [],
disposal: { state: "retained" },
};
if (params.verifiedEmpty && move.artifact.identity.size !== 0) {
throw new Error("Retained transcript is no longer empty; source needs verification");
}
for (const owner of targets) {
recordPlannedMigrationMoves(run, owner, [move]);
}
@ -318,7 +330,9 @@ export async function archiveConflictingRetainedSessionSources(
: report.archivedTranscriptFiles
).push(move.archivePath);
report.issues.push({
code: "retained_plugin_source_conflict",
code: params.verifiedEmpty
? "retained_empty_transcript_superseded"
: "retained_plugin_source_conflict",
message: `${source}: ${reason} Preserved at ${move.archivePath}; canonical SQLite sessions were not replayed.`,
});
} catch (error) {
@ -337,7 +351,7 @@ export async function archiveConflictingRetainedSessionSources(
/** Historical discovery yields; verify the receipt again before counting or authorizing archival. */
export function countRetainedSessionSources(
retained: NonNullable<ReturnType<typeof prepareRetainedSessionImport>>,
retained: NonNullable<Awaited<ReturnType<typeof prepareRetainedSessionImport>>>,
records: readonly LegacySessionRecord[],
report: DoctorSessionSqliteTargetReport,
): void {

View file

@ -1,19 +1,22 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
loadExactSessionEntry,
upsertSessionEntryCore,
} from "../config/sessions/session-accessor.sqlite-entry.js";
import { loadTranscriptEventsSync } from "../config/sessions/session-accessor.sqlite-read.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import { assertSessionStoreMigrationComplete } from "../config/sessions/startup-migration.js";
import { recordDeferredPluginMigrations } from "../infra/deferred-plugin-migrations.js";
import * as emptySourceRecovery from "../infra/deferred-plugin-session-empty.js";
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
import { readMigrationArtifactIdentity } from "../infra/session-sqlite-migration-artifact.js";
import * as migrationArtifacts from "../infra/session-sqlite-migration-artifact.js";
import { readSessionSqliteMigrationManifest } from "../infra/session-sqlite-migration-manifest.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { withExistingOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import * as transcriptArchives from "./doctor-session-sqlite-archive.js";
import { seedDeferredPluginSessionSource } from "./doctor-session-sqlite.deferred-plugin.test-support.js";
import { runDoctorSessionSqlite } from "./doctor-session-sqlite.js";
@ -29,11 +32,173 @@ function receipt(env: NodeJS.ProcessEnv) {
);
return JSON.parse(String(row?.report_json)) as {
databaseIdentity: string;
sources: Array<{ path: string; identity: ReturnType<typeof readMigrationArtifactIdentity> }>;
sources: Array<{
path: string;
identity: ReturnType<typeof migrationArtifacts.readMigrationArtifactIdentity>;
}>;
};
}
describe("retained session receipt recovery", () => {
it.each(["verification", "archive"] as const)(
"protects newer history when an empty source changes before %s",
async (phase) => {
await withOpenClawTestState({ label: "receipt-empty-source-change" }, async (state) => {
const { cfg, storePath, scope } = await seedDeferredPluginSessionSource(
state,
"default",
"brave",
);
const options = { cfg, env: state.env, allAgents: true };
await runDoctorSessionSqlite({ ...options, mode: "import" });
const source = path.join(path.dirname(storePath), "legacy-kept.jsonl");
const bytes = fs.readFileSync(source, "utf8");
const events = loadTranscriptEventsSync({ ...scope, sessionId: "legacy-kept" });
fs.writeFileSync(`${source}.bak-15767-200`, bytes);
fs.writeFileSync(source, "");
closeOpenClawAgentDatabasesForTest();
const db = openNodeSqliteDatabase(
resolveSqliteTargetFromSessionStorePath(storePath, scope).path,
);
db.prepare("DELETE FROM transcript_events WHERE session_id = ?").run("legacy-kept");
db.close();
const newer =
bytes +
JSON.stringify({
type: "message",
id: "newer-message",
parentId: "kept-message",
message: { role: "user", content: "Newer retained history" },
}) +
"\n";
let changed = false;
const recoverEmpty = emptySourceRecovery.recoverEmptyRetainedTranscript;
const planArchive = transcriptArchives.planSessionJsonlArchiveMove;
const spy =
phase === "verification"
? vi
.spyOn(emptySourceRecovery, "recoverEmptyRetainedTranscript")
.mockImplementation((params) => {
if (params.source.originalPath === source) {
fs.writeFileSync(source, newer);
changed = true;
}
return recoverEmpty(params);
})
: vi
.spyOn(transcriptArchives, "planSessionJsonlArchiveMove")
.mockImplementation((params) => {
const move = planArchive(params);
if (params.sourcePathRaw === source) {
fs.writeFileSync(source, newer);
changed = true;
}
return move;
});
try {
await runDoctorSessionSqlite({ ...options, mode: "recover" });
} finally {
spy.mockRestore();
}
expect(changed).toBe(true);
expect(loadTranscriptEventsSync({ ...scope, sessionId: "legacy-kept" })).toEqual(
phase === "verification" ? [] : events,
);
if (phase === "archive") {
expect(fs.existsSync(source)).toBe(true);
expect(fs.readFileSync(source, "utf8")).toBe(newer);
}
});
},
);
it.each([
{ backups: true, missingRows: false, indexed: false },
{ backups: true, missingRows: true, indexed: true },
{ backups: false, missingRows: false, indexed: true },
{ backups: false, missingRows: true, indexed: true },
])(
"recovers empty originals (backups: $backups, missing rows: $missingRows, indexed: $indexed)",
async ({ backups, missingRows, indexed }) => {
await withOpenClawTestState({ label: "receipt-empty-transcript" }, async (state) => {
const { cfg, storePath, scope } = await seedDeferredPluginSessionSource(
state,
"default",
"brave",
);
const source = path.join(
path.dirname(storePath),
indexed && backups ? "named-transcript.jsonl" : "legacy-kept.jsonl",
);
if (!indexed || backups) {
const index = JSON.parse(fs.readFileSync(storePath, "utf8"));
if (!indexed) {
delete index["agent:main:kept"];
} else {
index["agent:main:kept"].sessionFile = path.basename(source);
fs.renameSync(path.join(path.dirname(storePath), "legacy-kept.jsonl"), source);
}
fs.writeFileSync(storePath, JSON.stringify(index));
}
const options = { cfg, env: state.env, allAgents: true };
await runDoctorSessionSqlite({ ...options, mode: "import" });
const bytes = fs.readFileSync(source, "utf8");
const originalEvents = loadTranscriptEventsSync({ ...scope, sessionId: "legacy-kept" });
const backupPaths = [`${source}.bak-15767-100`, `${source}.bak-15767-200`];
if (backups) {
fs.writeFileSync(backupPaths[0]!, bytes.split("\n")[0]! + "\n");
fs.writeFileSync(backupPaths[1]!, bytes);
}
fs.writeFileSync(source, "");
closeOpenClawAgentDatabasesForTest();
const sqlitePath = resolveSqliteTargetFromSessionStorePath(storePath, scope).path;
fs.copyFileSync(sqlitePath, `${sqlitePath}.replacement`);
fs.renameSync(`${sqlitePath}.replacement`, sqlitePath);
if (missingRows) {
const db = openNodeSqliteDatabase(sqlitePath);
db.prepare("DELETE FROM transcript_events WHERE session_id = ?").run("legacy-kept");
db.close();
}
const before = receipt(state.env);
const recovered = await runDoctorSessionSqlite({ ...options, mode: "recover" });
const issues = recovered.targets.flatMap((target) => target.issues);
if (!backups && missingRows) {
expect(issues).toContainEqual(
expect.objectContaining({
code: "retained_plugin_source_conflict",
message: expect.stringContaining(
`Restore a complete verified transcript at ${source}`,
),
}),
);
expect(issues.map((issue) => issue.message).join("\n")).toContain(sqlitePath);
expect(fs.readFileSync(source, "utf8")).toBe("");
expect(receipt(state.env)).toEqual(before);
return;
}
expect(issues).not.toContainEqual(
expect.objectContaining({ code: "retained_plugin_source_conflict" }),
);
expect(issues).toContainEqual(
expect.objectContaining({ code: "retained_empty_transcript_superseded" }),
);
const archives = recovered.targets.flatMap((target) => target.archivedTranscriptFiles);
expect(archives).toHaveLength(1);
expect(fs.readFileSync(archives[0]!, "utf8")).toBe("");
expect(fs.existsSync(source)).toBe(false);
expect(loadTranscriptEventsSync({ ...scope, sessionId: "legacy-kept" })).toEqual(
originalEvents,
);
if (backups) {
expect(fs.readFileSync(backupPaths[1]!, "utf8")).toBe(bytes);
expect(issues.map((issue) => issue.message).join("\n")).toContain(backupPaths[1]);
}
const again = await runDoctorSessionSqlite({ ...options, mode: "recover" });
expect(again.targets.flatMap((target) => target.issues)).not.toContainEqual(
expect.objectContaining({ code: "retained_plugin_source_conflict" }),
);
});
},
);
it.each([
{ replacement: "index", failedManifest: true },
{ replacement: "database", failedManifest: true },
@ -81,7 +246,7 @@ describe("retained session receipt recovery", () => {
expect.objectContaining({ code: "retained_plugin_source_conflict" }),
);
const after = receipt(state.env);
const currentIndex = readMigrationArtifactIdentity(storePath);
const currentIndex = migrationArtifacts.readMigrationArtifactIdentity(storePath);
expect(after.sources.find((source) => source.path === storePath)?.identity).toEqual(
currentIndex,
);

View file

@ -668,7 +668,7 @@ async function inspectOrMigrateTarget(params: {
archivedLegacyStoreFiles: [],
issues,
});
const retained = prepareRetainedSessionImport(params, issues);
const retained = await prepareRetainedSessionImport(params, report);
if (!retained) {
return report;
}

View file

@ -0,0 +1,136 @@
import fs from "node:fs";
import path from "node:path";
import { extractGeneratedTranscriptSessionId } from "../config/sessions/generated-transcript-session-id.js";
import { importSqliteSessionRowsBatch } from "../config/sessions/session-accessor.sqlite-import.js";
import {
readMigrationArtifactIdentity,
sameMigrationArtifact,
} from "./session-sqlite-migration-artifact.js";
import {
createTranscriptEventReader,
readLegacyPrimaryTranscriptIdentity,
readOnlySqliteValidationSnapshot,
readTranscriptFingerprint,
} from "./session-sqlite-migration-readers.js";
import { verifyCanonicalSessionTranscriptSources } from "./session-sqlite-transcript-verification.js";
/** An empty retained original carries no history; its backups and canonical owner must prove it. */
export async function recoverEmptyRetainedTranscript(params: {
source: { path: string; originalPath: string };
target: { agentId: string; storePath: string; sqlitePath: string };
sessionIds: string[];
env: NodeJS.ProcessEnv;
assertCurrent: () => void;
}): Promise<string> {
const { source, target, env } = params;
const original = readMigrationArtifactIdentity(source.path);
const prefix = `${path.basename(source.originalPath)}.bak-`;
const candidates = fs
.readdirSync(path.dirname(source.originalPath))
.filter((name) => name.startsWith(prefix) && /^\d+-\d+$/.test(name.slice(prefix.length)))
.map((name) => path.join(path.dirname(source.originalPath), name));
const manual = `Preserve ${source.path} and the backup candidates (${candidates.join(", ") || `${source.originalPath}.bak-<pid>-<timestamp>: none found`}). Restore a complete verified transcript at ${source.originalPath}, then run openclaw doctor --session-sqlite recover --session-sqlite-all-agents against this same state directory. Canonical database: ${target.sqlitePath}.`;
try {
if (original.size !== 0) {
throw new Error("Retained transcript is no longer empty");
}
const snapshot = readOnlySqliteValidationSnapshot(target);
if (!snapshot.ok) {
throw snapshot.error;
}
const filename = path.basename(source.originalPath);
const sessionIds = params.sessionIds.length
? params.sessionIds
: [extractGeneratedTranscriptSessionId(filename) ?? filename.slice(0, -".jsonl".length)];
const backups = candidates
.map((candidate) => ({
path: candidate,
identity: readMigrationArtifactIdentity(candidate),
}))
.toSorted((a, b) => b.identity.size - a.identity.size || a.path.localeCompare(b.path));
for (const sessionId of sessionIds) {
const sessionKey = snapshot.snapshot.sessionKeysBySessionId.get(sessionId);
if (!sessionKey) {
throw new Error(
`No canonical session owner for ${sessionId}; deleted history was not replayed`,
);
}
if (!backups.length && !snapshot.snapshot.transcriptEventCountsBySessionId.get(sessionId)) {
throw new Error(`No backup or canonical transcript rows for ${sessionId}`);
}
for (const [index, backup] of backups.entries()) {
if (
params.sessionIds.length === 0 &&
readLegacyPrimaryTranscriptIdentity(backup.path, source.originalPath, undefined, true)
?.sessionId !== sessionId
) {
throw new Error(`Backup has no matching primary session identity: ${backup.path}`);
}
const sources = [{ path: backup.path, originalPath: source.originalPath, sessionId }];
const verify = (mode: "contained" | "appendable") =>
verifyCanonicalSessionTranscriptSources({ target, sources, env, mode });
const verified = verify(index === 0 ? "appendable" : "contained");
if (!verified || verified.events === 0) {
throw new Error(`Backup is not covered by canonical history: ${backup.path}`);
}
if (verified.missingEvents) {
const fingerprint = readTranscriptFingerprint(backup.path);
await importSqliteSessionRowsBatch([
{
agentId: target.agentId,
storePath: target.sqlitePath,
env,
sessionKey,
entry: { sessionId, updatedAt: 0 },
historicalOnly: true,
preserveExactStoredKey: true,
readTranscriptEvents: createTranscriptEventReader(
backup.path,
sessionId,
false,
fingerprint,
source.originalPath,
),
beforePersistentApply: () => {
params.assertCurrent();
const current = readOnlySqliteValidationSnapshot(target);
if (
!current.ok ||
current.snapshot.sessionKeysBySessionId.get(sessionId) !== sessionKey ||
!verify("appendable")
) {
throw new Error("Canonical session owner or transcript changed before recovery");
}
if (
!sameMigrationArtifact(readMigrationArtifactIdentity(source.path), original) ||
!sameMigrationArtifact(
readMigrationArtifactIdentity(backup.path),
backup.identity,
)
) {
throw new Error("Retained transcript or backup changed before recovery");
}
},
},
]);
if (!verify("contained")) {
throw new Error(`Recovered backup could not be verified: ${backup.path}`);
}
}
}
}
if (
!sameMigrationArtifact(readMigrationArtifactIdentity(source.path), original) ||
backups.some(
(backup) =>
!sameMigrationArtifact(readMigrationArtifactIdentity(backup.path), backup.identity),
)
) {
throw new Error("Retained transcript or backup changed during recovery");
}
params.assertCurrent();
return `Verified-empty retained transcript; superseded by canonical SQLite history. Backup candidates: ${candidates.join(", ") || "none"}.`;
} catch (error) {
throw new Error(`${String(error)}. ${manual}`, { cause: error });
}
}

View file

@ -24,7 +24,12 @@ import type { DB } from "../state/openclaw-state-db.generated.js";
import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js";
import { sha256Hex } from "./crypto-digest.js";
import type { DeferredPluginMigration } from "./deferred-plugin-migrations.js";
import { verifyDeferredSessionDatabase } from "./deferred-plugin-session-verification.js";
import {
databaseIdentity,
preservesRecordedIndexValue,
sameSourceContent,
verifyDeferredSessionDatabase,
} from "./deferred-plugin-session-verification.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js";
import {
MigrationArtifactSchema,
@ -154,16 +159,6 @@ function sourceKey(target: SessionImportTarget): string {
);
}
function databaseIdentity(sqlitePath: string): string {
const file = fs.lstatSync(sqlitePath, { bigint: true, throwIfNoEntry: false });
if (!file?.isFile()) {
throw new Error(
`The imported session database is missing or no longer a regular file: ${sqlitePath}. Run ${formatCliCommand("openclaw doctor --session-sqlite recover --session-sqlite-all-agents")} against the same state/config before retrying repair.`,
);
}
return `${file.dev}:${file.ino}`;
}
function collectArchivedSources(
target: SessionImportTarget,
env: NodeJS.ProcessEnv,
@ -254,29 +249,6 @@ export function resolveVerifiedSessionSource(
return resolved;
}
function sameSourceContent(left: MigrationArtifactIdentity, right: MigrationArtifactIdentity) {
return left.sha256 === right.sha256 && left.size === right.size;
}
/** Old receipts bind bytes, not row identities; only a proven original JSON value can rebind. */
function preservesRecordedIndexValue(bytes: Buffer, identity: MigrationArtifactIdentity): boolean {
const value: unknown = JSON.parse(bytes.toString("utf8"));
const pretty = JSON.stringify(value, null, 2);
const candidates = [
bytes.subarray(0, identity.size),
bytes.subarray(-identity.size),
...[JSON.stringify(value), pretty, pretty.replaceAll("\n", "\r\n")].flatMap((encoded) =>
["", "\n", "\r\n"].map((ending) => Buffer.from(encoded + ending)),
),
];
return candidates.some(
(original) =>
original.length === identity.size &&
sha256Hex(original) === identity.sha256 &&
isDeepStrictEqual(JSON.parse(original.toString("utf8")), value),
);
}
function assertVerifiedSessionSources(
params: SessionImportSource,
receipt: DeferredPluginSessionImport,
@ -311,7 +283,7 @@ function assertVerifiedSessionSources(
continue;
}
throw new Error(
`Retained session migration source changed: ${source.path}. Run openclaw doctor --fix to verify the current input or preserve it in the migration archive; canonical SQLite sessions were not replayed.`,
`Retained session migration source changed: ${source.path}. Preserve the current file and its ${source.path}.bak-<pid>-<timestamp> siblings, restore the verified original at ${source.path}, then run openclaw doctor --session-sqlite recover --session-sqlite-all-agents against the same state directory. Canonical SQLite sessions were not replayed.`,
);
}
verifiedPaths.set(source.path, verifiedPath);
@ -477,11 +449,12 @@ function parseSessionImportReceipt(
}
/** Rebuild derived evidence from proven original index values or verified canonical transcripts. */
export function rebuildDeferredPluginSessionSourceIndex(
export async function rebuildDeferredPluginSessionSourceIndex(
params: SessionImportSource & {
onSourceConflict?: (sourcePath: string, artifactPath?: string, error?: unknown) => void;
onEmptySource?: (sourcePath: string, reason: string) => void;
},
): boolean {
): Promise<boolean> {
const receipt = readSessionImportReceipt(params);
if (!receipt) {
return false;
@ -492,95 +465,133 @@ export function rebuildDeferredPluginSessionSourceIndex(
const index = recorded.sources.find((source) => source.path === path.resolve(target.storePath));
let verifiedIndex = index;
const archives = collectArchivedSources(target, params.env);
const verification: SessionSourceVerification = new Map();
const verifiedSourcePaths = new Set(recorded.sources.map((source) => source.path));
const missingIndex =
index && existingSessionSourcePaths(index.path, target, params.env, archives).length === 0;
const sources = recorded.sources
.filter((source) => !missingIndex || source !== index)
.map((source) => {
let failure: unknown;
const candidates = statMigrationPath(source.path)
? [source.path]
: (archives.get(source.path) ?? []).map((archive) => archive.path);
for (const candidate of candidates) {
if (!statMigrationPath(candidate)) {
continue;
}
try {
const identity = readMigrationArtifactIdentity(candidate);
if (sameSourceContent(identity, source.identity)) {
return { path: source.path, identity };
}
if (
candidate !== source.path ||
(source.path !== path.resolve(target.storePath) &&
!isPrimarySessionTranscriptFileName(path.basename(source.path)))
) {
const assertCurrent = () => {
if (
!isDeepStrictEqual(readSessionImportReceipt(params), receipt) ||
databaseIdentity(params.sqlitePath) !== currentDatabaseIdentity ||
(verifiedIndex &&
!missingIndex &&
!resolveVerifiedSessionSource(verifiedIndex, target, params.env)) ||
(missingIndex && existingSessionSourcePaths(index.path, target, params.env).length > 0)
) {
throw new Error(
"Retained session receipt, index, or database changed during recovery; sources remain protected.",
);
}
};
const sources: DeferredPluginSessionImport["sources"] = [];
for (const source of recorded.sources.filter((item) => !missingIndex || item !== index)) {
sources.push(
await (async () => {
let failure: unknown;
const candidates = statMigrationPath(source.path)
? [source.path]
: (archives.get(source.path) ?? []).map((archive) => archive.path);
for (const candidate of candidates) {
if (!statMigrationPath(candidate)) {
continue;
}
const isIndex = source.path === path.resolve(target.storePath);
const indexPath =
!isIndex &&
verifiedIndex &&
resolveVerifiedSessionSource(verifiedIndex, target, params.env, verification);
if (isIndex) {
const issues: Array<{ code: string; message: string }> = [];
const current = readLegacySessionStoreEntries(params.target, issues, {
sourcePath: candidate,
});
if (!current.bytes || !preservesRecordedIndexValue(current.bytes, source.identity)) {
throw new Error(
`Cannot prove the retained index preserves its original entries, metadata, and transcript links: ${candidate}. ${issues.map((issue) => issue.message).join("; ")}`,
);
try {
const identity = readMigrationArtifactIdentity(candidate);
if (
candidate !== source.path &&
sameSourceContent(identity, source.identity) &&
currentDatabaseIdentity === recorded.databaseIdentity
) {
return { path: source.path, identity };
}
} else {
if (!indexPath || !verifiedIndex) {
throw new Error(
"Changed transcript has no verified retained index to establish its session owner.",
);
const emptyTranscript =
identity.size === 0 && isPrimarySessionTranscriptFileName(path.basename(source.path));
if (!emptyTranscript && sameSourceContent(identity, source.identity)) {
return { path: source.path, identity };
}
verifyDeferredSessionDatabase({
...params,
sources: [
{ originalPath: verifiedIndex.path, path: indexPath },
{ originalPath: source.path, path: candidate },
],
requireCompleteTranscript: true,
verifiedSourcePaths,
});
}
if (
!sameMigrationArtifact(readMigrationArtifactIdentity(candidate), identity) ||
(indexPath &&
if (
(!emptyTranscript && candidate !== source.path) ||
(source.path !== path.resolve(target.storePath) &&
!isPrimarySessionTranscriptFileName(path.basename(source.path)))
) {
continue;
}
const isIndex = source.path === path.resolve(target.storePath);
const indexPath =
!isIndex &&
verifiedIndex &&
!sameSourceContent(readMigrationArtifactIdentity(indexPath), verifiedIndex.identity))
) {
continue;
resolveVerifiedSessionSource(verifiedIndex, target, params.env);
if (isIndex) {
const issues: Array<{ code: string; message: string }> = [];
const current = readLegacySessionStoreEntries(params.target, issues, {
sourcePath: candidate,
});
if (!current.bytes || !preservesRecordedIndexValue(current.bytes, source.identity)) {
throw new Error(
`Cannot prove the retained index preserves its original entries, metadata, and transcript links: ${candidate}. ${issues.map((issue) => issue.message).join("; ")}`,
);
}
} else {
if (!emptyTranscript && (!indexPath || !verifiedIndex)) {
throw new Error(
"Changed transcript has no verified retained index to establish its session owner.",
);
}
await verifyDeferredSessionDatabase({
...params,
sources: [
...(verifiedIndex && indexPath
? [{ originalPath: verifiedIndex.path, path: indexPath }]
: []),
{ originalPath: source.path, path: candidate },
],
requireCompleteTranscript: true,
verifiedSourcePaths,
assertCurrent,
});
}
if (
!sameMigrationArtifact(readMigrationArtifactIdentity(candidate), identity) ||
(indexPath &&
verifiedIndex &&
!sameSourceContent(
readMigrationArtifactIdentity(indexPath),
verifiedIndex.identity,
))
) {
continue;
}
if (isIndex) {
verifiedIndex = { path: source.path, identity };
}
return { path: source.path, identity };
} catch (error) {
if (
statMigrationPath(candidate)?.size === 0 &&
isPrimarySessionTranscriptFileName(path.basename(source.path))
) {
throw error;
}
// An unreadable or aliased source remains protected with its recorded identity.
failure = error;
}
if (isIndex) {
verifiedIndex = { path: source.path, identity };
}
return { path: source.path, identity };
} catch (error) {
// An unreadable or aliased source remains protected with its recorded identity.
failure = error;
}
}
if (failure !== undefined) {
params.onSourceConflict?.(source.path, undefined, failure);
}
// Unverified content retains its old receipt until Doctor protects the current artifact.
return source;
});
if (failure !== undefined) {
params.onSourceConflict?.(source.path, undefined, failure);
}
// Unverified content retains its old receipt until Doctor protects the current artifact.
return source;
})(),
);
}
if (currentDatabaseIdentity !== recorded.databaseIdentity) {
assertVerifiedSessionSources(params, { ...recorded, sources });
verifyDeferredSessionDatabase({
await verifyDeferredSessionDatabase({
...params,
sources: sources.map((source) => ({
originalPath: source.path,
path: resolveVerifiedSessionSource(source, target, params.env)!,
})),
assertCurrent,
});
}
const rebuilt = { ...recorded, databaseIdentity: currentDatabaseIdentity, sources };

View file

@ -1,4 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { formatCliCommand } from "../cli/command-format.js";
import { isPrimarySessionTranscriptFileName } from "../config/sessions/artifacts.js";
import {
isLegacySessionRecordOwnedByTarget,
@ -8,6 +11,12 @@ import {
type LegacySessionStoreTarget,
} from "../config/sessions/legacy-store-inspection.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { sha256Hex } from "./crypto-digest.js";
import { recoverEmptyRetainedTranscript } from "./deferred-plugin-session-empty.js";
import {
readMigrationArtifactIdentity,
type MigrationArtifactIdentity,
} from "./session-sqlite-migration-artifact.js";
import {
readLegacyPrimaryTranscriptIdentity,
readOnlySqliteDbStats,
@ -16,7 +25,7 @@ import {
import { verifyCanonicalSessionTranscriptSources } from "./session-sqlite-transcript-verification.js";
/** A replaced database cannot inherit completed-import authority from an old inode. */
export function verifyDeferredSessionDatabase(params: {
export async function verifyDeferredSessionDatabase(params: {
cfg: OpenClawConfig;
target: LegacySessionStoreTarget;
sqlitePath: string;
@ -24,7 +33,9 @@ export function verifyDeferredSessionDatabase(params: {
sources: Array<{ path: string; originalPath: string }>;
requireCompleteTranscript?: boolean;
verifiedSourcePaths?: ReadonlySet<string>;
}): void {
onEmptySource?: (sourcePath: string, reason: string) => void;
assertCurrent: () => void;
}): Promise<void> {
const target = { ...params.target, sqlitePath: params.sqlitePath };
const snapshot = readOnlySqliteValidationSnapshot(target);
const stats = readOnlySqliteDbStats(target);
@ -63,7 +74,7 @@ export function verifyDeferredSessionDatabase(params: {
snapshot.snapshot.sessionKeysBySessionId.get(record.entry.sessionId) !== record.sessionKey
) {
throw new Error(
`Retained session ${record.sessionKey} is not present in ${params.sqlitePath}; sources remain protected. Compare a verified database backup before retrying recovery; canonical edits and deletions were not replayed.`,
`Retained session ${record.sessionKey} is not present in ${params.sqlitePath}; sources remain protected. Preserve ${record.transcriptPath ?? target.storePath} and its backups. Restore the intended session from a verified backup to ${params.sqlitePath}, then run openclaw doctor --session-sqlite recover --session-sqlite-all-agents against the same state directory. Canonical edits and deletions were not replayed.`,
);
}
}
@ -73,6 +84,17 @@ export function verifyDeferredSessionDatabase(params: {
}
const sourcePath = resolved.get(source.originalPath);
const indexed = records.filter((candidate) => candidate.transcriptPath === source.originalPath);
if (sourcePath && readMigrationArtifactIdentity(sourcePath).size === 0) {
const reason = await recoverEmptyRetainedTranscript({
source,
target,
env: params.env,
sessionIds: indexed.map((record) => record.entry.sessionId),
assertCurrent: params.assertCurrent,
});
params.onEmptySource?.(source.path, reason);
continue;
}
if (params.requireCompleteTranscript && indexed.length === 0) {
throw new Error(`Changed retained transcript has no verified indexed owner: ${source.path}`);
}
@ -106,3 +128,42 @@ export function verifyDeferredSessionDatabase(params: {
}
}
}
/** Old receipts bind bytes, not row identities; only a proven original JSON value can rebind. */
export function preservesRecordedIndexValue(
bytes: Buffer,
identity: MigrationArtifactIdentity,
): boolean {
const value: unknown = JSON.parse(bytes.toString("utf8"));
const pretty = JSON.stringify(value, null, 2);
const candidates = [
bytes.subarray(0, identity.size),
bytes.subarray(-identity.size),
...[JSON.stringify(value), pretty, pretty.replaceAll("\n", "\r\n")].flatMap((encoded) =>
["", "\n", "\r\n"].map((ending) => Buffer.from(encoded + ending)),
),
];
return candidates.some(
(original) =>
original.length === identity.size &&
sha256Hex(original) === identity.sha256 &&
isDeepStrictEqual(JSON.parse(original.toString("utf8")), value),
);
}
export function databaseIdentity(sqlitePath: string): string {
const file = fs.lstatSync(sqlitePath, { bigint: true, throwIfNoEntry: false });
if (!file?.isFile()) {
throw new Error(
`The imported session database is missing or no longer a regular file: ${sqlitePath}. Run ${formatCliCommand("openclaw doctor --session-sqlite recover --session-sqlite-all-agents")} against the same state/config before retrying repair.`,
);
}
return `${file.dev}:${file.ino}`;
}
export function sameSourceContent(
left: MigrationArtifactIdentity,
right: MigrationArtifactIdentity,
) {
return left.sha256 === right.sha256 && left.size === right.size;
}

View file

@ -19,6 +19,7 @@ const SESSION_SQLITE_WARNING_ISSUE_CODES = new Set([
"legacy_index_informational",
"plugin_migration_source_retained",
"retained_plugin_source_index_rebuilt",
"retained_empty_transcript_superseded",
"retained_plugin_source_conflict",
"transcript_archive_failed",
"transcript_malformed",