openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
427 lines (426 loc) • 18.7 kB
JavaScript
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { l as readFileDescriptorBoundedSync } from "./boundary-file-read-uaJcf6X6.js";
import { r as isPathInside } from "./path-guards-Cp-mGr3-.js";
import { w as resolveStateDir } from "./paths-D2sRr1a_.js";
import { dt as resolveSqliteDatabaseFilePaths } from "./openclaw-state-db-BRTnL-D8.js";
import { f as syncDirectorySync, s as requireDirectorySync } from "./directory-durability-CINgXRM4.js";
import { C as moveMigrationArtifact, E as statMigrationPath, _ as writeSessionSqliteMigrationManifest, a as filterRestoreManifestTargets, c as isRegularFileWithoutFollowingSymlinks, d as readSessionSqliteMigrationManifest, h as uniqueRestoreMoves, l as listSessionSqliteMigrationManifestPaths, n as assertSafeSessionSqliteMigrationMove, r as canonicalMigrationFilePath, s as hasSymbolicLinkInDirectoryPath, u as migrationMoveKey, w as readMigrationArtifactIdentity } from "./doctor-session-sqlite-migration-run-CJtP-RPT.js";
import { n as assertDoctorSqliteMaintenancePathsNotAliased } from "./doctor-sqlite-maintenance-lock-BDyGk9pJ.js";
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
//#region src/commands/doctor-session-sqlite-restore.ts
/** Restore planning across retained migration manifests. */
const RESTORE_ARCHIVE_HASH_CHUNK_BYTES = 65536;
async function restoreSessionSqliteMigrationRuns(params) {
const restoreReport = emptyRestoreReport();
const contexts = loadRestoreManifestContexts(listSessionSqliteMigrationManifestPaths(params.env).toReversed(), params.trustedTargets);
await reconcileRestorePublications(contexts, params.env);
const restorePlan = createRestorePlan(contexts);
for (const { manifest, manifestPath, targets } of contexts) {
const manifestRestoreReport = {
...emptyRestoreReport(),
manifestPaths: [manifestPath]
};
restoreReport.manifestPaths.push(manifestPath);
await restoreSessionSqliteMigrationManifest(manifest, manifestPath, targets, manifestRestoreReport, restorePlan);
restoreReport.conflicts.push(...manifestRestoreReport.conflicts);
restoreReport.restoredFiles.push(...manifestRestoreReport.restoredFiles);
restoreReport.skippedFiles.push(...manifestRestoreReport.skippedFiles);
writeSessionSqliteMigrationManifest({
manifest,
manifestPath
});
}
return restoreReport;
}
/** Undo only recorded, still-linked publication intermediates before a fresh import or restore. */
async function reconcileSessionSqliteMigrationPublications(params) {
await reconcileRestorePublications(loadRestoreManifestContexts(listSessionSqliteMigrationManifestPaths(params.env), params.trustedTargets), params.env, params.sourcePath);
}
async function reconcileRestorePublications(contexts, env, sourcePath) {
const stateDir = path.dirname(canonicalMigrationFilePath(path.join(resolveStateDir(env), "anchor")));
for (const context of contexts) for (const target of context.targets) for (const move of uniqueRestoreMoves(target)) {
if (sourcePath && canonicalMigrationFilePath(sourcePath) !== move.sourcePath || !move.artifact || move.artifact.disposal.state !== "retained") continue;
const source = statMigrationPath(move.sourcePath);
const archive = statMigrationPath(move.archivePath);
if (!source || !archive) continue;
if (!source.isFile() || !archive.isFile() || source.dev !== archive.dev || source.ino !== archive.ino) continue;
if (source.nlink !== 2 || archive.nlink !== 2) continue;
if (![
target.storePath,
target.sqlitePath,
move.archivePath
].every((file) => isPathInside(stateDir, file))) continue;
assertSafeSessionSqliteMigrationMove(move, target);
assertDoctorSqliteMaintenancePathsNotAliased("session recovery publication", [context.manifestPath, ...resolveSqliteDatabaseFilePaths(target.sqlitePath)], [stateDir]);
await moveMigrationArtifact(move.archivePath, move.sourcePath, move.artifact.identity, () => {
assertSafeSessionSqliteMigrationMove(move, target);
recordRestoredMigrationMove(context.manifest, context.manifestPath, move);
});
}
}
function recordRestoredMigrationMove(manifest, manifestPath, move) {
requireDirectorySync(syncDirectorySync(path.dirname(path.dirname(move.sourcePath))), "Restored session directory");
const consumed = collectRecordedConsumedArchives(manifest);
consumed.add(move.archivePath);
manifest.restore = {
attemptedAt: (/* @__PURE__ */ new Date()).toISOString(),
consumedArchives: [...consumed].toSorted(),
conflicts: [],
restoredFiles: [.../* @__PURE__ */ new Set([...manifest.restore?.restoredFiles ?? [], move.sourcePath])],
skippedFiles: [],
status: "restored"
};
writeSessionSqliteMigrationManifest({
manifest,
manifestPath
});
}
function loadRestoreManifestContexts(manifestPaths, trustedTargets) {
const contexts = [];
for (const manifestPath of manifestPaths) {
const stat = statMigrationPath(manifestPath);
const manifest = stat?.isFile() && stat.nlink === 1 && !hasSymbolicLinkInDirectoryPath(path.dirname(manifestPath)) ? readSessionSqliteMigrationManifest(manifestPath) : void 0;
if (!manifest) continue;
const targets = filterRestoreManifestTargets(manifest, trustedTargets);
if (targets.length > 0) contexts.push({
manifest,
manifestPath,
targets
});
}
return contexts;
}
/**
* Resolve every duplicate destination before moving an archive. A missing archive only disappears
* from the conflict set when its own manifest proves that an earlier restore consumed it.
*/
function createRestorePlan(contexts) {
const plan = /* @__PURE__ */ new Map();
const candidatesBySource = /* @__PURE__ */ new Map();
for (const context of contexts) {
const consumedArchives = collectRecordedConsumedArchives(context.manifest);
for (const target of context.targets) for (const move of uniqueRestoreMoves(target)) {
if (move.artifact && move.artifact.disposal.state !== "retained") {
plan.set(restoreMovePlanKey(context.manifestPath, move), {
action: "conflict",
reason: move.artifact.disposal.state === "disposed" ? "rollback original was intentionally disposed by update cleanup" : "rollback original has pending cleanup; finish cleanup before restore"
});
continue;
}
const candidates = candidatesBySource.get(move.sourcePath) ?? [];
candidates.push({
consumed: consumedArchives.has(move.archivePath),
context,
move
});
candidatesBySource.set(move.sourcePath, candidates);
}
}
for (const [sourcePath, candidates] of candidatesBySource) {
if (fs.existsSync(sourcePath) || candidates.length === 1) {
for (const candidate of candidates) plan.set(restoreMovePlanKey(candidate.context.manifestPath, candidate.move), { action: "standard" });
continue;
}
const available = [];
let blocked = false;
for (const candidate of candidates) {
const key = restoreMovePlanKey(candidate.context.manifestPath, candidate.move);
const inspection = inspectRestoreArchive(candidate.move);
if (inspection.state === "available") {
available.push({
...candidate,
snapshot: inspection.snapshot
});
continue;
}
if (inspection.state === "missing" && candidate.consumed) {
plan.set(key, { action: "skip-consumed" });
continue;
}
blocked = true;
plan.set(key, {
action: "conflict",
reason: inspection.state === "missing" ? "archive is missing without a recorded prior restore; refusing another candidate" : inspection.reason
});
}
if (blocked) {
for (const candidate of available) plan.set(restoreMovePlanKey(candidate.context.manifestPath, candidate.move), {
action: "conflict",
reason: "another archive for this source is unavailable without prior restore evidence; refusing automatic selection"
});
continue;
}
if (available.length === 0) continue;
if (new Set(available.map((candidate) => candidate.move.kind)).size !== 1) {
setRestoreCandidateConflicts(plan, available, "recorded archives disagree on artifact kind; refusing automatic selection");
continue;
}
const winner = selectRestoreCandidate(available);
if (!winner) {
setRestoreCandidateConflicts(plan, available, available[0]?.move.kind === "legacy-store" ? "multiple distinct nonempty session indexes require explicit archive selection" : "multiple distinct archives require explicit archive selection");
continue;
}
const winnerKey = restoreMovePlanKey(winner.context.manifestPath, winner.move);
for (const candidate of available) {
const candidateKey = restoreMovePlanKey(candidate.context.manifestPath, candidate.move);
plan.set(candidateKey, candidateKey === winnerKey ? {
action: "restore",
snapshot: candidate.snapshot
} : { action: "skip-superseded" });
}
}
return plan;
}
function selectRestoreCandidate(candidates) {
if (new Set(candidates.map((candidate) => candidate.snapshot.digest)).size === 1) return candidates[0];
if (candidates[0]?.move.kind !== "legacy-store") return;
const nonemptyDigests = new Set(candidates.filter((candidate) => (candidate.snapshot.legacyEntryCount ?? 0) > 0).map((candidate) => candidate.snapshot.digest));
if (nonemptyDigests.size === 0) return candidates[0];
return nonemptyDigests.size === 1 ? candidates.find((candidate) => (candidate.snapshot.legacyEntryCount ?? 0) > 0) : void 0;
}
function setRestoreCandidateConflicts(plan, candidates, reason) {
for (const candidate of candidates) plan.set(restoreMovePlanKey(candidate.context.manifestPath, candidate.move), {
action: "conflict",
reason
});
}
function restoreMovePlanKey(manifestPath, move) {
return `${manifestPath}\u0000${migrationMoveKey(move)}`;
}
function collectRecordedConsumedArchives(manifest) {
const consumed = new Set(manifest.restore?.consumedArchives ?? []);
const restoredSources = new Set(manifest.restore?.restoredFiles ?? []);
if (restoredSources.size === 0) return consumed;
const movesBySource = /* @__PURE__ */ new Map();
for (const target of manifest.targets) for (const move of uniqueRestoreMoves(target)) {
const moves = movesBySource.get(move.sourcePath) ?? [];
moves.push(move);
movesBySource.set(move.sourcePath, moves);
}
for (const sourcePath of restoredSources) {
const moves = movesBySource.get(sourcePath);
const move = moves?.length === 1 ? moves[0] : void 0;
if (move) consumed.add(move.archivePath);
}
return consumed;
}
function hashRestoreArchive(fd, size) {
const hash = createHash("sha256");
const buffer = Buffer.allocUnsafe(RESTORE_ARCHIVE_HASH_CHUNK_BYTES);
let offset = 0;
while (offset < size) {
const read = fs.readSync(fd, buffer, 0, Math.min(buffer.length, size - offset), offset);
if (read === 0) throw new Error("archive changed while it was inspected");
hash.update(buffer.subarray(0, read));
offset += read;
}
return hash.digest("hex");
}
function inspectRestoreArchive(move) {
if (hasSymbolicLinkInDirectoryPath(path.dirname(move.archivePath))) return {
state: "invalid",
reason: "archive parent is a symbolic link; refusing restore"
};
let pathStat;
try {
pathStat = fs.lstatSync(move.archivePath);
} catch (error) {
const code = isRecord(error) ? error.code : void 0;
return code === "ENOENT" || code === "ENOTDIR" ? { state: "missing" } : {
state: "invalid",
reason: "archive could not be inspected; refusing restore"
};
}
if (!pathStat.isFile()) return {
state: "invalid",
reason: "archive is not a regular file; refusing restore"
};
let fd;
try {
const flags = process.platform === "win32" ? "r" : fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
fd = fs.openSync(move.archivePath, flags);
const descriptorStat = fs.fstatSync(fd);
if (!descriptorStat.isFile() || descriptorStat.dev !== pathStat.dev || descriptorStat.ino !== pathStat.ino) return {
state: "invalid",
reason: "archive changed while it was inspected; refusing restore"
};
let digest;
let legacyEntryCount;
if (move.kind === "legacy-store") {
const content = readFileDescriptorBoundedSync(fd, descriptorStat.size);
digest = createHash("sha256").update(content).digest("hex");
let parsed;
try {
parsed = JSON.parse(content.toString("utf-8"));
} catch {
return {
state: "invalid",
reason: "session index archive is not valid JSON; refusing automatic selection"
};
}
if (!isRecord(parsed)) return {
state: "invalid",
reason: "session index archive is not a JSON object; refusing automatic selection"
};
legacyEntryCount = Object.keys(parsed).length;
} else digest = hashRestoreArchive(fd, descriptorStat.size);
const finalPathStat = fs.lstatSync(move.archivePath);
if (finalPathStat.dev !== descriptorStat.dev || finalPathStat.ino !== descriptorStat.ino || finalPathStat.size !== descriptorStat.size) return {
state: "invalid",
reason: "archive changed while it was inspected; refusing restore"
};
return {
state: "available",
snapshot: {
digest,
...legacyEntryCount === void 0 ? {} : { legacyEntryCount },
size: descriptorStat.size
}
};
} catch (error) {
const code = isRecord(error) ? error.code : void 0;
return code === "ENOENT" || code === "ENOTDIR" ? { state: "missing" } : {
state: "invalid",
reason: "archive could not be read safely; refusing restore"
};
} finally {
if (fd !== void 0) fs.closeSync(fd);
}
}
async function restoreSessionSqliteMigrationRun(params) {
const restoreReport = {
...emptyRestoreReport(),
manifestPaths: [params.manifestPath]
};
const manifest = readSessionSqliteMigrationManifest(params.manifestPath);
if (!manifest) {
restoreReport.conflicts.push({
archivePath: params.manifestPath,
reason: "manifest is missing or unreadable",
sourcePath: params.manifestPath
});
return restoreReport;
}
const targetManifests = filterRestoreManifestTargets(manifest, params.trustedTargets);
if (targetManifests.length === 0) {
restoreReport.conflicts.push({
archivePath: params.manifestPath,
reason: "manifest does not match a trusted session target",
sourcePath: params.manifestPath
});
return restoreReport;
}
await reconcileRestorePublications([{
manifest,
manifestPath: params.manifestPath,
targets: targetManifests
}], params.env ?? process.env);
await restoreSessionSqliteMigrationManifest(manifest, params.manifestPath, targetManifests, restoreReport, createRestorePlan([{
manifest,
manifestPath: params.manifestPath,
targets: targetManifests
}]));
writeSessionSqliteMigrationManifest({
manifest,
manifestPath: params.manifestPath
});
return restoreReport;
}
function emptyRestoreReport() {
return {
conflicts: [],
manifestPaths: [],
restoredFiles: [],
skippedFiles: []
};
}
async function restoreSessionSqliteMigrationManifest(manifest, manifestPath, targets, restoreReport, restorePlan) {
for (const target of targets) for (const move of uniqueRestoreMoves(target)) await restoreMigrationMove({
manifest,
manifestPath,
target,
move,
restorePlan,
restoreReport
});
const consumedArchives = collectRecordedConsumedArchives(manifest);
manifest.restore = {
attemptedAt: (/* @__PURE__ */ new Date()).toISOString(),
...consumedArchives.size > 0 ? { consumedArchives: [...consumedArchives].toSorted() } : {},
conflicts: restoreReport.conflicts,
restoredFiles: restoreReport.restoredFiles,
skippedFiles: restoreReport.skippedFiles,
status: resolveRestoreStatus(restoreReport)
};
}
async function restoreMigrationMove(params) {
const { manifest, manifestPath, target, move, restorePlan, restoreReport } = params;
const recordConflict = (reason) => {
restoreReport.conflicts.push({
archivePath: move.archivePath,
reason,
sourcePath: move.sourcePath
});
};
const planned = restorePlan.get(restoreMovePlanKey(manifestPath, move)) ?? { action: "standard" };
if (planned.action === "conflict") {
recordConflict(planned.reason);
return;
}
if (planned.action === "skip-consumed" || planned.action === "skip-superseded") {
restoreReport.skippedFiles.push(move.sourcePath);
return;
}
const sourceExists = statMigrationPath(move.sourcePath) !== void 0;
const archiveExists = statMigrationPath(move.archivePath) !== void 0;
if (sourceExists || !archiveExists) {
if (sourceExists && !archiveExists) restoreReport.skippedFiles.push(move.sourcePath);
else recordConflict(sourceExists ? "source and archive both exist; refusing to overwrite source" : "source and archive are both missing");
return;
}
try {
if (!isRegularFileWithoutFollowingSymlinks(move.archivePath)) throw new Error("archive is not a regular file; refusing restore");
assertRestoreDirectories(move);
fs.mkdirSync(path.dirname(move.sourcePath), {
recursive: true,
mode: 448
});
assertRestoreDirectories(move);
const identity = move.artifact?.identity ?? readMigrationArtifactIdentity(move.archivePath);
if (planned.action === "restore" && (identity.sha256 !== planned.snapshot.digest || identity.size !== planned.snapshot.size)) throw new Error("archive changed after restore planning; refusing restore");
if (!move.artifact) {
move.artifact = {
identity,
classification: "protected",
reason: "historical-restore-original",
dependencies: move.kind === "legacy-store" ? uniqueRestoreMoves(target).filter((item) => item.kind === "transcript").map((item) => item.sourcePath) : [],
disposal: { state: "retained" }
};
for (const recorded of [...target.plannedMoves, ...target.completedMoves]) if (migrationMoveKey(recorded) === migrationMoveKey(move)) recorded.artifact = move.artifact;
writeSessionSqliteMigrationManifest({
manifest,
manifestPath
});
}
await moveMigrationArtifact(move.archivePath, move.sourcePath, move.artifact.identity, () => {
assertRestoreDirectories(move);
recordRestoredMigrationMove(manifest, manifestPath, move);
});
restoreReport.restoredFiles.push(move.sourcePath);
} catch (error) {
recordConflict(error instanceof Error ? error.message : String(error));
}
}
function assertRestoreDirectories(move) {
if (hasSymbolicLinkInDirectoryPath(path.dirname(move.sourcePath)) || hasSymbolicLinkInDirectoryPath(path.dirname(move.archivePath))) throw new Error("source or archive parent is a symbolic link; refusing restore");
}
function resolveRestoreStatus(report) {
if (report.conflicts.length > 0 && report.restoredFiles.length > 0) return "partial";
if (report.conflicts.length > 0) return "conflicts";
if (report.restoredFiles.length > 0) return "restored";
return "noop";
}
//#endregion
export { restoreSessionSqliteMigrationRuns as i, reconcileSessionSqliteMigrationPublications as n, restoreSessionSqliteMigrationRun as r, collectRecordedConsumedArchives as t };