UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

346 lines (345 loc) • 16.7 kB
import { t as openNodeSqliteDatabase } from "./node-sqlite-XIcwmOrl.mjs"; import { d as isSqlitePathOnBtrfs, p as setSqliteDirectoryNoCow } from "./sqlite-wal-DUSYRhyv.mjs"; import { l as syncDirectory, s as requireDirectorySync } from "./directory-durability-CqSvP1tc.mjs"; import { i as sameFileMutationFingerprint, t as copyFileHandle } from "./file-descriptor-B4yBUiAP.mjs"; import { n as assertSqliteIntegrity } from "./sqlite-integrity-GtqcmwG3.mjs"; import { t as DoctorMaintenanceRefusalError } from "./update-doctor-result-b1ffujJO.mjs"; import { t as loadSqliteVecExtension } from "./sqlite-vec-BcbJNyja.mjs"; import { t as createVerifiedSqliteSnapshot } from "./sqlite-snapshot-c6hBDowh.mjs"; import { n as assertDoctorSqliteMaintenancePathsNotAliased } from "./doctor-sqlite-maintenance-lock-B-Ck8335.mjs"; import fs from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; import { spawnSync } from "node:child_process"; import { root } from "@openclaw/fs-safe/root"; //#region src/commands/doctor-sqlite-nocow.ts /** Offline btrfs rewrites retain the original directory and SQLite's WAL-aware backup. */ const TOOL_TIMEOUT_MS = 2e3; const FUSER_MAX_PATHS = 2e3; const FUSER_MAX_PATH_BYTES = 65536; function hasNoCow(pathname) { const result = spawnSync("lsattr", [ "-d", "--", pathname ], { encoding: "utf8", timeout: TOOL_TIMEOUT_MS }); const flags = result.stdout?.trim().split(/\s+/u)[0]; if (result.error || result.status !== 0 || !flags || !/^[a-zA-Z-]+$/u.test(flags)) throw new Error("NOCOW check skipped: lsattr is unavailable or could not read file attributes."); return flags.includes("C"); } function inspectDoctorSqliteNoCow(paths) { const result = { paths: [], notes: [] }; for (const pathname of new Set(paths)) try { if (!fs.existsSync(pathname) || !isSqlitePathOnBtrfs(pathname)) continue; if (!hasNoCow(pathname) || !hasNoCow(path.dirname(pathname))) { result.paths.push(pathname); result.notes.push(`SQLite store on btrfs without NOCOW: ${pathname}. Run openclaw doctor --fix to rewrite it while the Gateway is stopped.`); } } catch (error) { result.notes.push(`${pathname}: ${String(error)}`); } return result; } async function copyRegularFile(sourcePath, targetPath, assertCurrent) { const source = await fs$1.open(sourcePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); try { const identity = await source.stat(); assertCurrent(); const target = await fs$1.open(targetPath, "wx", 384); try { await copyFileHandle(source, target); if ((await target.stat()).size !== identity.size) throw new Error(`SQLite NOCOW copy size mismatch: ${sourcePath}`); } finally { await target.close(); } } finally { await source.close(); } } function runAclTool(command, args, input) { const env = { ...process.env }; delete env.POSIXLY_CORRECT; const result = spawnSync(command, args, { encoding: "utf8", timeout: TOOL_TIMEOUT_MS, input, env }); if (result.error || result.status !== 0) throw new Error(`NOCOW repair requires working ${command} to preserve access and default ACLs`); return result.stdout.trim(); } function readAcl(pathname) { return runAclTool("getfacl", [ "-cEpn", "--", pathname ]); } function preserveMetadata(target, original, acl) { const current = fs.statSync(target); if (BigInt(current.uid) !== original.uid || BigInt(current.gid) !== original.gid) fs.chownSync(target, Number(original.uid), Number(original.gid)); const mode = Number(original.mode) & 4095; fs.chmodSync(target, mode & 4032); if (original.isDirectory()) runAclTool("setfacl", [ "-k", "--", target ]); runAclTool("setfacl", [ "-n", "--set-file=-", "--", target ], `${acl}\n`); if (readAcl(target) !== acl || (fs.statSync(target).mode & 4095) !== mode) throw new Error(`NOCOW copy ACL verification failed: ${target}`); if (original.isFile()) { const descriptor = fs.openSync(target, "r"); try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } } } async function removeUninstalledDirectory(pathname, identity, assertCurrent) { await (await root(path.dirname(pathname))).remove(path.basename(pathname), { recursive: true, assertBeforeMutation() { assertCurrent(); const current = fs.lstatSync(pathname, { bigint: true }); if (current.dev !== identity.dev || current.ino !== identity.ino) throw new Error(`NOCOW staging identity changed: ${pathname}`); } }); } async function quickCheck(pathname) { const db = openNodeSqliteDatabase(pathname, { readOnly: true, allowExtension: true }); try { await loadSqliteVecExtension({ db }); assertSqliteIntegrity(db, pathname, "quick_check"); } finally { db.close(); } } function assertNoOpenFiles(paths) { const batches = []; let batch = []; let batchBytes = 0; for (const pathname of paths) { const absolute = path.resolve(pathname); const bytes = Buffer.byteLength(absolute, "utf8") + 1; if (batch.length > 0 && (batch.length >= FUSER_MAX_PATHS || batchBytes + bytes > FUSER_MAX_PATH_BYTES)) { batches.push(batch); batch = []; batchBytes = 0; } batch.push(absolute); batchBytes += bytes; } if (batch.length > 0) batches.push(batch); const pids = /* @__PURE__ */ new Set(); let inspectionError; for (const args of batches) { const result = spawnSync("fuser", args, { encoding: "utf8", timeout: TOOL_TIMEOUT_MS }); const stdout = result.stdout?.trim() ?? ""; const stderr = result.stderr?.trim() ?? ""; if (result.status === 0 && /^\d+(?:\s+\d+)*$/u.test(stdout)) for (const pid of stdout.split(/\s+/u)) pids.add(pid); else if (result.error || result.status !== 1 || stdout || stderr) inspectionError ??= stderr || result.error?.message || (result.signal ? `signal ${result.signal}` : stdout || `exit status ${result.status}`); } if (pids.size > 0) throw new Error(`store files are open (pids: ${[...pids].join(", ")}); stop processes using this store before retrying`); if (inspectionError) throw new Error(`fuser could not establish that all handles are closed: ${inspectionError}; ensure fuser is installed and can inspect processes using this store`); } function readStoreEntries(directory) { const entries = []; const pending = [directory]; for (const parent of pending) { if (!fs.lstatSync(parent).isDirectory()) throw new Error(`store directory changed during traversal: ${parent}`); for (const entry of fs.readdirSync(parent, { withFileTypes: true })) { entries.push(entry); if (entry.isDirectory()) pending.push(path.join(entry.parentPath, entry.name)); } } return entries; } /** Only the live Doctor maintenance owner may call this after draining its database handles. */ async function repairDoctorSqliteNoCow(params) { const help = spawnSync("mv", ["--help"], { encoding: "utf8", timeout: TOOL_TIMEOUT_MS }); if (help.error || help.status !== 0 || !help.stdout.includes("--exchange") || !help.stdout.includes("--no-copy")) return { changes: [], warnings: ["SQLite NOCOW repair skipped: GNU mv with --exchange and --no-copy is required. Stores remain unchanged."] }; const result = { changes: [], warnings: [] }; for (const directory of new Set(params.paths.map((pathname) => path.dirname(pathname)))) { let backup; let backupIdentity; let snapshotRoot; let snapshotIdentity; let snapshotsCompleted = 0; let exchangeAttempted = false; try { params.assertCurrent(); const relative = path.relative(path.resolve(params.stateDir), path.resolve(directory)); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("store directory must be owned by the active state directory"); assertDoctorSqliteMaintenancePathsNotAliased("SQLite NOCOW repair", [directory], [params.stateDir]); const entries = readStoreEntries(directory); const files = entries.map((entry) => path.join(entry.parentPath, entry.name)); const inventory = new Map(files.map((pathname) => [pathname, fs.lstatSync(pathname, { bigint: true })])); assertDoctorSqliteMaintenancePathsNotAliased("SQLite NOCOW repair", files.filter((pathname) => !inventory.get(pathname).isSymbolicLink()), [params.stateDir]); const sourceLinks = /* @__PURE__ */ new Map(); for (const [pathname, stat] of inventory) if (stat.isSymbolicLink()) sourceLinks.set(pathname, fs.readlinkSync(pathname, { encoding: "buffer" })); else if (!stat.isFile() && !stat.isDirectory()) { const type = stat.isFIFO() ? "FIFO" : stat.isSocket() ? "socket" : stat.isBlockDevice() ? "block device" : stat.isCharacterDevice() ? "character device" : "unknown"; throw new Error(`unsupported store directory entry (${type}): ${pathname}`); } const regularPaths = files.filter((pathname) => inventory.get(pathname).isFile()); const sqlitePaths = new Set(regularPaths.filter((pathname) => pathname.endsWith(".sqlite"))); for (const pathname of params.paths) if (inventory.get(pathname)?.isFile()) sqlitePaths.add(pathname); const sharedMemoryPaths = new Set([...sqlitePaths].map((pathname) => `${pathname}-shm`)); const sourceIdentity = fs.statSync(directory, { bigint: true }); const sourceFiles = new Map([...inventory].filter(([pathname, stat]) => !stat.isFile() || !sharedMemoryPaths.has(pathname))); const sourceAcls = new Map([directory, ...sourceFiles.keys()].filter((pathname) => !sourceLinks.has(pathname)).map((pathname) => [pathname, readAcl(pathname)])); assertNoOpenFiles(regularPaths); const size = [...inventory.values()].reduce((total, stat) => total + stat.size, 0n); const space = fs.statfsSync(directory, { bigint: true }); if (space.bavail * space.bsize < size * 2n) throw new Error(`at least ${size * 2n} bytes of free space are required (twice the store directory size)`); backup = fs.mkdtempSync(`${directory}.nocow-backup-`); backupIdentity = fs.statSync(backup, { bigint: true }); setSqliteDirectoryNoCow(backup); snapshotRoot = fs.mkdtempSync(`${directory}.nocow-snapshots-`); snapshotIdentity = fs.statSync(snapshotRoot, { bigint: true }); for (const entry of entries.filter((candidate) => candidate.isDirectory())) { const target = path.join(backup, path.relative(directory, path.join(entry.parentPath, entry.name))); fs.mkdirSync(target, { recursive: true, mode: 448 }); setSqliteDirectoryNoCow(target); } for (const [pathname, linkText] of sourceLinks) { const target = path.join(backup, path.relative(directory, pathname)); const original = inventory.get(pathname); params.assertCurrent(); fs.symlinkSync(linkText, target); fs.lchownSync(target, Number(original.uid), Number(original.gid)); } for (const pathname of regularPaths) { params.assertCurrent(); if ([ "-wal", "-shm", "-journal" ].some((suffix) => pathname.endsWith(suffix) && sqlitePaths.has(pathname.slice(0, -suffix.length)))) continue; const relativePath = path.relative(directory, pathname); const target = path.join(backup, relativePath); if (sqlitePaths.has(pathname)) { assertDoctorSqliteMaintenancePathsNotAliased("SQLite NOCOW repair", [pathname, ...[ "-wal", "-shm", "-journal" ].map((suffix) => `${pathname}${suffix}`)], [params.stateDir]); const snapshotPath = path.join(snapshotRoot, relativePath); fs.mkdirSync(path.dirname(snapshotPath), { recursive: true, mode: 448 }); await createVerifiedSqliteSnapshot({ sourcePath: pathname, targetPath: snapshotPath, preserveRowIds: true, beforePublish: params.assertCurrent }); snapshotsCompleted++; params.assertCurrent(); await copyRegularFile(snapshotPath, target, params.assertCurrent); await quickCheck(target); if (!hasNoCow(target)) throw new Error(`NOCOW was not inherited by ${target}`); } else await copyRegularFile(pathname, target, params.assertCurrent); params.assertCurrent(); preserveMetadata(target, inventory.get(pathname), sourceAcls.get(pathname)); } const sourceDirectories = [directory, ...files.filter((pathname) => inventory.get(pathname).isDirectory())]; for (const sourceDirectory of sourceDirectories.toReversed()) { const stagedDirectory = path.join(backup, path.relative(directory, sourceDirectory)); params.assertCurrent(); preserveMetadata(stagedDirectory, sourceDirectory === directory ? sourceIdentity : inventory.get(sourceDirectory), sourceAcls.get(sourceDirectory)); requireDirectorySync(await syncDirectory(stagedDirectory), "SQLite NOCOW staging directory"); } params.assertCurrent(); assertNoOpenFiles(readStoreEntries(directory).map((entry) => path.join(entry.parentPath, entry.name)).filter((pathname) => fs.lstatSync(pathname).isFile())); const currentIdentity = fs.statSync(directory, { bigint: true }); const currentFiles = readStoreEntries(directory).map((entry) => path.join(entry.parentPath, entry.name)).filter((pathname) => { if (sharedMemoryPaths.has(pathname) && fs.lstatSync(pathname).isFile()) return false; if (pathname.endsWith("-wal") && sqlitePaths.has(pathname.slice(0, -4)) && !sourceFiles.has(pathname)) { const stat = fs.lstatSync(pathname); return !stat.isFile() || stat.size !== 0; } return true; }); if (currentIdentity.dev !== sourceIdentity.dev || currentIdentity.ino !== sourceIdentity.ino || currentIdentity.mode !== sourceIdentity.mode || currentIdentity.uid !== sourceIdentity.uid || currentIdentity.gid !== sourceIdentity.gid || readAcl(directory) !== sourceAcls.get(directory) || currentFiles.length !== sourceFiles.size || currentFiles.some((pathname) => { const expected = sourceFiles.get(pathname); const current = fs.lstatSync(pathname, { bigint: true }); return !expected || (expected.isDirectory() ? !current.isDirectory() || expected.dev !== current.dev || expected.ino !== current.ino || expected.mode !== current.mode || expected.uid !== current.uid || expected.gid !== current.gid || readAcl(pathname) !== sourceAcls.get(pathname) : !sameFileMutationFingerprint(expected, current) || expected.isSymbolicLink() && (!current.isSymbolicLink() || !fs.readlinkSync(pathname, { encoding: "buffer" }).equals(sourceLinks.get(pathname)))); })) throw new Error("source store changed during the NOCOW rewrite; original retained for retry"); const original = fs.statSync(directory); const replacement = fs.statSync(backup); params.assertCurrent(); exchangeAttempted = true; const exchange = spawnSync("mv", [ "--exchange", "--no-copy", "-T", "--", backup, directory ], { encoding: "utf8", timeout: TOOL_TIMEOUT_MS }); const installed = fs.statSync(directory); const retained = fs.statSync(backup); if (installed.dev === original.dev && installed.ino === original.ino && retained.dev === replacement.dev && retained.ino === replacement.ino) exchangeAttempted = false; if (installed.dev !== replacement.dev || installed.ino !== replacement.ino || retained.dev !== original.dev || retained.ino !== original.ino) throw new Error(`atomic directory exchange failed: ${exchange.error?.message ?? exchange.stderr}`); requireDirectorySync(await syncDirectory(path.dirname(directory)), "SQLite NOCOW store parent"); result.changes.push(`Rewrote SQLite store directory with NOCOW: ${directory}. Original retained at ${backup}; verified WAL-aware snapshots at ${snapshotRoot}.`); } catch (error) { if (exchangeAttempted) throw new DoctorMaintenanceRefusalError(`SQLite NOCOW directory publication needs inspection: ${directory}; retained directory: ${backup}. ${String(error)}`, { kind: "data-at-risk", reason: "incomplete-migration" }); for (const candidate of [...backup && backupIdentity ? [{ pathname: backup, identity: backupIdentity }] : [], ...snapshotRoot && snapshotIdentity && snapshotsCompleted === 0 ? [{ pathname: snapshotRoot, identity: snapshotIdentity }] : []]) try { await removeUninstalledDirectory(candidate.pathname, candidate.identity, params.assertCurrent); if (candidate.pathname === backup) backup = void 0; if (candidate.pathname === snapshotRoot) snapshotRoot = void 0; } catch (cleanupError) { result.warnings.push(`NOCOW staging cleanup failed; retained ${candidate.pathname}: ${String(cleanupError)}`); } result.warnings.push(`SQLite NOCOW repair refused for ${directory}: ${String(error)}${backup ? ` Recovery directory: ${backup}.` : ". Original store remains in place."}${snapshotRoot ? ` WAL-aware backup directory: ${snapshotRoot}.` : ""}`); } } return result; } //#endregion export { inspectDoctorSqliteNoCow, repairDoctorSqliteNoCow };