openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
629 lines (628 loc) • 31.3 kB
JavaScript
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 };