mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
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:
parent
4f575823aa
commit
57396d3cfe
8 changed files with 526 additions and 127 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
136
src/infra/deferred-plugin-session-empty.ts
Normal file
136
src/infra/deferred-plugin-session-empty.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue