UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

629 lines (628 loc) 31.3 kB
import { c as isRecord } from "./record-coerce-DItp3I4t.js"; import { a as writeRuntimeJson, r as defaultRuntime } from "./runtime-CF2WjnNZ.js"; import { d as readSourceConfigBestEffort } from "./io.runtime-B9iJRs3w.js"; import { l as readFileDescriptorBoundedSync } from "./boundary-file-read-uaJcf6X6.js"; import { r as isPathInside } from "./path-guards-Cp-mGr3-.js"; import { o as listAgentIds } from "./agent-scope-config-DcbEhP0R.js"; import { w as resolveStateDir } from "./paths-D2sRr1a_.js"; import { i as executeSqliteQueryTakeFirstSync, o as iterateSqliteQuerySync } from "./kysely-sync-COmh4HWh.js"; import { dt as resolveSqliteDatabaseFilePaths, m as assertOpenClawStateWriteAllowedAtPath } from "./openclaw-state-db-BRTnL-D8.js"; import { i as isMigrationArchiveArtifactName } from "./artifacts-DMDO9PHy.js"; import { o as resolveSessionStorePathCore } from "./paths-CXdaYWF_.js"; import "./io-bdCzpGWJ.js"; import { l as syncDirectory, s as requireDirectorySync } from "./directory-durability-CINgXRM4.js"; import { V as migrateLegacySessionCreator } from "./openclaw-agent-db-maintenance-wTIy-jt-.js"; import { C as inspectOpenClawAgentDatabaseOwner } from "./openclaw-agent-db-CWtDoRbC.js"; import { n as withOpenClawAgentDatabaseReadOnly } from "./openclaw-agent-db-readonly-CHjf8FxN.js"; import { i as resolveUnsuffixedSqliteTargetFromSessionStorePath } from "./session-sqlite-target-Dp-kgpcT.js"; import { i as getSessionKysely } from "./session-accessor.sqlite-scope-2KfMzb44.js"; import { n as normalizeLegacySessionEntryDelivery } from "./state-migrations.legacy-session-store-CQJX6yZy.js"; import { t as withSqliteSessionImportStage } from "./session-accessor.sqlite-import-stage-BRYc0bpF.js"; import { C as moveMigrationArtifact, E as statMigrationPath, S as isPendingMigrationArtifactClaim, T as sameMigrationArtifact, _ as writeSessionSqliteMigrationManifest, b as isSessionSqliteMigrationWarning, d as readSessionSqliteMigrationManifest, h as uniqueRestoreMoves, l as listSessionSqliteMigrationManifestPaths, m as resolveSessionSqliteMigrationRunsDir, r as canonicalMigrationFilePath, s as hasSymbolicLinkInDirectoryPath, w as readMigrationArtifactIdentity } from "./doctor-session-sqlite-migration-run-CJtP-RPT.js"; import { i as withDoctorSqliteMaintenanceLock, n as assertDoctorSqliteMaintenancePathsNotAliased } from "./doctor-sqlite-maintenance-lock-BDyGk9pJ.js"; import { t as collectRecordedConsumedArchives } from "./doctor-session-sqlite-restore-Bpz8ls7-.js"; import { c as readTranscriptFingerprint, l as resolveLegacyTranscriptPaths, n as createTranscriptEventReader } from "./doctor-session-sqlite-readers-DxvNNHSA.js"; import fs from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { confirm, isCancel } from "@clack/prompts"; //#region src/commands/doctor-session-sqlite-recovery-inventory.ts /** Read-only recovery inventory and dependency classification; never deletion authority. */ function resolveRecoveryArtifact(refs) { return refs.find((ref) => ref.move.artifact?.disposal.state === "pending-disposal")?.move.artifact ?? refs[0]?.move.artifact; } function collectRecoveryInventory(params) { const stateDir = canonicalMigrationFilePath(path.join(resolveStateDir(params.env), "anchor")); const root = path.dirname(stateDir); const stores = /* @__PURE__ */ new Set(); const archiveDirs = /* @__PURE__ */ new Set(); const agentIds = new Set(listAgentIds(params.cfg)); const agentsRoot = path.join(root, "agents"); if (statMigrationPath(agentsRoot)?.isDirectory() && !hasSymbolicLinkInDirectoryPath(agentsRoot)) { for (const item of fs.readdirSync(agentsRoot, { withFileTypes: true })) if (item.isDirectory()) { agentIds.add(item.name); stores.add(path.join(agentsRoot, item.name, "sessions", "sessions.json")); } } for (const agentId of agentIds) stores.add(canonicalMigrationFilePath(resolveSessionStorePathCore(params.cfg.session?.store, { agentId, env: params.env }))); stores.add(path.join(root, "sessions", "sessions.json")); for (const store of stores) if (isPathInside(root, store)) archiveDirs.add(path.join(path.dirname(path.dirname(store)), "session-sqlite-import-archive")); const references = /* @__PURE__ */ new Map(); const manifestPaths = []; const artifacts = []; const manifestsDir = resolveSessionSqliteMigrationRunsDir(params.env); if (hasSymbolicLinkInDirectoryPath(manifestsDir)) artifacts.push({ path: manifestsDir, runs: [], bytes: 0, outcome: "blocked", reason: "manifest-directory-alias" }); else { manifestPaths.push(...listSessionSqliteMigrationManifestPaths(params.env)); for (const manifestPath of manifestPaths) { const stat = statMigrationPath(manifestPath); const manifest = stat?.isFile() && stat.nlink === 1 ? readSessionSqliteMigrationManifest(manifestPath) : void 0; if (!manifest) { artifacts.push({ path: manifestPath, runs: [], bytes: stat?.size ?? 0, outcome: "blocked", reason: "unreadable-manifest" }); continue; } const run = { manifest, manifestPath }; const consumed = collectRecordedConsumedArchives(manifest); for (const target of manifest.targets) { const expected = resolveUnsuffixedSqliteTargetFromSessionStorePath(target.storePath); const trusted = stores.has(target.storePath) && isPathInside(root, target.sqlitePath) && isPathInside(root, target.storePath) && !hasSymbolicLinkInDirectoryPath(path.dirname(target.storePath)) && !hasSymbolicLinkInDirectoryPath(path.dirname(target.sqlitePath)) && (expected.agentId ? target.sqlitePath === expected.path && target.agentId === expected.agentId : path.dirname(target.sqlitePath) === path.dirname(expected.path)); for (const move of uniqueRestoreMoves(target)) { const refs = references.get(move.archivePath) ?? []; refs.push({ run, target, move, trusted, consumedByRestore: consumed.has(move.archivePath) }); references.set(move.archivePath, refs); } } } } for (const [archivePath, refs] of references) { const evidence = resolveRecoveryArtifact(refs); const stat = refs.every((ref) => ref.trusted) ? fs.lstatSync(archivePath, { bigint: true, throwIfNoEntry: false }) : void 0; const claim = refs.every((ref) => ref.trusted) && evidence?.disposal.state === "pending-disposal" ? fs.lstatSync(evidence.disposal.claimPath, { bigint: true, throwIfNoEntry: false }) : void 0; const current = stat ?? claim; const item = { path: archivePath, runs: [...new Set(refs.map((ref) => ref.run.manifest.runId))], bytes: current?.isFile() ? Number(current.size) : evidence?.identity.size ?? 0, outcome: "candidate", reason: "producer-verified-original", consequence: "Permanently loses rollback to this original, including pre-repair branches and metadata." }; if (refs.some((ref) => !ref.trusted)) { item.outcome = "protected"; item.reason = "unsupported-target-ownership"; } else if (refs.every((ref) => ref.move.artifact?.disposal.state === "disposed")) { item.outcome = stat ? "protected" : "disposed"; item.reason = stat ? "recreated-after-disposal" : "intentionally-disposed"; } else if (refs.some((ref) => ref.consumedByRestore)) { item.outcome = "protected"; item.reason = "archive-consumed-by-restore"; } else if (hasSymbolicLinkInDirectoryPath(path.dirname(archivePath)) || stat && (!stat.isFile() || stat.nlink !== 1n && !isPendingMigrationArtifactClaim(archivePath, evidence))) { item.outcome = "blocked"; item.reason = "artifact-alias-or-nonregular"; } else if (refs.some(({ run, target }) => !run.manifest.completedAt || target.validationBeforeArchive !== "passed" || target.issues.some((issue) => !isSessionSqliteMigrationWarning(issue)))) { item.outcome = "protected"; item.reason = "incomplete-recovery-operation"; } else if (refs.some(({ move }) => move.artifact?.classification === "protected")) { item.outcome = "protected"; item.reason = refs.find((ref) => ref.move.artifact?.classification === "protected").move.artifact.reason; } else if (refs.some(({ move }) => !move.artifact)) { item.outcome = "verification-required"; item.reason = "historical-manifest-without-import-proof"; } else if (current && evidence && [ "dev", "ino", "mtimeNs", "size" ].some((key) => String(current[key]) !== String(evidence.identity[key]))) { item.outcome = "blocked"; item.reason = "artifact-metadata-changed"; } else if (refs.some(({ move }) => !sameMigrationArtifact(move.artifact.identity, evidence.identity))) { item.outcome = "blocked"; item.reason = "conflicting-artifact-identities"; } else if (refs.some(({ move }) => { const receipt = move.artifact?.disposal; return receipt?.state === "pending-disposal" && evidence?.disposal.state === "pending-disposal" && receipt.claimPath !== evidence.disposal.claimPath; })) { item.outcome = "blocked"; item.reason = "conflicting-disposal-claims"; } else if (!stat && !refs.every(({ move }) => move.artifact?.disposal.state === "pending-disposal" || move.artifact?.disposal.state === "disposed")) { item.outcome = "blocked"; item.reason = "unexpectedly-missing-artifact"; } else if (refs.some(({ move }) => move.artifact?.disposal.state === "pending-disposal")) item.reason = "resume-pending-disposal"; artifacts.push(item); } for (const store of stores) { const directory = path.dirname(store); if (!isPathInside(root, directory) || !statMigrationPath(directory)?.isDirectory() || hasSymbolicLinkInDirectoryPath(directory)) continue; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { if (!isMigrationArchiveArtifactName(entry.name) && !entry.name.includes(".pre-doctor-")) continue; const filePath = path.join(directory, entry.name); artifacts.push({ path: filePath, runs: [], bytes: entry.isFile() ? fs.lstatSync(filePath).size : 0, outcome: "protected", reason: "unmanifested-recovery-original" }); } } for (const directory of archiveDirs) { if (!statMigrationPath(directory)?.isDirectory() || hasSymbolicLinkInDirectoryPath(directory)) continue; for (const item of fs.readdirSync(directory, { withFileTypes: true })) { const filePath = path.join(directory, item.name); if (references.has(filePath) || artifacts.some((artifact) => artifact.path === filePath)) continue; if ([...references.values()].some((refs) => refs.some(({ move }) => move.artifact?.disposal.state === "pending-disposal" && move.artifact.disposal.claimPath === filePath))) continue; artifacts.push({ path: filePath, runs: [], bytes: item.isFile() ? fs.lstatSync(filePath).size : 0, outcome: "protected", reason: "unmanifested-artifact" }); } } if (artifacts.some((item) => item.reason === "unreadable-manifest" || item.reason === "manifest-directory-alias")) { for (const item of artifacts) if (item.outcome === "candidate" || item.outcome === "verification-required") { item.outcome = "blocked"; item.reason = "unreadable-recovery-dependencies"; } } protectRecoveryDependencies(artifacts, references); return { references, manifestPaths, report: summarizeRecoveryCleanup(root, artifacts, "preview") }; } function protectRecoveryDependencies(artifacts, refs, adoptions) { const active = (ref) => (!ref.consumedByRestore || statMigrationPath(ref.move.archivePath) !== void 0) && ref.move.artifact?.disposal.state !== "disposed"; const bySource = /* @__PURE__ */ new Map(); const dependents = /* @__PURE__ */ new Map(); for (const [archive, references] of refs) for (const ref of references.filter(active)) { const paths = bySource.get(ref.move.sourcePath) ?? []; paths.push(archive); bySource.set(ref.move.sourcePath, paths); } const connect = (from, to) => { const paths = dependents.get(from) ?? /* @__PURE__ */ new Set(); paths.add(to); dependents.set(from, paths); }; for (const [archive, references] of refs) for (const ref of references.filter(active)) { const moves = uniqueRestoreMoves(ref.target); const dependencies = (ref.move.artifact ?? adoptions?.get(ref))?.dependencies ?? (ref.move.kind === "legacy-store" ? moves.filter((move) => move.kind === "transcript").map((move) => move.sourcePath) : []); for (const source of dependencies) for (const dependency of bySource.get(source) ?? []) { connect(archive, dependency); if (ref.move.kind === "legacy-store") connect(dependency, archive); } } const byPath = new Map(artifacts.map((item) => [item.path, item])); const retained = artifacts.filter((item) => item.outcome !== "candidate" && item.outcome !== "verification-required" && item.outcome !== "disposed" && item.outcome !== "removed"); for (const item of retained) for (const dependency of dependents.get(item.path) ?? []) { const candidate = byPath.get(dependency); if (candidate?.outcome !== "candidate" && candidate?.outcome !== "verification-required") continue; candidate.outcome = "protected"; candidate.reason = "retained-recovery-dependency"; retained.push(candidate); } } function summarizeRecoveryCleanup(stateDir, artifacts, status) { const totals = { candidateBytes: 0, verificationRequiredBytes: 0, protectedBytes: 0, blockedBytes: 0, removedBytes: 0, removedFiles: 0 }; for (const item of artifacts) { if (item.outcome === "candidate") totals.candidateBytes += item.bytes; if (item.outcome === "verification-required") totals.verificationRequiredBytes += item.bytes; if (item.outcome === "protected") totals.protectedBytes += item.bytes; if (item.outcome === "blocked" || item.outcome === "failed") totals.blockedBytes += item.bytes; if (item.removedBytes !== void 0) { totals.removedBytes += item.removedBytes; totals.removedFiles += 1; } } return { stateDir, artifacts, totals, status }; } function inspectSessionSqliteRecovery(params) { return collectRecoveryInventory(params).report; } //#endregion //#region src/commands/doctor-session-sqlite-verification.ts /** Offline destination ownership and conservative adoption of historical import evidence. */ /** Keep one owner proof per database; fence in-place writes and sidecar changes after awaits. */ function createRecoveryDestinationVerifier(stateDir) { const destinations = /* @__PURE__ */ new Map(); return (refs) => { for (const { target } of refs) { const paths = resolveSqliteDatabaseFilePaths(target.sqlitePath); assertDoctorSqliteMaintenancePathsNotAliased("update recovery cleanup", paths, [stateDir]); const expected = destinations.get(target.sqlitePath); if (!expected) { const owner = inspectOpenClawAgentDatabaseOwner(target.sqlitePath); if (owner.status !== "owned" || owner.agentId !== target.agentId) throw new Error("destination database ownership cannot be verified"); } const files = paths.map((file) => fs.lstatSync(file, { bigint: true, throwIfNoEntry: false })); if (!files[0]?.isFile() || files.some((file) => file && (!file.isFile() || file.nlink !== 1n)) || expected && (expected.agentId !== target.agentId || files.some((file, index) => [ "dev", "ino", "ctimeNs", "mtimeNs", "size" ].some((key) => file?.[key] !== expected.files[index]?.[key])))) throw new Error("Recovery destination database changed; preview cleanup again."); if (!expected) destinations.set(target.sqlitePath, { agentId: target.agentId, files }); } }; } function verifyHistoricalMigrationArtifact(params) { const { target, move, env } = params; if (move.kind !== "legacy-store" && move.kind !== "transcript") return; const identity = readMigrationArtifactIdentity(move.archivePath); const indexMove = move.kind === "legacy-store" ? move : uniqueRestoreMoves(target).find((item) => item.kind === "legacy-store"); if (!indexMove) return; readMigrationArtifactIdentity(indexMove.archivePath); const fd = fs.openSync(indexMove.archivePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); let index; try { index = JSON.parse(readFileDescriptorBoundedSync(fd, fs.fstatSync(fd).size).toString("utf8")); } finally { fs.closeSync(fd); } if (!isRecord(index)) return; const entries = move.kind === "legacy-store" ? Object.entries(index) : move.sessionKey ? [[move.sessionKey, index[move.sessionKey]]] : []; if (move.kind === "transcript" && entries.length !== 1) return; const dependencies = new Set(move.kind === "legacy-store" ? uniqueRestoreMoves(target).filter((item) => item.kind === "transcript").map((item) => item.sourcePath) : []); const verified = withOpenClawAgentDatabaseReadOnly((database) => { const db = getSessionKysely(database.db); for (const [key, raw] of entries) { if (typeof key !== "string" || !isRecord(raw) || typeof raw.sessionId !== "string" || !raw.sessionId.trim() || typeof raw.updatedAt !== "number") return false; const sessionId = raw.sessionId; const row = executeSqliteQueryTakeFirstSync(database.db, db.selectFrom("session_nodes").select(["current_session_id", "entry_json"]).where("session_key", "=", key)); if (!row || row.current_session_id !== raw.sessionId) return false; const current = JSON.parse(row.entry_json); const entry = { ...raw, sessionId, updatedAt: raw.updatedAt }; const normalized = migrateLegacySessionCreator(normalizeLegacySessionEntryDelivery(entry)); if (!isRecord(current) || Object.entries(normalized).some(([field, value]) => field !== "sessionFile" && JSON.stringify(current[field]) !== JSON.stringify(value))) return false; if (move.kind !== "transcript") { for (const source of resolveLegacyTranscriptPaths(target, entry).transcriptDependencies) dependencies.add(canonicalMigrationFilePath(source)); continue; } if (!withSqliteSessionImportStage((stage) => { let seq = 0; const validate = createTranscriptEventReader(move.archivePath, sessionId, false, readTranscriptFingerprint(move.archivePath), move.sourcePath)((event) => stage.append(0, seq++, JSON.stringify(event), null)); const repair = stage.repairLegacyTranscript(0); if (repair.repaired || !repair.recognized) return false; for (const event of iterateSqliteQuerySync(database.db, db.selectFrom("transcript_events").select("event_json").where("session_id", "=", sessionId))) stage.addSeen(event.event_json); for (const event of stage.rows(0)) if (!stage.contains(event.eventJson)) return false; validate(); return true; })) return false; } return true; }, { agentId: target.agentId, path: target.sqlitePath, env }); if (!verified.found || !verified.value) return; return { identity, classification: "imported", reason: "verified-historical-import", dependencies: [...dependencies], disposal: { state: "retained" } }; } //#endregion //#region src/commands/doctor-session-sqlite-retirement.ts /** Explicit retirement of producer-verified rollback originals; never a suffix deletion policy. */ function assertRecoveryOriginal(archivePath, artifact) { const currentPath = statMigrationPath(archivePath) ? archivePath : artifact.disposal.state === "pending-disposal" ? artifact.disposal.claimPath : archivePath; if (!statMigrationPath(currentPath)) { if (artifact.disposal.state === "pending-disposal" && artifact.disposal.phase === "unlink-pending") return; throw new Error("artifact is unexpectedly missing"); } const links = isPendingMigrationArtifactClaim(archivePath, artifact) ? 2n : 1n; if (!sameMigrationArtifact(readMigrationArtifactIdentity(currentPath, links), artifact.identity)) throw new Error("artifact identity or contents changed"); } /** The CLI supplies source-only configuration again under authority before exact confirmation. */ async function retireSessionSqliteRecovery(params) { await assertOpenClawStateWriteAllowedAtPath({ databasePath: path.join(params.preview.stateDir, "state", "openclaw.sqlite"), env: params.env, recoverOrphanedSidecars: false }); return withDoctorSqliteMaintenanceLock({ env: params.env, operation: "update recovery cleanup", run: async (authority) => { const { report, references, manifestPaths } = collectRecoveryInventory({ cfg: await params.readConfig(), env: params.env }); authority.assertCurrent(); if (report.stateDir !== params.preview.stateDir || JSON.stringify(report.artifacts) !== JSON.stringify(params.preview.artifacts)) throw new Error("Recovery selection changed; preview cleanup again."); await assertOpenClawStateWriteAllowedAtPath({ databasePath: path.join(report.stateDir, "state", "openclaw.sqlite"), env: params.env, recoverOrphanedSidecars: false }); const adoptions = /* @__PURE__ */ new Map(); const assertDestinations = createRecoveryDestinationVerifier(report.stateDir); for (const item of report.artifacts) { if (item.outcome === "verification-required") { const refs = references.get(item.path); try { for (const ref of refs) { if (ref.move.artifact) continue; const artifact = verifyHistoricalMigrationArtifact({ target: ref.target, move: ref.move, env: params.env }); if (!artifact) throw new Error("historical-import-proof-unavailable"); adoptions.set(ref, artifact); } item.outcome = "candidate"; item.reason = "verified-historical-import"; } catch (error) { item.outcome = "protected"; item.reason = "historical-import-proof-unavailable"; item.detail = String(error); } } if (item.outcome !== "candidate") continue; try { const refs = references.get(item.path); const artifact = resolveRecoveryArtifact(refs) ?? adoptions.get(refs[0]); assertRecoveryOriginal(item.path, artifact); } catch (error) { item.outcome = "blocked"; item.reason = "artifact-verification-failed"; item.detail = String(error); } } for (const item of report.artifacts) { if (item.outcome !== "candidate") continue; try { assertDestinations(references.get(item.path)); } catch (error) { item.outcome = "blocked"; item.reason = "destination-verification-failed"; item.detail = String(error); } } protectRecoveryDependencies(report.artifacts, references, adoptions); const verified = summarizeRecoveryCleanup(report.stateDir, report.artifacts, "preview"); const selected = verified.artifacts.filter((item) => item.outcome === "candidate"); if (!await params.confirm(verified)) return { ...verified, status: "refused" }; authority.assertCurrent(); const rechecked = collectRecoveryInventory({ cfg: await params.readConfig(), env: params.env }); if (rechecked.report.stateDir !== report.stateDir || JSON.stringify(rechecked.manifestPaths) !== JSON.stringify(manifestPaths) || JSON.stringify(rechecked.report.artifacts) !== JSON.stringify(params.preview.artifacts)) throw new Error("Recovery selection changed during confirmation; preview again."); for (const refs of references.values()) for (const ref of refs) { if (JSON.stringify(readSessionSqliteMigrationManifest(ref.run.manifestPath)) !== JSON.stringify(ref.run.manifest)) throw new Error("Recovery manifest changed during confirmation; preview again."); if (!(rechecked.references.get(ref.move.archivePath) ?? []).some((current) => current.trusted === ref.trusted && current.target.storePath === ref.target.storePath && current.target.sqlitePath === ref.target.sqlitePath)) throw new Error("Recovery target changed during confirmation; preview again."); } await assertOpenClawStateWriteAllowedAtPath({ databasePath: path.join(report.stateDir, "state", "openclaw.sqlite"), env: params.env, recoverOrphanedSidecars: false }); authority.assertCurrent(); for (const item of selected) { const refs = references.get(item.path); assertDestinations(refs); assertRecoveryOriginal(item.path, resolveRecoveryArtifact(refs) ?? adoptions.get(refs[0])); } for (const [ref, artifact] of adoptions) { if (!selected.some((item) => item.path === ref.move.archivePath)) continue; if (ref.run.manifest.manifestVersion !== 4) ref.run.manifest.manifestVersion = 3; for (const move of [...ref.target.plannedMoves, ...ref.target.completedMoves]) if (move.archivePath === ref.move.archivePath) move.artifact = artifact; } const runs = /* @__PURE__ */ new Set(); for (const item of selected) { const refs = references.get(item.path); const disposal = refs.map(({ move }) => move.artifact.disposal).find((receipt) => receipt.state === "pending-disposal") ?? { state: "pending-disposal", intendedAt: (/* @__PURE__ */ new Date()).toISOString(), phase: "intent", claimPath: path.join(path.dirname(item.path), `.cleanup-${randomUUID()}`) }; for (const ref of refs) { for (const move of [...ref.target.plannedMoves, ...ref.target.completedMoves]) if (move.archivePath === item.path && move.artifact) move.artifact.disposal = disposal; runs.add(ref.run); } } for (const run of runs) writeSessionSqliteMigrationManifest(run); let activeItem; const claims = selected.map((item) => { const refs = references.get(item.path); const artifact = refs[0].move.artifact; const disposal = artifact.disposal; if (disposal.state !== "pending-disposal" || path.dirname(disposal.claimPath) !== path.dirname(item.path) || !path.basename(disposal.claimPath).startsWith(".cleanup-")) throw new Error("invalid disposal claim"); return { item, refs, artifact, disposal, present: false }; }); try { for (const claim of claims) { const { item, refs, artifact, disposal } = claim; activeItem = item; authority.assertCurrent(); assertDestinations(refs); if (hasSymbolicLinkInDirectoryPath(path.dirname(item.path))) throw new Error("archive directory changed"); if (statMigrationPath(item.path)) { if (disposal.phase === "unlink-pending") throw new Error("archive was recreated after claim"); await moveMigrationArtifact(item.path, disposal.claimPath, artifact.identity); } authority.assertCurrent(); assertDestinations(refs); assertRecoveryOriginal(item.path, artifact); claim.present = statMigrationPath(disposal.claimPath) !== void 0; disposal.phase = "unlink-pending"; } for (const run of runs) writeSessionSqliteMigrationManifest(run); for (const { item, artifact, disposal, present } of claims) { activeItem = item; if (hasSymbolicLinkInDirectoryPath(path.dirname(item.path))) throw new Error("archive directory changed"); if (statMigrationPath(item.path)) throw new Error("archive was recreated after claim"); assertRecoveryOriginal(item.path, artifact); if (present !== (statMigrationPath(disposal.claimPath) !== void 0)) throw new Error("disposal claim changed after intent"); } authority.assertCurrent(); for (const { item, refs } of claims) { activeItem = item; assertDestinations(refs); } for (const { item, artifact, disposal } of claims) { activeItem = item; const claim = statMigrationPath(disposal.claimPath); if (claim) { fs.unlinkSync(disposal.claimPath); item.removedBytes = artifact.identity.size; } item.outcome = claim ? "removed" : "disposed"; item.bytes = claim ? artifact.identity.size : 0; item.reason = claim ? "rollback-original-retired" : "completed-interrupted-disposal"; } for (const { item, refs } of claims) { activeItem = item; if (item.outcome === "removed") requireDirectorySync(await syncDirectory(path.dirname(item.path)), "Recovery artifact removal"); for (const ref of refs) for (const move of [...ref.target.plannedMoves, ...ref.target.completedMoves]) if (move.archivePath === item.path && move.artifact) move.artifact.disposal = { state: "disposed", disposedAt: (/* @__PURE__ */ new Date()).toISOString() }; for (const run of new Set(refs.map((ref) => ref.run))) writeSessionSqliteMigrationManifest(run); } } catch (error) { if (activeItem) { activeItem.outcome = "failed"; activeItem.reason = "artifact-retirement-failed"; activeItem.detail = String(error); } for (const item of selected) if (item.outcome === "candidate") { item.outcome = "blocked"; item.reason = "retirement-stopped"; } } return summarizeRecoveryCleanup(report.stateDir, report.artifacts, report.artifacts.some((item) => item.outcome === "failed" || item.outcome === "blocked") ? "blocked" : "complete"); } }); } //#endregion //#region src/cli/update-cli/cleanup.ts /** Local recovery retirement. This handler never invokes update or Doctor repair. */ function renderCleanup(report) { defaultRuntime.log(`Recovery cleanup: ${report.stateDir}`); for (const item of report.artifacts) defaultRuntime.log(` ${item.outcome}: ${item.path} (${item.bytes} bytes; ${item.reason})${item.detail ? ` ${item.detail}` : ""}`); defaultRuntime.log(`Candidates: ${report.totals.candidateBytes} bytes; verification required: ${report.totals.verificationRequiredBytes}; protected: ${report.totals.protectedBytes}; blocked: ${report.totals.blockedBytes}.`); defaultRuntime.log("Retiring originals permanently loses rollback, including pre-repair branches and metadata. Logical bytes are not a promise of physical space reclaimed."); if (report.artifacts.length === 0) defaultRuntime.log("No recorded recovery artifacts found."); if (report.status === "complete") defaultRuntime.log(`Removed ${report.totals.removedFiles} files (${report.totals.removedBytes} logical bytes).`); } async function updateCleanupCommand(options) { let report; try { const readConfig = () => readSourceConfigBestEffort(); report = inspectSessionSqliteRecovery({ cfg: await readConfig(), env: process.env }); if (!options.dryRun) { if (report.artifacts.length === 0 && !options.yes) report.status = "complete"; else if (!options.yes && (options.json || !process.stdin.isTTY || !process.stderr.isTTY)) report.status = "refused"; else report = await retireSessionSqliteRecovery({ env: process.env, preview: report, readConfig, confirm: async (verified) => { if (options.yes || verified.artifacts.length === 0) return true; renderCleanup(verified); const answer = await confirm({ message: "Permanently retire these rollback originals?", initialValue: false, output: process.stderr }); return !isCancel(answer) && answer; } }); } if (options.json) writeRuntimeJson(defaultRuntime, { ...report, dryRun: options.dryRun === true }); else renderCleanup(report); if (report.status === "refused") { defaultRuntime.error("Nothing removed. Review with `openclaw update cleanup --dry-run`; use --yes to acknowledge permanent rollback loss."); defaultRuntime.exit(1); } else if (report.status === "blocked") defaultRuntime.exit(1); } catch (error) { if (options.json) writeRuntimeJson(defaultRuntime, { ...report, status: "blocked", error: String(error) }); else defaultRuntime.error(String(error)); defaultRuntime.exit(1); } } //#endregion export { updateCleanupCommand };