UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

510 lines (509 loc) • 22.3 kB
import { i as runOutsideCommandProcessScope } from "./exec-spawn-DktriUN8.mjs"; import { c as normalizeGitPathForFilesystem } from "./git-exec-DPoSKq41.mjs"; import { c as requireGit, d as runGit, l as requireGitBuffer, n as commandError, s as listGitWorktrees } from "./git-DOeuOnFt.mjs"; import { F as requireWorktreeDiskSpace, I as withWorktreeAllocationLease, T as resolveRepository, a as removeManagedCheckout, d as withExactStateGitLocks, i as prepareSnapshotBranchDeletion } from "./removal-git-B4zDth4_.mjs"; import { t as withWorktreeGitConfig } from "./checkout-git-config-XnzMDYqJ.mjs"; import { n as getRegistryWorktreeProvisionedPaths } from "./registry-read-C1mg8Nxl.mjs"; import { b as updateRegistryWorktree, d as getRegistryWorktree, r as assertWorktreeRemovalClaim, t as abortWorktreeRemovalRow } from "./registry-CvH4JdSJ.mjs"; import { n as claimWorktreeRemoval, s as lockState } from "./run-lease-C-e8hMCX.mjs"; import { c as rawPathStat, l as splitNullBuffer } from "./git-path-inventory-BRi-Wr7x.mjs"; import fs from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; import { createHash, randomUUID } from "node:crypto"; //#region src/agents/worktrees/removal-recovery-inventory.ts /** Match physical checkout bytes to the original capture under Git's filename/content policy. */ async function createRemovalRecoveryInventory(params) { const { record, snapshot, gitdir, options, assertIdentity, refuse: preserved } = params; const readRetainedGitBoolean = async (key, fallback) => { const result = await runGit(record.repoRoot, [ "config", "--type=bool", "--get", key ], { ...options, beforeRun: assertIdentity, env: { ...options.env, GIT_DIR: gitdir } }); if (result.code !== 0 && result.code !== 1) throw commandError(`git config ${key}`, result); return result.code === 1 ? fallback : result.stdout.trim() === "true"; }; const precompose = process.platform === "darwin" && await readRetainedGitBoolean("core.precomposeunicode", false); const pathKey = (filename) => precompose ? filename.normalize("NFC") : filename; const entries = /* @__PURE__ */ new Map(); for (const item of splitNullBuffer(await requireGitBuffer(record.repoRoot, [ "ls-tree", "-r", "-l", "-z", snapshot ], options))) { const tab = item.indexOf(9); const filename = item.subarray(tab + 1).toString("utf8"); const fields = item.subarray(0, tab).toString("ascii").trim().split(/\s+/u); if (tab < 0 || !Buffer.from(filename).equals(item.subarray(tab + 1)) || filename.split("/").some((part) => !part || part === "." || part === ".." || part.toLowerCase() === ".git") || ![ "100644", "100755", "120000" ].includes(fields[0])) throw preserved("Unsupported snapshot path or file type"); const key = pathKey(filename); if (entries.has(key)) throw preserved("Snapshot paths alias under the checkout filename policy"); entries.set(key, { filename, mode: fields[0], oid: fields[2], size: Number(fields[3]) }); } const materializedSymlinks = [...entries.values()].some((entry) => entry.mode === "120000") && !await readRetainedGitBoolean("core.symlinks", process.platform !== "win32"); const checkoutBytes = (entry) => entry.size * (snapshot.length + 8) + 65536; const inventory = (conversionCandidates) => { const found = /* @__PURE__ */ new Set(); const visit = (relative) => { const directory = path.join(record.path, relative); for (const raw of fs.readdirSync(directory, { encoding: "buffer" })) { const name = raw.toString("utf8"); if (!Buffer.from(name).equals(raw)) throw preserved("Unsupported remaining path"); if (!relative && name === ".git") continue; const child = relative ? `${relative}/${name}` : name; const key = pathKey(child); const target = path.join(record.path, child); const info = fs.lstatSync(target); if (info.isDirectory()) { if (![...entries.keys()].some((entry) => entry.startsWith(`${key}/`))) throw preserved(`Foreign directory: ${child}`); visit(child); continue; } if (found.has(key)) throw preserved(`Aliased remaining path: ${child}`); const expected = entries.get(key); const mode = info.isSymbolicLink() ? "120000" : info.isFile() ? (process.platform === "win32" ? expected?.mode === "100755" : info.mode & 73) ? "100755" : "100644" : "unsupported"; const symlinkFile = materializedSymlinks && expected?.mode === "120000" && mode === "100644"; if (!expected || mode !== expected.mode && !symlinkFile) throw preserved(`Changed or foreign file: ${child}`); const content = info.isSymbolicLink() ? fs.readlinkSync(target, { encoding: "buffer" }) : fs.readFileSync(target); if (createHash(snapshot.length === 64 ? "sha256" : "sha1").update(`blob ${content.length}\0`).update(content).digest("hex") !== expected.oid) { if (conversionCandidates && expected.mode !== "120000") conversionCandidates.push(key); else throw preserved(`Changed file: ${child}`); } found.add(key); } }; assertIdentity(); visit(""); assertIdentity(); return [...entries].filter(([key]) => !found.has(key)).map(([, entry]) => entry.filename); }; const verifyInventory = async () => { const conversions = []; inventory(conversions); if (conversions.length) await withWorktreeGitConfig(record.repoRoot, true, { ...options, beforeRun: assertIdentity, env: { ...options.env, GIT_DIR: gitdir } }, async (git) => { for (const key of conversions) { const entry = entries.get(key); const filename = entry.filename; const result = await git.worker.buffered(record.repoRoot, [ `--attr-source=${snapshot}`, "cat-file", "--filters", `--path=${filename}`, `${snapshot}:${filename}` ], { ...options, maxOutputBytes: { stdout: checkoutBytes(entry), stderr: 65536 } }); if (result.code !== 0 || result.termination !== "exit" || result.outputLimitStream) throw preserved("Captured checkout representation is unavailable"); entry.oid = createHash(snapshot.length === 64 ? "sha256" : "sha1").update(`blob ${result.stdout.length}\0`).update(result.stdout).digest("hex"); } }); return inventory(); }; return { verify: verifyInventory, missingBytes: (files) => files.reduce((sum, file) => sum + checkoutBytes(entries.get(pathKey(file))), 0), assertComplete: () => { if (inventory().length) throw preserved("Checkout changed before destructive admission"); } }; } //#endregion //#region src/agents/worktrees/removal-recovery.ts const preserved = (reason) => /* @__PURE__ */ new Error(`${reason}; remaining source and original snapshot preserved`); /** CLI-only recovery: reconstitute a clean checkout without replacing any surviving file, * then let native non-force Git removal own deletion. Dirty/exact-state captures keep their * existing recovery owners; neither their index nor their snapshots can be reconstructed here. */ async function recoverManagedWorktreeRemoval(params, context) { return await withWorktreeAllocationLease({ ...params, env: context.env }, async (guard) => recoverRemovalWithAllocation({ ...params, ...guard, ...context })); } async function recoverRemovalWithAllocation(params) { if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(params.snapshot)) throw preserved("Expected a full snapshot commit id"); const record = getRegistryWorktree(params.env, params.id); if (!record || record.snapshotRef !== `refs/openclaw/snapshots/${params.id}`) throw preserved("Recovery requires an ordinary removal snapshot"); const repository = await resolveRepository(record.repoRoot); if (repository.fingerprint !== record.repoFingerprint || repository.repoRoot !== record.repoRoot) throw preserved("Worktree repository identity changed"); const pendingRef = `refs/openclaw/removals/${record.id}`; const removalRefs = [ record.snapshotRef, pendingRef, `refs/heads/${record.branch}` ]; const assertDirectRefFiles = () => { for (const ref of removalRefs) { const filename = path.join(repository.commonDir, ref); const info = fs.lstatSync(filename, { throwIfNoEntry: false }); if (info && (!info.isFile() || info.nlink !== 1 || fs.readFileSync(filename, "utf8").startsWith("ref:"))) throw preserved("Removal refs must remain direct refs"); } }; const assertDirectRefs = async (options) => { for (const ref of removalRefs) if ((await runGit(record.repoRoot, [ "symbolic-ref", "--quiet", ref ], options)).code !== 1) throw preserved("Removal refs must remain direct refs"); }; const retiredRegistration = record.removedAt !== void 0 && (await listGitWorktrees(record.repoRoot, { signal: params.signal, beforeRun: params.commitGuard, env: { GIT_NO_LAZY_FETCH: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_OPTIONAL_LOCKS: "0" } })).some((entry) => path.resolve(entry.path) === record.path); if (record.removedAt !== void 0 && !retiredRegistration) { const assertTerminal = () => { params.commitGuard?.(); assertDirectRefFiles(); if (JSON.stringify(getRegistryWorktree(params.env, record.id)) !== JSON.stringify(record)) throw preserved("Completed removal lifecycle changed"); }; const options = { signal: params.signal, beforeRun: assertTerminal, env: { GIT_NO_LAZY_FETCH: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_OPTIONAL_LOCKS: "0" } }; await assertDirectRefs(options); if (await rawPathStat(record.path) || (await listGitWorktrees(record.repoRoot, options)).some((entry) => path.resolve(entry.path) === record.path) || await requireGit(record.repoRoot, ["rev-parse", record.snapshotRef], options) !== params.snapshot) throw preserved("Completed removal identity changed"); const pending = await runGit(record.repoRoot, [ "rev-parse", "--verify", "--quiet", pendingRef ], options); const branch = await runGit(record.repoRoot, [ "rev-parse", "--verify", "--quiet", `refs/heads/${record.branch}` ], options); if (pending.code === 1) { if (branch.code !== 1) throw preserved("Completed removal branch changed"); } else { if (pending.code !== 0 || pending.stdout.trim() !== params.snapshot) throw preserved("Pending snapshot changed"); if (branch.code !== 1) { const head = await requireGit(record.repoRoot, ["rev-parse", `${params.snapshot}^`], options); if (branch.code !== 0 || branch.stdout.trim() !== head) throw preserved("Recorded branch changed"); const deletion = await prepareSnapshotBranchDeletion(record, record.snapshotRef, params.snapshot, options); await requireGit(record.repoRoot, [ "branch", "-d", "--", record.branch ], deletion); } await requireGit(record.repoRoot, ["update-ref", "--stdin"], { ...options, input: `option no-deref\nverify ${record.snapshotRef} ${params.snapshot}\noption no-deref\ndelete ${pendingRef} ${params.snapshot}\n` }); } return { removed: true, snapshotRef: record.snapshotRef }; } const token = randomUUID(); const assertRecord = () => { params.commitGuard?.(); params.signal?.throwIfAborted(); const current = getRegistryWorktree(params.env, record.id); if (JSON.stringify(current) !== JSON.stringify(record)) throw preserved("Worktree registry changed during recovery"); }; assertRecord(); claimWorktreeRemoval(params.env, { worktreeId: record.id, token, ...retiredRegistration ? { retiredRemoval: true } : {}, assertCurrent: assertRecord }); const assertCurrent = () => { assertRecord(); assertDirectRefFiles(); assertWorktreeRemovalClaim(params.env, record.id, token); }; const options = { signal: params.signal, beforeRun: assertCurrent, killProcessTree: true, env: { GIT_NO_LAZY_FETCH: "1", GIT_NO_REPLACE_OBJECTS: "1", GIT_OPTIONAL_LOCKS: "0" } }; try { const provisioned = await getRegistryWorktreeProvisionedPaths(params.env, record.id); if (!provisioned || provisioned.length) throw preserved("Provisioned-file recovery requires its original archive owner"); const { localWorkspaceStore } = await import("./local-workspace-store-zFU-VOM_.mjs"); if (localWorkspaceStore(params.env).get(record.id)) throw preserved("Projected worktree recovery requires its original archive owner"); const head = await requireGit(record.repoRoot, ["rev-parse", `${params.snapshot}^`], options); if (await requireGit(record.repoRoot, ["rev-parse", `${head}^{tree}`], options) !== await requireGit(record.repoRoot, ["rev-parse", `${params.snapshot}^{tree}`], options)) throw preserved("Recovery requires an unchanged captured tree"); const assertRefs = async (allowAbsentBranch = false) => { await assertDirectRefs(options); for (const ref of [record.snapshotRef, pendingRef]) if (await requireGit(record.repoRoot, [ "rev-parse", "--verify", ref ], options) !== params.snapshot) throw preserved("Removal snapshot ref changed"); const branch = await runGit(record.repoRoot, [ "rev-parse", "--verify", "--quiet", `refs/heads/${record.branch}` ], options); if (branch.code === 0 ? branch.stdout.trim() !== head : !allowAbsentBranch || branch.code !== 1) throw preserved("Recorded branch changed"); assertCurrent(); }; await assertRefs(!await rawPathStat(record.path)); const state = await lockState(record); if (state.kind === "live" || state.kind === "foreign") throw preserved("Worktree is locked or in use"); const registered = (await listGitWorktrees(record.repoRoot, options)).some((entry) => path.resolve(entry.path) === record.path); const originalRoot = await rawPathStat(record.path); if (retiredRegistration && originalRoot) throw preserved("Retired checkout path reappeared"); let admin; if (registered) { const adminRoot = path.join(repository.commonDir, "worktrees"); for (const name of await fs$1.readdir(adminRoot)) { const candidate = path.join(adminRoot, name); if (!(await rawPathStat(candidate))?.isDirectory()) continue; const backlink = await fs$1.readFile(path.join(candidate, "gitdir"), "utf8").catch(() => ""); if (path.resolve(candidate, normalizeGitPathForFilesystem(backlink.trim())) === path.join(record.path, ".git")) { if (admin) throw preserved("Ambiguous worktree registration"); admin = candidate; } } if (!admin) throw preserved("Original worktree administration is unavailable"); } if (originalRoot && (!originalRoot.isDirectory() || !admin || await fs$1.realpath(record.path) !== record.path)) throw preserved("Worktree path identity is unavailable"); let assertAdmin = () => {}; if (admin) { const gitdir = admin; const adminIdentity = await fs$1.lstat(gitdir); const indexOptions = { ...options, env: { ...options.env, GIT_DIR: gitdir, GIT_INDEX_FILE: path.join(gitdir, "index") } }; const sharedIndex = await requireGit(record.repoRoot, ["rev-parse", "--shared-index-path"], indexOptions); const metadataNames = [ "HEAD", "gitdir", "commondir", "index" ]; if (sharedIndex) { const shared = path.resolve(record.repoRoot, normalizeGitPathForFilesystem(sharedIndex)); if (path.dirname(shared) !== gitdir || !/^sharedindex\.[a-f0-9]+$/u.test(path.basename(shared))) throw preserved("Original split index identity changed"); metadataNames.push(path.basename(shared)); } const worktreeConfig = path.join(gitdir, "config.worktree"); const hadWorktreeConfig = Boolean(await rawPathStat(worktreeConfig)); if (hadWorktreeConfig) metadataNames.push("config.worktree"); const metadata = await Promise.all(metadataNames.map(async (name) => { const filename = path.join(gitdir, name); const identity = await fs$1.lstat(filename); if (!identity.isFile() || identity.nlink !== 1) throw preserved("Original Git metadata is aliased"); return { filename, identity, bytes: await fs$1.readFile(filename) }; })); assertAdmin = () => { assertCurrent(); if (!hadWorktreeConfig && fs.lstatSync(worktreeConfig, { throwIfNoEntry: false })) throw preserved("Original Git configuration changed"); const currentAdmin = fs.lstatSync(gitdir); if (currentAdmin.dev !== adminIdentity.dev || currentAdmin.ino !== adminIdentity.ino) throw preserved("Worktree administration identity changed"); for (const saved of metadata) { const current = fs.lstatSync(saved.filename); if (current.dev !== saved.identity.dev || current.ino !== saved.identity.ino || current.mode !== saved.identity.mode || current.nlink !== saved.identity.nlink || !fs.readFileSync(saved.filename).equals(saved.bytes)) throw preserved("Original Git metadata changed"); } assertCurrent(); }; if (await fs$1.realpath(gitdir) !== gitdir || path.resolve(gitdir, normalizeGitPathForFilesystem((await fs$1.readFile(path.join(gitdir, "commondir"), "utf8")).trim())) !== repository.commonDir || (await fs$1.readFile(path.join(gitdir, "HEAD"), "utf8")).trim() !== `ref: refs/heads/${record.branch}`) throw preserved("Original Git metadata identity changed"); const indexed = await requireGitBuffer(record.repoRoot, [ "ls-files", "--stage", "-v", "-z" ], indexOptions); if (splitNullBuffer(indexed).some((entry) => entry[0] !== 72)) throw preserved("Original index has hidden or unmerged entries"); if ((await runGit(record.repoRoot, [ "diff-index", "--cached", "--quiet", params.snapshot, "--" ], indexOptions)).code !== 0) throw preserved("Original index differs from the capture"); } if (originalRoot && admin) { const gitdir = admin; const gitfile = path.join(record.path, ".git"); const assertIdentity = () => { assertCurrent(); assertAdmin(); const current = fs.lstatSync(record.path, { throwIfNoEntry: false }); if (current?.dev !== originalRoot.dev || current.ino !== originalRoot.ino) throw preserved("Checkout or original index changed"); const marker = fs.lstatSync(gitfile, { throwIfNoEntry: false }); if (marker) { if (!marker.isFile() || marker.nlink !== 1) throw preserved("Checkout Git link changed"); const text = fs.readFileSync(gitfile, "utf8").trim(); const target = text.startsWith("gitdir: ") ? path.resolve(record.path, normalizeGitPathForFilesystem(text.slice(8))) : ""; if (!(process.platform === "win32" ? target.toLowerCase() === gitdir.toLowerCase() : target === gitdir)) throw preserved("Checkout Git link changed"); } }; const inventory = await createRemovalRecoveryInventory({ record, snapshot: params.snapshot, gitdir, options, assertIdentity, refuse: preserved }); const missing = await inventory.verify(); await assertRefs(); requireWorktreeDiskSpace([{ path: record.path, bytes: inventory.missingBytes(missing) }], "interrupted removal recovery"); if (!await rawPathStat(gitfile)) { assertIdentity(); await fs$1.writeFile(gitfile, `gitdir: ${gitdir}\n`, { flag: "wx", mode: 384 }); } if (state.kind === "dead") await requireGit(record.repoRoot, [ "worktree", "unlock", record.path ], options); if (missing.length) await withWorktreeGitConfig(record.path, true, { ...options, beforeRun: assertIdentity }, async (git) => { await git.require(record.path, [ `--attr-source=${params.snapshot}`, "checkout-index", "--stdin", "-z" ], { ...options, beforeRun: assertIdentity, input: Buffer.from(`${missing.join("\0")}\0`) }); }); await withExactStateGitLocks(record, assertIdentity, async () => { if ((await inventory.verify()).length) throw preserved("Reconstruction is incomplete"); await assertRefs(); assertIdentity(); await withWorktreeGitConfig(record.path, true, options, async (git) => await removeManagedCheckout(record, git, true, inventory.assertComplete)); }, [record.snapshotRef, pendingRef]); } else if (registered) { await assertRefs(); assertAdmin(); await withWorktreeGitConfig(record.repoRoot, true, options, async (git) => await runOutsideCommandProcessScope(() => git.require(record.repoRoot, [ "worktree", "remove", "--", record.path ], { beforeRun: () => { assertCurrent(); assertAdmin(); if (fs.lstatSync(record.path, { throwIfNoEntry: false })) throw preserved("Missing checkout reappeared before destructive admission"); }, killProcessTree: true, waitForExit: true }))); } await assertRefs(true); if (await rawPathStat(record.path) || (await listGitWorktrees(record.repoRoot, options)).some((entry) => path.resolve(entry.path) === record.path)) throw preserved("Checkout removal did not finish"); const branch = await runGit(record.repoRoot, [ "show-ref", "--verify", "--quiet", `refs/heads/${record.branch}` ], options); if (branch.code === 0) { const deletion = await prepareSnapshotBranchDeletion(record, record.snapshotRef, params.snapshot, options); await assertRefs(); await requireGit(record.repoRoot, [ "branch", "-d", "--", record.branch ], deletion); } else if (branch.code !== 1) throw commandError("git show-ref", branch); await assertRefs(true); assertCurrent(); updateRegistryWorktree(params.env, record.id, { removedAt: record.removedAt ?? params.now() }, { assertCurrent, removalToken: token }); const finalized = JSON.stringify(getRegistryWorktree(params.env, record.id)); await requireGit(record.repoRoot, ["update-ref", "--stdin"], { beforeRun: () => { params.commitGuard?.(); assertDirectRefFiles(); assertWorktreeRemovalClaim(params.env, record.id, token); if (JSON.stringify(getRegistryWorktree(params.env, record.id)) !== finalized) throw preserved("Completed removal lifecycle changed"); }, env: options.env, input: `option no-deref\nverify ${record.snapshotRef} ${params.snapshot}\noption no-deref\ndelete ${pendingRef} ${params.snapshot}\n` }); await fs$1.rmdir(path.dirname(record.path)).catch(() => void 0); return { removed: true, snapshotRef: record.snapshotRef }; } finally { abortWorktreeRemovalRow(params.env, record.id, token); } } //#endregion export { recoverManagedWorktreeRemoval };