202 lines · 6.8 KB · TypeScript
RawDownload
import { mkdtemp, rm, writeFile } from "node:fs/promises";import os from "node:os";import path from "node:path";import { randomUUID } from "node:crypto"; import { runProcess } from "./processRunner"; const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";const DEFAULT_TIMEOUT_MS = 30_000;const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; function quoteGitCommand(args: readonly string[]): string {  return `git ${args.join(" ")}`;} function checkpointRefThreadSegment(threadId: string): string {  return Buffer.from(threadId, "utf8").toString("base64url");} export function checkpointRefForThreadTurn(threadId: string, turnCount: number): string {  if (!Number.isInteger(turnCount) || turnCount < 0) {    throw new Error(`Invalid checkpoint turn count: ${turnCount}`);  }  return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`;} interface GitOptions {  env?: NodeJS.ProcessEnv;  stdin?: string;  allowNonZeroExit?: boolean;} export class FilesystemCheckpointStore {  async isGitRepository(cwd: string): Promise<boolean> {    const result = await this.runGit(cwd, ["rev-parse", "--is-inside-work-tree"], {      allowNonZeroExit: true,    });    return result.code === 0 && result.stdout.trim() === "true";  }   async captureCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<void> {    const { cwd, threadId, turnCount } = input;    const ref = checkpointRefForThreadTurn(threadId, turnCount);     const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-"));    const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`);    const commitEnv: NodeJS.ProcessEnv = {      ...process.env,      GIT_INDEX_FILE: tempIndexPath,      GIT_AUTHOR_NAME: "T3 Code",      GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com",      GIT_COMMITTER_NAME: "T3 Code",      GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com",    };     try {      await writeFile(tempIndexPath, "");       const hasHead = await this.hasHeadCommit(cwd);      if (hasHead) {        await this.runGit(cwd, ["read-tree", "HEAD"], { env: commitEnv });      }       await this.runGit(cwd, ["add", "-A", "--", "."], { env: commitEnv });      const writeTreeResult = await this.runGit(cwd, ["write-tree"], { env: commitEnv });      const treeOid = writeTreeResult.stdout.trim();      if (!treeOid) {        throw new Error("git write-tree returned an empty tree oid.");      }       const message = `t3 checkpoint thread=${threadId} turn=${turnCount}`;      const commitTreeResult = await this.runGit(cwd, ["commit-tree", treeOid], {        env: commitEnv,        stdin: `${message}\n`,      });      const commitOid = commitTreeResult.stdout.trim();      if (!commitOid) {        throw new Error("git commit-tree returned an empty commit oid.");      }       await this.runGit(cwd, ["update-ref", ref, commitOid]);    } finally {      await rm(tempDir, { recursive: true, force: true });    }  }   async hasCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> {    const { cwd, threadId, turnCount } = input;    const ref = checkpointRefForThreadTurn(threadId, turnCount);    const commit = await this.resolveCheckpointCommit(cwd, ref);    return commit !== null;  }   async restoreCheckpoint(input: { cwd: string; threadId: string; turnCount: number }): Promise<boolean> {    const { cwd, threadId, turnCount } = input;    const ref = checkpointRefForThreadTurn(threadId, turnCount);    const commitOid = await this.resolveCheckpointCommit(cwd, ref);    if (!commitOid) {      return false;    }     await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]);    await this.runGit(cwd, ["clean", "-fd", "--", "."]);    await this.runGit(cwd, ["reset", "--quiet", "--", "."], { allowNonZeroExit: true });    return true;  }   async pruneAfterTurn(input: { cwd: string; threadId: string; maxTurnCount: number }): Promise<void> {    const { cwd, threadId, maxTurnCount } = input;    if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) {      throw new Error(`Invalid max turn count: ${maxTurnCount}`);    }     const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`;    const result = await this.runGit(cwd, ["for-each-ref", "--format=%(refname)", threadRefPrefix], {      allowNonZeroExit: true,    });    if (result.code !== 0) {      return;    }     const refsToDelete = result.stdout      .split(/\r?\n/g)      .map((line) => line.trim())      .filter((line) => line.length > 0)      .filter((refName) => {        const turn = this.readRefTurnCount(refName);        return turn !== null && turn > maxTurnCount;      });     await Promise.all(      refsToDelete.map((refName) =>        this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }),      ),    );  }   private async hasHeadCommit(cwd: string): Promise<boolean> {    const result = await this.runGit(cwd, ["rev-parse", "--verify", "HEAD"], {      allowNonZeroExit: true,    });    return result.code === 0;  }   private async resolveCheckpointCommit(cwd: string, ref: string): Promise<string | null> {    const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {      allowNonZeroExit: true,    });    if (result.code !== 0) {      return null;    }    const commit = result.stdout.trim();    return commit.length > 0 ? commit : null;  }   private readRefTurnCount(ref: string): number | null {    const segments = ref.split("/");    const rawTurn = segments.at(-1);    if (!rawTurn) {      return null;    }    const turn = Number.parseInt(rawTurn, 10);    return Number.isInteger(turn) && turn >= 0 ? turn : null;  }   private async runGit(    cwd: string,    args: readonly string[],    options: GitOptions = {},  ): Promise<Awaited<ReturnType<typeof runProcess>>> {    let result: Awaited<ReturnType<typeof runProcess>>;    try {      result = await runProcess("git", args, {        cwd,        env: options.env,        stdin: options.stdin,        timeoutMs: DEFAULT_TIMEOUT_MS,        allowNonZeroExit: true,        maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES,        outputMode: "truncate",      });    } catch (error) {      const message = error instanceof Error ? error.message : String(error);      throw new Error(`Failed to run ${quoteGitCommand(args)}: ${message}`, { cause: error });    }     if (result.timedOut) {      throw new Error(`${quoteGitCommand(args)} timed out.`);    }     if (!options.allowNonZeroExit && result.code !== 0) {      const stderr = result.stderr.trim();      throw new Error(        stderr.length > 0          ? `${quoteGitCommand(args)} failed: ${stderr}`          : `${quoteGitCommand(args)} failed with code ${result.code ?? "null"}.`,      );    }     return result;  }}