// Per-turn workspace checkpoints: a shadow git repository per bot+folder. // The user's own .git is never read, written or locked: every git call sets GIT_DIR to the shadow repo and GIT_WORK_TREE to the folder. // Best-effort: nothing here throws into the turn path; a git failure turns checkpoints off for that bot, a timeout for that folder. // // Adapted from the checkpoint designs of Cline, Roo-Code, and Gemini CLI // (all Apache-2.0): the per-call GIT_DIR/GIT_WORK_TREE/GIT_CONFIG_* env // override follows Gemini CLI's gitService, the sanitized GIT_* env list and // the exclude categories follow Roo-Code's checkpoint service, and the // snapshot-before-every-turn cadence follows Cline. import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { isAbsolute, join, parse, resolve } from "node:path"; import { DATA_DIR } from "./config.ts"; export const CHECKPOINTS_DIR = join(DATA_DIR, "checkpoints"); /** Full sha1 hex only, so no revspec ("HEAD~3", "main@{u}") reaches git. */ const COMMIT_HASH = /^[0-9a-f]{40}$/; // What a checkpoint never carries, so a digest never lists it; categories follow Roo-Code's checkpoint excludes. const EXCLUDES = `# OpenMausBot checkpoint excludes — never snapshotted .git/ .svn/ .hg/ .openmausbot-secrets/ node_modules/ bower_components/ .pnpm-store/ .venv/ venv/ .direnv/ __pycache__/ .pytest_cache/ .mypy_cache/ .ruff_cache/ .tox/ .gradle/ Pods/ dist/ build/ out/ .next/ .nuxt/ .output/ .svelte-kit/ target/ coverage/ .cache/ .parcel-cache/ .turbo/ .vite/ .terraform/ *.log logs/ *.tmp *.swp .DS_Store Thumbs.db *.env* .env .env.* *.pem *.key *.jpg *.jpeg *.png *.gif *.bmp *.tiff *.webp *.ico *.icns *.psd *.mp3 *.wav *.flac *.ogg *.mp4 *.mov *.avi *.mkv *.webm *.zip *.tar *.gz *.tgz *.bz2 *.xz *.7z *.rar *.jar *.iso *.dmg *.sqlite *.sqlite3 *.db *.parquet *.onnx *.safetensors *.gguf `; // gpgsign off so a user's global signing config never blocks on a passphrase; auto GC off because GC runs in the per-folder queue. const GITCONFIG = "[commit]\n\tgpgsign = false\n[core]\n\tautocrlf = false\n[gc]\n\tauto = 0\n"; const GIT_TIMEOUT_MS = 120_000; export const GC_EVERY_COMMITS = 25; const GC_LOOSE_OBJECTS = 6_700; const GC_NEW_DATA_KIB = 262_144; const disabledBots = new Set(); function disable(botId: string, message: string): void { disabledBots.add(botId); console.warn(`workspace checkpoints disabled for bot ${botId} this session: ${message}`); } class GitTimeoutError extends Error {} const disabledFolders = new Set(); function disableFolder(shadow: string, botId: string, worktree: string, message: string): void { if (disabledFolders.has(shadow)) return; disabledFolders.add(shadow); console.warn(`workspace checkpoints disabled for bot ${botId} in ${worktree} this session: ${message}`); } function folderShadow(botId: string, cwd: string): { worktree: string; shadow: string } | null { try { const worktree = realpathSync(resolve(cwd)); return { worktree, shadow: shadowDir(botId, worktree) }; } catch { return null; } } let gitProbe: Promise | null = null; function gitAvailable(): Promise { gitProbe ??= new Promise((resolveProbe) => { execFile("git", ["--version"], { windowsHide: true, timeout: 5_000 }, (err) => resolveProbe(!err)); }); return gitProbe; } /** Never home, Desktop, Documents, Downloads or the root: that would trawl unbounded personal data into a repo. */ export function refusalReason(cwd: string): string | null { if (!isAbsolute(cwd)) return "the working folder must be an absolute path"; const requested = resolve(cwd); let stat; let dir: string; try { stat = statSync(requested); dir = realpathSync.native(requested); } catch { return "the working folder does not exist"; } if (!stat.isDirectory()) return "the working folder is not a folder"; // Canonical paths too: a symlink to $HOME would bypass the refusal, and Windows has 8.3 spellings and case-insensitive names. const same = (a: string, b: string) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b; if (dir === parse(dir).root) return "checkpoints are not taken at the filesystem root"; const requestedHome = resolve(homedir()); const home = existsSync(requestedHome) ? realpathSync.native(requestedHome) : requestedHome; if (same(requested, requestedHome) || same(dir, home)) return "checkpoints are not taken in the home folder"; for (const name of ["Desktop", "Documents", "Downloads"]) { const requestedProtected = join(requestedHome, name); const protectedDir = existsSync(requestedProtected) ? realpathSync.native(requestedProtected) : requestedProtected; if (same(requested, requestedProtected) || same(dir, protectedDir)) { return `checkpoints are not taken in the ${name} folder`; } } return null; } function shadowDir(botId: string, cwd: string): string { const key = createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 16); return join(CHECKPOINTS_DIR, botId, key); } function gitEnv(shadow: string, cwd: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; // Git honours redirection env beyond GIT_DIR (GIT_COMMON_DIR, GIT_CONFIG_KEY_*), so the whole GIT_ namespace is cleared. for (const name of Object.keys(env)) { if (name.startsWith("GIT_")) delete env[name]; } env.GIT_DIR = join(shadow, ".git"); env.GIT_WORK_TREE = resolve(cwd); env.GIT_CONFIG_GLOBAL = join(shadow, "gitconfig"); env.GIT_CONFIG_SYSTEM = join(shadow, "gitconfig_empty"); env.GIT_AUTHOR_NAME = "OpenMausBot Checkpoint"; env.GIT_AUTHOR_EMAIL = "checkpoint@openmausbot.local"; env.GIT_COMMITTER_NAME = "OpenMausBot Checkpoint"; env.GIT_COMMITTER_EMAIL = "checkpoint@openmausbot.local"; return env; } /** execFile reports its own timeout as a killed child; a `signal` cancellation arrives as an AbortError instead. */ function gitFailure(command: string, err: Error & { killed?: boolean }, stderr: string, signal?: AbortSignal): Error { if (err.killed && err.name !== "AbortError" && !signal?.aborted) { return new GitTimeoutError(`git ${command} timed out after ${GIT_TIMEOUT_MS / 1000} s`); } return new Error(`git ${command}: ${(stderr || err.message).trim().slice(0, 400)}`); } function runGit(args: string[], cwd: string, env: NodeJS.ProcessEnv, signal?: AbortSignal, input?: string): Promise { return new Promise((resolvePromise, rejectPromise) => { signal?.throwIfAborted(); const child = execFile( "git", args, { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { if (err) rejectPromise(gitFailure(args[0]!, err, stderr, signal)); else resolvePromise(stdout); }, ); if (input !== undefined) { // A git that exits before reading stdin must not surface as an unhandled EPIPE. child.stdin?.on("error", () => {}); child.stdin?.end(input); } }); } // One operation at a time per shadow repo (git's index lock would fail the loser); the stored tail never rejects. const chains = new Map>(); function serialize(key: string, fn: () => Promise): Promise { const tail = chains.get(key) ?? Promise.resolve(); const run = tail.then(fn); chains.set( key, run.then( () => undefined, () => undefined, ), ); return run; } // A pin keeps a running turn's pre-turn commit reachable until release(): a collection after a sibling's snapshot would discard it. const LIVE_REFS = "refs/omb-live/"; function liveRef(pin: string): string { return LIVE_REFS + createHash("sha256").update(pin).digest("hex").slice(0, 16); } // Pins outlive a crashed process, so the first use of a shadow in this process drops every leftover pin. const sweptShadows = new Set(); const droppedPins = new Set(); async function sweepLiveRefs(cwd: string, env: NodeJS.ProcessEnv, shadow: string, signal?: AbortSignal): Promise { if (sweptShadows.has(shadow)) return; const stale = (await runGit(["for-each-ref", "--format=%(refname)", LIVE_REFS], cwd, env, signal)).split("\n").filter(Boolean); for (const ref of stale) await runGit(["update-ref", "-d", ref], cwd, env, signal); if (stale.length > 0) droppedPins.add(shadow); sweptShadows.add(shadow); } /** `base`: the empty marker HEAD was made on, or null for legacy multi-commit history the next snapshot compacts. */ type Head = { hash: string; tree: string; base: string | null }; async function readHead(cwd: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { const out = await runGit(["log", "-2", "--format=%H %T %P", "HEAD"], cwd, env, signal); const [head, parent] = out.split("\n").filter(Boolean).map((line) => line.trim().split(" ")); const [hash, tree, ...parents] = head ?? []; if (!hash || !tree) throw new Error(`git log: unexpected output ${JSON.stringify(out.slice(0, 200))}`); let base: string | null = null; if (parents.length === 0) base = hash; else if (parents.length === 1 && parent?.length === 2) base = parents[0]!; return { hash, tree, base }; } /** Rewrites its config files on every use so exclude-list updates reach existing shadows. */ async function ensureShadow(cwd: string, env: NodeJS.ProcessEnv, shadow: string, signal?: AbortSignal): Promise { signal?.throwIfAborted(); mkdirSync(shadow, { recursive: true, mode: 0o700 }); writeFileSync(join(shadow, "gitconfig"), GITCONFIG, { mode: 0o600 }); writeFileSync(join(shadow, "gitconfig_empty"), "", { mode: 0o600 }); if (!existsSync(join(shadow, ".git", "HEAD"))) { // --template= keeps the user's init.templateDir hooks/config out await runGit(["init", "--initial-branch=main", "--template="], cwd, env, signal); } mkdirSync(join(shadow, ".git", "info"), { recursive: true }); writeFileSync(join(shadow, ".git", "info", "exclude"), EXCLUDES); // Cargo and other tools put caches outside target/ or build/; honour the standard CACHEDIR.TAG. const tags = (await runGit(["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "**/CACHEDIR.TAG"], cwd, env, signal)).split("\0"); const cachePatterns = new Set(); for (const tag of tags) { if (!tag.includes("/") || /[\r\n]/.test(tag)) continue; try { const path = join(cwd, tag); const stat = lstatSync(path); if (!stat.isFile() || stat.size > 4096) continue; if (!readFileSync(path, "utf8").startsWith("Signature: 8a477f597d28d172789f06886806bc55")) continue; const directory = tag.slice(0, -"CACHEDIR.TAG".length); cachePatterns.add("/" + directory.replace(/[\\*?[\]#! ]/g, "\\$&")); } catch { } } writeFileSync(join(shadow, ".git", "info", "exclude"), EXCLUDES + [...cachePatterns].join("\n") + "\n"); let head: Head; try { head = await readHead(cwd, env, signal); } catch { await runGit(["commit", "--no-verify", "--allow-empty", "-m", "checkpoint base"], cwd, env, signal); head = await readHead(cwd, env, signal); } await sweepLiveRefs(cwd, env, shadow, signal); return head; } type FolderNames = { exact: Set; folded: Set }; /** Folder listings in flight at once (libuv's pool has four threads). */ const LISTING_CONCURRENCY = 4; /** On case-insensitive file systems git sets core.ignorecase, so an incremental `add -A` keeps a case-renamed path's old * spelling forever. Names compare in NFC: git stores precomposed paths on macOS, readdir returns them as created. */ async function caseRenamed(cwd: string, paths: readonly string[]): Promise { if (paths.length === 0) return false; const pending = new Set([""]); for (const path of paths) { for (let slash = path.indexOf("/"); slash > 0; slash = path.indexOf("/", slash + 1)) pending.add(path.slice(0, slash)); } const listings = new Map(); const queue = [...pending]; const read = async (): Promise => { for (let dir = queue.pop(); dir !== undefined; dir = queue.pop()) { try { const exact = new Set((await readdir(join(cwd, dir))).map((name) => name.normalize("NFC"))); listings.set(dir, { exact, folded: new Set([...exact].map((name) => name.toLowerCase())) }); } catch { listings.set(dir, null); } } }; await Promise.all(Array.from({ length: Math.min(LISTING_CONCURRENCY, queue.length) }, read)); const folders = new Map(); const renamed = (path: string, isFolder: boolean): boolean => { const known = isFolder ? folders.get(path) : undefined; if (known !== undefined) return known; const slash = path.lastIndexOf("/"); const parent = slash < 0 ? "" : path.slice(0, slash); const name = path.slice(slash + 1).normalize("NFC"); const names = listings.get(parent) ?? null; let verdict: boolean; if (names === null) verdict = false; else if (!names.exact.has(name)) verdict = names.folded.has(name.toLowerCase()); else verdict = parent !== "" && renamed(parent, true); if (isFolder) folders.set(path, verdict); return verdict; }; return paths.some((path) => renamed(path, false)); } /** Would an incremental `git add -A` differ from a rebuilt index? Only when a tracked path became ignored or cache-tagged, * a folder became or stopped being a nested repository, or a path was renamed by case only. */ async function indexNeedsRebuild(cwd: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { const list = (args: string[]) => runGit(args, cwd, env, signal).catch((e: unknown) => { if (e instanceof GitTimeoutError || signal?.aborted) throw e; return null; }); const ignored = await list(["ls-files", "--cached", "--ignored", "--exclude-standard", "-z"]); if (ignored === null || ignored.length > 0) return true; const staged = await list(["ls-files", "--stage", "-z"]); if (staged === null) return true; const paths: string[] = []; const parents = new Set(); for (const entry of staged.split("\0")) { const tab = entry.indexOf("\t"); if (tab < 0) continue; const path = entry.slice(tab + 1); paths.push(path); if (entry.startsWith("160000 ")) { if (!existsSync(join(cwd, path, ".git"))) return true; continue; } for (let slash = path.indexOf("/"); slash > 0; slash = path.indexOf("/", slash + 1)) parents.add(path.slice(0, slash)); } for (const dir of parents) if (existsSync(join(cwd, dir, ".git"))) return true; return (process.platform === "darwin" || process.platform === "win32") && (await caseRenamed(cwd, paths)); } async function stageWorkTree(cwd: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { if (await indexNeedsRebuild(cwd, env, signal)) await runGit(["read-tree", "--empty"], cwd, env, signal); await runGit(["add", "-A", "--ignore-errors", "."], cwd, env, signal); } type CommitResult = { hash: string; advanced: boolean; compacted: boolean }; /** A partial capture never replaces the last usable checkpoint; with `pin`, the pin lands in the ref transaction that moves HEAD. */ async function commitAll(cwd: string, env: NodeJS.ProcessEnv, head: Head, label: string, pin?: string, signal?: AbortSignal): Promise { try { await stageWorkTree(cwd, env, signal); } catch (e) { if (e instanceof GitTimeoutError) throw e; return null; } const tree = (await runGit(["write-tree"], cwd, env, signal)).trim(); const changed = tree !== head.tree; let hash = head.hash; if (changed || head.base === null) { const base = head.base ?? (await runGit(["rev-list", "--max-parents=0", "HEAD"], cwd, env, signal)).trim(); const message = changed ? label : (await runGit(["show", "-s", "--format=%B", "HEAD"], cwd, env, signal)).trim(); // Parent only the empty marker: keeping the previous commit as an ancestor would retain every old tree. hash = (await runGit(["commit-tree", tree, "-p", base, "-m", message], cwd, env, signal)).trim(); } const advanced = hash !== head.hash; const updates = [advanced ? `update HEAD ${hash} ${head.hash}\n` : "", pin ? `update ${liveRef(pin)} ${hash}\n` : ""].join(""); if (updates !== "") await runGit(["update-ref", "--stdin"], cwd, env, signal, updates); return { hash, advanced, compacted: advanced && head.base === null }; } const commitsSinceGc = new Map(); /** Packs grow between collections because `git add` writes files over core.bigFileThreshold straight into packs. */ const packBaselineKib = new Map(); /** Held until that turn's settled diff is queued, or until the next snapshot, which runs it first. */ const pendingCollections = new Map Promise>(); function releaseCollection(shadow: string): void { const collect = pendingCollections.get(shadow); if (collect === undefined) return; pendingCollections.delete(shadow); void serialize(shadow, collect); } async function collectionDue(shadow: string, result: CommitResult, cwd: string, env: NodeJS.ProcessEnv, signal?: AbortSignal): Promise { const freed = droppedPins.delete(shadow) || result.compacted; if (!result.advanced && !freed) return false; const commits = (commitsSinceGc.get(shadow) ?? 0) + (result.advanced ? 1 : 0); commitsSinceGc.set(shadow, commits); let due = freed || commits >= GC_EVERY_COMMITS; if (!due) { const counted = await runGit(["count-objects", "-v"], cwd, env, signal).catch(() => ""); if (counted !== "") { const field = (name: string) => Number(new RegExp(`^${name}: (\\d+)$`, "m").exec(counted)?.[1] ?? 0); const pack = field("size-pack"); if (!packBaselineKib.has(shadow)) packBaselineKib.set(shadow, pack); const newData = field("size") + Math.max(0, pack - packBaselineKib.get(shadow)!); due = field("count") >= GC_LOOSE_OBJECTS || newData >= GC_NEW_DATA_KIB; } } if (due) { commitsSinceGc.set(shadow, 0); packBaselineKib.delete(shadow); } return due; } async function collectObsolete(cwd: string, env: NodeJS.ProcessEnv): Promise { try { await runGit(["reflog", "expire", "--expire=now", "--all"], cwd, env); await runGit(["gc", "--prune=now", "--quiet"], cwd, env); } catch { console.warn("checkpoint cleanup deferred; the latest snapshot is preserved"); } } /** Never throws. With `pin`, the commit stays reachable through later snapshots until release(pin). */ export async function snapshot(botId: string, cwd: string, label: string, signal?: AbortSignal, opts?: { pin?: string }): Promise { if (signal?.aborted) return null; if (disabledBots.has(botId)) return null; if (!(await gitAvailable())) return null; if (refusalReason(cwd) !== null) return null; const folder = folderShadow(botId, cwd); if (folder === null || disabledFolders.has(folder.shadow)) return null; const { worktree, shadow } = folder; try { // Collect ahead of this snapshot, so this turn's diff never queues behind GC. releaseCollection(shadow); return await serialize(shadow, async () => { const env = gitEnv(shadow, worktree); const head = await ensureShadow(worktree, env, shadow, signal); const result = await commitAll(worktree, env, head, label, opts?.pin, signal); if (!result) return null; // Held for this turn's settled diff, so GC never eats into the digest's capture window. if (await collectionDue(shadow, result, worktree, env, signal)) { pendingCollections.set(shadow, () => collectObsolete(worktree, env)); } return result.hash; }); } catch (e) { if (signal?.aborted) return null; const message = e instanceof Error ? e.message : String(e); if (e instanceof GitTimeoutError) disableFolder(shadow, botId, worktree, message); else disable(botId, message); return null; } } /** Queued behind the shadow's pending operations, so a diff already queued still finds its commit. */ export async function release(botId: string, cwd: string, pin: string): Promise { if (!(await gitAvailable())) return; try { const worktree = realpathSync(resolve(cwd)); const shadow = shadowDir(botId, worktree); if (!existsSync(join(shadow, ".git", "HEAD"))) return; const env = gitEnv(shadow, worktree); await serialize(shadow, () => runGit(["update-ref", "-d", liveRef(pin)], worktree, env)); } catch (e) { console.warn(`checkpoint pin not released for bot ${botId}: ${e instanceof Error ? e.message : String(e)}`); } } export interface CheckpointDiff { changed: string[]; added: string[]; deleted: string[]; } export async function diffWorkingTree(botId: string, cwd: string, fromHash: string, signal?: AbortSignal): Promise { if (signal?.aborted || !COMMIT_HASH.test(fromHash)) return null; if (disabledBots.has(botId) || !(await gitAvailable()) || refusalReason(cwd) !== null) return null; const folder = folderShadow(botId, cwd); if (folder === null || disabledFolders.has(folder.shadow)) return null; const { worktree, shadow } = folder; try { const env = gitEnv(shadow, worktree); const run = serialize(shadow, async () => { await ensureShadow(worktree, env, shadow, signal); await stageWorkTree(worktree, env, signal); const out = await runGit(["diff", "--cached", "--name-status", "--no-renames", "-z", fromHash], worktree, env, signal); const diff: CheckpointDiff = { changed: [], added: [], deleted: [] }; const fields = out.split("\0").filter((field) => field.length > 0); for (let i = 0; i + 1 < fields.length; i += 2) { const status = fields[i]!; const path = fields[i + 1]!; if (status.startsWith("A")) diff.added.push(path); else if (status.startsWith("D")) diff.deleted.push(path); else diff.changed.push(path); } return diff; }); // The collection this turn's snapshot left pending runs behind this diff. releaseCollection(shadow); return await run; } catch (e) { if (e instanceof GitTimeoutError && !signal?.aborted) disableFolder(shadow, botId, worktree, e.message); return null; } }