openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
224 lines (223 loc) • 10.3 kB
JavaScript
import { t as FsSafeError, w as root } from "./fs-safe-BUXBSN-4.mjs";
import { r as isMissingPathError, t as hasErrnoCode } from "./errno-FR4CRyya.mjs";
import { t as formatErrorMessage } from "./errors-O33D01XW.mjs";
import { a as isSqliteLockError, o as isSqliteNativeOpenFailure } from "./sqlite-error-diagnostics-DCvjvszg.mjs";
import { r as getChildLogger } from "./logger-Mnn26Jj7.mjs";
import { f as createPrivateSqliteTempDirectory } from "./sqlite-snapshot-staging-BIgaAYlp.mjs";
import { n as SqliteStagingRetiredError, r as acquireSqliteStagingToken, t as SQLITE_STAGING_TOKEN_FILES } from "./sqlite-staging-token-DXBiTLqt.mjs";
import fs from "node:fs";
import path from "node:path";
import fs$1 from "node:fs/promises";
import os from "node:os";
import { sameFileIdentity } from "@openclaw/fs-safe/advanced";
//#region src/infra/backup-scratch.ts
const scratchName = /^openclaw-backup-(?:owned-|retired-)?(?:[A-Za-z0-9]{6}|[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})$/u;
const ownedPrefix = "openclaw-backup-owned-";
const retiredPrefix = "openclaw-backup-retired-";
async function createBackupScratchDirectory(root$1) {
for (let attempt = 0;; attempt += 1) {
const directory = await createPrivateSqliteTempDirectory(root$1, ownedPrefix);
let boundary;
try {
boundary = await root(directory);
const observed = await fs$1.lstat(directory);
if (!observed.isDirectory() || !sameFileIdentity(observed, await boundary.stat("."))) throw new Error("Backup scratch directory identity changed");
return {
directory,
boundary,
release: acquireSqliteStagingToken(directory, "create")
};
} catch (error) {
if (isSqliteLockError(error) || error instanceof SqliteStagingRetiredError || isMissingPathError(error) || (isSqliteNativeOpenFailure(error) || error instanceof FsSafeError && error.code === "path-mismatch" && isMissingPathError(error.cause)) && await wasScratchReclaimed(directory)) {
if (attempt < 2) continue;
throw error;
}
await cleanupBackupScratchDirectory(directory, boundary);
throw error;
}
}
}
function reportScratchMessage(message, log, level = "warn") {
try {
if (log) {
log(message);
return;
}
} catch {}
try {
getChildLogger({ subsystem: "infra/backup" })[level](message);
} catch {}
}
async function wasScratchReclaimed(directory) {
try {
await fs$1.lstat(directory);
return false;
} catch (inspectionError) {
return isMissingPathError(inspectionError);
}
}
async function inspectScratchPayload(directory, layout = "root") {
for (const name of await fs$1.readdir(directory)) {
const location = path.join(directory, name);
const item = await fs$1.lstat(location);
if (process.getuid && item.uid !== process.getuid()) throw new Error(`Scratch file ownership is unknown: ${location}`);
if (item.isDirectory()) {
const nested = layout === "root" && /^state-snapshot-(?:no-legacy|attempt-[1-3])$/u.test(name) ? "snapshot" : layout === "snapshot" && name === "legacy-verification" ? "verification" : layout !== "sqlite" && /^\.sqlite-(?:snapshot-|publish-[\da-f-]{36}-)(?:[A-Za-z0-9]{6}|[\da-f-]{36})$/u.test(name) ? "sqlite" : void 0;
if (nested) {
await inspectScratchPayload(location, nested);
continue;
}
} else if (item.isFile() && (layout === "root" && (SQLITE_STAGING_TOKEN_FILES.some((control) => control === name) || /^config-\d+$/u.test(name) || name === "archive.tar.gz") || (layout === "sqlite" ? /^database\.sqlite(?:-wal|-shm|-journal)?$/u.test(name) : /^(?:openclaw-state-db-\d+\.sqlite(?:-wal|-shm|-journal)?|legacy-audit-raw-\d+\.jsonl)$/u.test(name)))) continue;
throw new Error(`Unrecognized backup scratch content: ${location}`);
}
}
async function removeBackupScratchPayload(boundary) {
for await (const entry of boundary.entries(".")) if (!SQLITE_STAGING_TOKEN_FILES.some((control) => control === entry.name)) await boundary.remove(entry.name, {
recursive: true,
force: true,
mutationSymlinks: "reject",
maxEntries: Infinity,
maxDepth: Infinity
});
}
async function cleanupBackupScratchDirectory(initialDirectory, initialBoundary, log) {
let directory = initialDirectory;
let boundary = initialBoundary;
try {
if (boundary && !path.basename(directory).startsWith(retiredPrefix)) {
const expected = await boundary.stat(".");
const parent = await root(path.dirname(directory));
const retiredPath = await createPrivateSqliteTempDirectory(parent.rootReal, retiredPrefix);
const retiredName = path.basename(retiredPath);
const reservation = await fs$1.lstat(retiredPath);
await parent.move(path.basename(directory), retiredName, {
overwrite: true,
mutationSymlinks: "reject",
assertBeforeMutation: () => {
const current = fs.lstatSync(directory);
const destination = fs.lstatSync(retiredPath);
if (!current.isDirectory() || !sameFileIdentity(expected, current) || !destination.isDirectory() || !sameFileIdentity(reservation, destination)) throw new Error("Backup scratch directory identity changed");
}
});
directory = path.join(parent.rootReal, retiredName);
boundary = await root(directory);
if (!sameFileIdentity(expected, await boundary.stat("."))) throw new Error("Retired backup scratch directory identity changed");
}
if (boundary) {
for (const control of SQLITE_STAGING_TOKEN_FILES) await boundary.remove(control, {
force: true,
mutationSymlinks: "reject"
});
await boundary.stat(".");
}
await fs$1.rmdir(directory);
return { status: "reclaimed" };
} catch (error) {
if (await wasScratchReclaimed(directory)) return {
status: "already-reclaimed",
directory
};
const warning = `Backup scratch cleanup failed at ${directory}: ${formatErrorMessage(error)}. Run \`openclaw doctor --fix\` to retry cleanup.`;
reportScratchMessage(warning, log);
return {
status: "failed",
warning
};
}
}
async function finishBackupScratch(scratch, log) {
let retirementFailure;
try {
scratch.release = scratch.release.beginRetirement();
await removeBackupScratchPayload(scratch.boundary);
scratch.release(true);
} catch (error) {
retirementFailure = error;
}
try {
scratch.release();
} catch (error) {
retirementFailure ??= error;
}
if (retirementFailure) {
const warning = `Backup scratch retirement failed at ${scratch.directory}: ${formatErrorMessage(retirementFailure)}. Scratch was preserved.`;
reportScratchMessage(warning, log);
return warning;
}
const cleanup = await cleanupBackupScratchDirectory(scratch.directory, scratch.boundary, log);
if (cleanup.status === "already-reclaimed") reportScratchMessage(`Backup scratch already reclaimed: ${cleanup.directory}`, log, "info");
return cleanup.status === "failed" ? cleanup.warning : void 0;
}
/** The transaction, not age or a successful archive record, fences live scratch. */
async function maintainBackupScratch(params) {
const report = {
reclaimed: [],
alreadyReclaimed: [],
active: [],
unchecked: [],
warnings: []
};
const roots = /* @__PURE__ */ new Set();
for (const root of params.roots ?? [os.tmpdir()]) try {
roots.add(await fs$1.realpath(root));
} catch (error) {
if (!hasErrnoCode(error, "ENOENT")) report.warnings.push(`Cannot inspect backup scratch in ${root}: ${formatErrorMessage(error)}`);
}
for (const root$2 of roots) try {
for (const entry of await fs$1.readdir(root$2, { withFileTypes: true })) {
if (!scratchName.test(entry.name)) continue;
const directory = path.join(root$2, entry.name);
if (!entry.isDirectory()) {
report.warnings.push(`Backup scratch entry preserved at ${directory}: not a directory. Inspect it before manual cleanup.`);
continue;
}
let release;
try {
const before = await fs$1.lstat(directory);
if (!before.isDirectory() || process.getuid && before.uid !== process.getuid()) continue;
const token = await fs$1.lstat(path.join(directory, SQLITE_STAGING_TOKEN_FILES[0])).catch((error) => {
if (hasErrnoCode(error, "ENOENT")) return;
throw error;
});
const owned = entry.name.startsWith(ownedPrefix);
if (!params.repair || !token && !owned) await inspectScratchPayload(directory);
if (!token && !owned && !entry.name.startsWith(retiredPrefix)) {
report.warnings.push(`Legacy backup scratch at ${directory} has no lifetime token. Confirm older backup processes have stopped before removing it.`);
continue;
}
if (!params.repair) {
report.unchecked.push(directory);
continue;
}
if (token || owned) release = acquireSqliteStagingToken(directory, "reclaim", { allowMissing: owned });
const boundary = await root(directory);
const current = await fs$1.lstat(directory);
if (!current.isDirectory() || !sameFileIdentity(before, current) || !sameFileIdentity(before, await boundary.stat("."))) throw new Error("Scratch directory identity changed");
await inspectScratchPayload(directory);
await removeBackupScratchPayload(boundary);
release?.(true);
const cleanup = await cleanupBackupScratchDirectory(directory, boundary, () => {});
if (cleanup.status === "failed") report.warnings.push(cleanup.warning);
else if (cleanup.status === "already-reclaimed") report.alreadyReclaimed.push(cleanup.directory);
else report.reclaimed.push(directory);
} catch (error) {
if (isSqliteLockError(error)) report.active.push(directory);
else if (await wasScratchReclaimed(directory)) report.alreadyReclaimed.push(directory);
else report.warnings.push(`Backup scratch preserved at ${directory}: ${formatErrorMessage(error)}`);
} finally {
try {
release?.();
} catch (error) {
report.warnings.push(`Backup scratch token release failed at ${directory}: ${formatErrorMessage(error)}`);
}
}
}
} catch (error) {
report.warnings.push(`Cannot inspect backup scratch in ${root$2}: ${formatErrorMessage(error)}`);
}
for (const warning of report.warnings) reportScratchMessage(warning, params.log);
for (const directory of report.alreadyReclaimed) reportScratchMessage(`Backup scratch already reclaimed: ${directory}`, params.log, "info");
return report;
}
//#endregion
export { finishBackupScratch as n, maintainBackupScratch as r, createBackupScratchDirectory as t };