UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

427 lines (426 loc) 18.7 kB
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 };