Ctrl K
ComunidadGuías…

server/checkpoints.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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<string>();

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<string>();

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<boolean> | null = null;
function gitAvailable(): Promise<boolean> {
  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<string> {
  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<string, Promise<void>>();
function serialize<T>(key: string, fn: () => Promise<T>): Promise<T> {
  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<string>();
const droppedPins = new Set<string>();
async function sweepLiveRefs(cwd: string, env: NodeJS.ProcessEnv, shadow: string, signal?: AbortSignal): Promise<void> {
  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<Head> {
  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<Head> {
  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<string>();
  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<string>; folded: Set<string> };

/** 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<boolean> {
  if (paths.length === 0) return false;
  const pending = new Set<string>([""]);
  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<string, FolderNames | null>();
  const queue = [...pending];
  const read = async (): Promise<void> => {
    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<string, boolean>();
  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<boolean> {
  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<string>();
  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<void> {
  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<CommitResult | null> {
  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<string, number>();

/** Packs grow between collections because `git add` writes files over core.bigFileThreshold straight into packs. */
const packBaselineKib = new Map<string, number>();

/** Held until that turn's settled diff is queued, or until the next snapshot, which runs it first. */
const pendingCollections = new Map<string, () => Promise<void>>();

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<boolean> {
  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<void> {
  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<string | null> {
  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<void> {
  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<CheckpointDiff | null> {
  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;
  }
}