Repository Public
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