SPB Git

Repository Public

setup.ts 6.5 KB 128 lines typescript

1/**2 * The three journaled setup steps of a run: mission facts (`run_meta`), the frozen prefix (`prefix`,3 * which also pins `runs.prompt_hash`) and the mission git branch (`git_branch`).4 */5import fs from 'node:fs/promises';6import type { RunContext } from '@aos/kernel';7import { Budget, DoneCriterion, Effort, Provider } from '@aos/shared';8import { resolveInWorkspace, runGit, type Tool } from '@aos/tools';9import { z } from 'zod';10import { dbQuery } from './db.js';11import type { ResolvedDeps } from './deps.js';12import { buildSystemPrompt, promptHash, selectTools, toolDefs, type GitStart, type PrefixInput } from './prompt.js';13import type { RunMeta } from './state.js';1415const MetaRow = z.object({16  provider: Provider,17  model: z.string().min(1),18  effort: Effort.nullable(),19  budget: Budget,20  owner_id: z.string(),21  project_id: z.string().nullable(),22  network: z.enum(['none', 'open']),23  done_criteria: z.array(DoneCriterion),24  project_notes: z.string().nullable(),25});2627/** Facts the RunContext does not carry (model, budget, owner, project, network, initial criteria). Journaled. */28export async function readRunMeta(ctx: RunContext, deps: ResolvedDeps): Promise<RunMeta> {29  return ctx.step<Record<string, never>, RunMeta>({ kind: 'setup', name: 'run_meta', effect: 'read', request: {} }, async () => {30    const r = await dbQuery(31      deps.kernel,32      `SELECT r.provider, r.model, r.effort, r.budget, m.owner_id, m.project_id, m.network, m.done_criteria, p.instructions AS project_notes33         FROM runs r JOIN missions m ON m.id = r.mission_id LEFT JOIN projects p ON p.id = m.project_id WHERE r.id = $1`,34      [ctx.runId],35    );36    const row = MetaRow.parse(r[0]);37    return {38      result: {39        provider: row.provider, model: row.model, effort: row.effort, budget: row.budget, ownerId: row.owner_id, projectId: row.project_id,40        network: row.network, projectNotes: row.project_notes ?? '', criteria: row.done_criteria,41      },42    };43  });44}4546const INSTRUCTION_FILES = ['AGENTS.md', 'CLAUDE.md'];4748/** <workspace>/AGENTS.md, else CLAUDE.md: a regular file inside the workspace (symlink-safe), capped at `maxBytes`. */49async function readInstructions(workspaceAbs: string, maxBytes: number): Promise<PrefixInput['instructionsFile']> {50  for (const name of INSTRUCTION_FILES) {51    try {52      const r = await resolveInWorkspace(workspaceAbs, name, 'read');53      const st = await fs.stat(r.abs);54      if (!st.isFile()) continue;55      const fh = await fs.open(r.abs, 'r');56      try {57        const buf = Buffer.alloc(Math.min(st.size, maxBytes));58        const { bytesRead } = await fh.read(buf, 0, buf.length, 0);59        // A cut in the middle of a multi-byte character decodes to U+FFFD: drop it.60        const text = buf.subarray(0, bytesRead).toString('utf8').replace(/�+$/, '');61        if (text.trim().length === 0) continue;62        return { name, text, truncated: st.size > maxBytes };63      } finally {64        await fh.close();65      }66    } catch {67      /* absent, unreadable or outside the workspace: try the next name */68    }69  }70  return null;71}7273export interface Prefix {74  system: string;75  tools: Tool[];76  promptHash: string;77}78interface PrefixRecord {79  instructionsFile: PrefixInput['instructionsFile'];80  promptHash: string;81}8283/**84 * Builds the frozen prefix. The step records the instruction file content (the only non-journaled85 * input) and pins `runs.prompt_hash`; the system prompt is then a pure function of journaled data.86 */87export async function buildPrefix(ctx: RunContext, deps: ResolvedDeps, meta: RunMeta): Promise<Prefix> {88  const tools = selectTools(deps.tools, ctx.contract.tools ?? [], meta.criteria.length > 0);89  const defs = toolDefs(tools);90  const input = (file: PrefixInput['instructionsFile']): PrefixInput => ({ network: meta.network, instructionsFile: file, projectNotes: meta.projectNotes });91  const rec = await ctx.step<Record<string, never>, PrefixRecord>({ kind: 'setup', name: 'prefix', effect: 'idempotent_write', request: {} }, async () => {92    const instructionsFile = await readInstructions(ctx.workspaceAbs, deps.limits.instructionsMaxBytes);93    const hash = promptHash(buildSystemPrompt(input(instructionsFile)), defs);94    await dbQuery(deps.kernel, 'UPDATE runs SET prompt_hash = COALESCE(prompt_hash, $2) WHERE id = $1', [ctx.runId, hash]);95    return { result: { instructionsFile, promptHash: hash } };96  });97  return { system: buildSystemPrompt(input(rec.instructionsFile ?? null)), tools, promptHash: rec.promptHash };98}99100/** `aos/<missionId[0:8]>`: created (or checked out again) at the start of the run when the workspace is a git repository. Idempotent. */101export async function ensureMissionBranch(ctx: RunContext): Promise<GitStart> {102  const branch = `aos/${ctx.missionId.slice(0, 8)}`;103  return ctx.step<{ branch: string }, GitStart>({ kind: 'setup', name: 'git_branch', effect: 'idempotent_write', request: { branch } }, async (_idem, signal) => {104    const isRepo = await fs.lstat(`${ctx.workspaceAbs}/.git`).then(() => true, () => false);105    if (!isRepo) return { result: { repo: false, branch: null, base: null, error: null } };106    const g = (args: string[]) => runGit(ctx.workspaceAbs, args, { signal });107    // The branch HEAD was on before, read back from the reflog so that a re-execution of this step after a108    // crash (already on the mission branch) records the same value as the first execution.109    const previous = async (): Promise<string | null> => {110      const log = await g(['reflog', '-n', '50', '--format=%gs', 'HEAD']);111      const prefix = 'checkout: moving from ';112      const suffix = ` to ${branch}`;113      const line = log.code === 0 ? log.stdout.split('\n').find((l) => l.startsWith(prefix) && l.endsWith(suffix)) : undefined;114      return line ? line.slice(prefix.length, line.length - suffix.length) : null;115    };116    const cur = await g(['symbolic-ref', '--short', '-q', 'HEAD']);117    const current = cur.code === 0 ? cur.stdout.trim() : null;118    if (current === branch) return { result: { repo: true, branch, base: await previous(), error: null } };119    const exists = (await g(['rev-parse', '--verify', '-q', `refs/heads/${branch}`])).code === 0;120    const sw = await g(exists ? ['checkout', '-q', branch] : ['checkout', '-q', '-b', branch]);121    if (sw.code !== 0) {122      const error = (sw.stderr.trim() || sw.stdout.trim() || `git checkout exited with code ${sw.code}`).split('\n')[0]?.slice(0, 300) ?? 'git checkout failed';123      return { result: { repo: true, branch: current, base: current, error } };124    }125    return { result: { repo: true, branch, base: (await previous()) ?? current, error: null } };126  });127}128
Files main
Menu

Theme