diff --git a/src/fable5/execution-pipeline.test.ts b/src/fable5/execution-pipeline.test.ts new file mode 100644 index 0000000..c4dae0e --- /dev/null +++ b/src/fable5/execution-pipeline.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi } from "vitest"; +import * as path from "node:path"; +import * as os from "node:os"; +import * as fs from "node:fs"; +import { runPipeline, PipelineSpec } from "./execution-pipeline.js"; +import { setLedgerDir } from "./flat-ledger.js"; + +// Mock agent that runs in the worktree +function makeMockAgent(out: string): (cwd: string, prompt: string) => Promise { + return vi.fn(async (_cwd: string, _prompt: string) => out); +} + +describe("execution pipeline", () => { + const testRoot = path.join(os.tmpdir(), "fable-pipeline-test"); + const ledgerDir = path.join(testRoot, "ledger"); + + beforeAll(() => { + fs.mkdirSync(testRoot, { recursive: true }); + setLedgerDir(ledgerDir); + // Init a minimal git repo for worktree creation + if (!fs.existsSync(path.join(testRoot, ".git"))) { + const { execFileSync } = require("node:child_process"); + execFileSync("git", ["init", testRoot], { stdio: "ignore" }); + execFileSync("git", ["-C", testRoot, "config", "user.email", "test@test"], { stdio: "ignore" }); + execFileSync("git", ["-C", testRoot, "config", "user.name", "test"], { stdio: "ignore" }); + fs.writeFileSync(path.join(testRoot, "README.md"), "# test\n"); + execFileSync("git", ["-C", testRoot, "add", "."], { stdio: "ignore" }); + execFileSync("git", ["-C", testRoot, "commit", "-m", "init"], { stdio: "ignore" }); + } + }); + + afterAll(() => { + // Clean up worktrees + const wmDir = path.join(testRoot, "..", ".worktrees"); + if (fs.existsSync(wmDir)) { + fs.rmSync(wmDir, { recursive: true, force: true }); + } + fs.rmSync(testRoot, { recursive: true, force: true }); + }); + + it("creates worktree, runs agent, verifies, and records ok", async () => { + const spec: PipelineSpec = { + taskName: "test-pass", + agentPrompt: "do the thing", + repoRoot: testRoot, + buildCmd: [process.execPath, "-e", "process.exit(0)"], + testCmd: [process.execPath, "-e", "process.exit(0)"], + runAgent: makeMockAgent("done"), + interruptOpts: { defaultValue: "allow" }, + }; + + const result = await runPipeline(spec); + expect(result.verify?.verdict).toBe("pass"); + expect(result.approved).toBe(true); + expect(result.worktreePath).toBeTruthy(); + expect(result.worktreeCleaned).toBe(false); + expect(result.agentOutput).toBe("done"); + expect(result.ledgerEntry?.result).toBe("ok"); + }); + + it("wipes worktree on verify failure", async () => { + const spec: PipelineSpec = { + taskName: "test-fail", + agentPrompt: "do the thing", + repoRoot: testRoot, + buildCmd: [process.execPath, "-e", "process.exit(1)"], + testCmd: [process.execPath, "-e", "process.exit(0)"], + runAgent: makeMockAgent("broken"), + interruptOpts: { defaultValue: "allow" }, + }; + + const result = await runPipeline(spec); + expect(result.verify?.verdict).toBe("fail"); + expect(result.approved).toBe(false); + expect(result.worktreePath).toBeNull(); + expect(result.worktreeCleaned).toBe(true); + expect(result.ledgerEntry?.result).toBe("fail"); + }); + + it("wipes worktree on human deny", async () => { + const spec: PipelineSpec = { + taskName: "test-deny", + agentPrompt: "do the thing", + repoRoot: testRoot, + buildCmd: [process.execPath, "-e", "process.exit(0)"], + testCmd: [process.execPath, "-e", "process.exit(0)"], + runAgent: makeMockAgent("done"), + interruptOpts: { defaultValue: "deny" }, + }; + + const result = await runPipeline(spec); + expect(result.verify?.verdict).toBe("pass"); + expect(result.approved).toBe(false); + expect(result.worktreePath).toBeNull(); + expect(result.worktreeCleaned).toBe(true); + expect(result.ledgerEntry?.result).toBe("skip"); + }); + + it("injects repo map into prompt", async () => { + let capturedPrompt = ""; + const capturingAgent = vi.fn(async (_cwd: string, prompt: string) => { + capturedPrompt = prompt; + return "done"; + }); + + const spec: PipelineSpec = { + taskName: "test-map", + agentPrompt: "original prompt", + repoRoot: testRoot, + buildCmd: [process.execPath, "-e", "process.exit(0)"], + testCmd: [process.execPath, "-e", "process.exit(0)"], + runAgent: capturingAgent, + interruptOpts: { defaultValue: "allow" }, + }; + + await runPipeline(spec); + expect(capturedPrompt).toContain(""); + expect(capturedPrompt).toContain(""); + expect(capturedPrompt).toContain("original prompt"); + }); +}); diff --git a/src/fable5/execution-pipeline.ts b/src/fable5/execution-pipeline.ts new file mode 100644 index 0000000..127a52b --- /dev/null +++ b/src/fable5/execution-pipeline.ts @@ -0,0 +1,146 @@ +import { WorktreeSpec, WorktreeManager } from "./worktree-isolation.js"; +import { VerifyGateSpec, VerifyGateResult, runVerifyGate } from "./verify-gate.js"; +import { humanInterruptGate, InterruptGateOptions } from "./interrupt-gate.js"; +import { FlatLedger, LedgerEntry } from "./flat-ledger.js"; +import { minifiedTree, buildRepoMap } from "./repo-mapper.js"; + +export interface PipelineSpec { + taskName: string; + agentPrompt: string; + repoRoot: string; + buildCmd: string[]; + testCmd: string[]; + lintCmd?: string[]; + runAgent: (cwd: string, prompt: string) => Promise; + interruptOpts?: InterruptGateOptions; + worktreeBranch?: string; +} + +export interface PipelineResult { + worktreePath: string | null; + verify: VerifyGateResult | null; + approved: boolean; + worktreeCleaned: boolean; + ledgerEntry: LedgerEntry | null; + agentOutput: string; + durationMs: number; +} + +function worktreeExists(wm: WorktreeManager, name: string): boolean { + try { + const path = (wm as any).baseDir; + if (!path) return false; + return require("node:fs").existsSync(require("node:path").join(path, name)); + } catch { + return false; + } +} + +export async function runPipeline(spec: PipelineSpec): Promise { + const start = Date.now(); + const session = FlatLedger.sessionNameFromTask(spec.taskName); + const ledger = new FlatLedger(session); + const wm = new WorktreeManager(spec.repoRoot); + const worktreeName = `pipeline-${session}`; + + // 1. Create worktree + wm.initDir(); + const worktreePath = wm.create({ + name: worktreeName, + branch: spec.worktreeBranch ?? `pipeline/${session}`, + targetDir: spec.repoRoot, + }); + + // 2. Inject repo map into prompt + const repoMap = buildRepoMap(spec.repoRoot, 3); + const tree = minifiedTree(repoMap); + const enrichedPrompt = `\n${tree}\n\n\n${spec.agentPrompt}`; + + // 3. Run agent in worktree + let agentOutput = ""; + try { + agentOutput = await spec.runAgent(worktreePath, enrichedPrompt); + } catch (e: any) { + agentOutput = `agent-error: ${e.message ?? e}`; + } + + // 4. Verify gate + const vgSpec: VerifyGateSpec = { + buildCmd: spec.buildCmd, + testCmd: spec.testCmd, + lintCmd: spec.lintCmd, + cwd: worktreePath, + }; + const verify = runVerifyGate(vgSpec); + const passed = verify.verdict === "pass"; + + if (!passed) { + wm.remove(worktreeName); + const entry = ledger.append({ + timestamp: new Date().toISOString(), + agent: "pipeline", + action: spec.taskName, + target: worktreePath, + result: "fail", + durationMs: Date.now() - start, + }); + return { + worktreePath: null, + verify, + approved: false, + worktreeCleaned: true, + ledgerEntry: entry, + agentOutput, + durationMs: Date.now() - start, + }; + } + + // 5. Interrupt gate + const label = `[pipeline] ${spec.taskName}`; + const decision = await humanInterruptGate({ ...spec.interruptOpts, prompt: `${label} — approve verified worktree? (a=allow / d=deny / s=skip / q=quit): ` }); + + const approved = decision === "allow"; + + if (!approved) { + wm.remove(worktreeName); + const entry = ledger.append({ + timestamp: new Date().toISOString(), + agent: "pipeline", + action: spec.taskName, + target: worktreePath, + result: "skip", + worktree: worktreeName, + durationMs: Date.now() - start, + }); + return { + worktreePath: null, + verify, + approved: false, + worktreeCleaned: true, + ledgerEntry: entry, + agentOutput, + durationMs: Date.now() - start, + }; + } + + // 6. Success — worktree survives + const entry = ledger.append({ + timestamp: new Date().toISOString(), + agent: "pipeline", + action: spec.taskName, + target: worktreePath, + result: "ok", + worktree: worktreeName, + durationMs: Date.now() - start, + }); + + return { + worktreePath, + verify, + approved: true, + worktreeCleaned: false, + ledgerEntry: entry, + agentOutput, + durationMs: Date.now() - start, + }; +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 38d9d39..11afa4b 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -49,10 +49,12 @@ export { runVerifyGate, verifyAgentTask, verifyAndClean } from "./verify-gate.js export type { VerifyGateSpec, VerifyGateResult } from "./verify-gate.js"; export { humanInterruptGate, requireApproval } from "./interrupt-gate.js"; export type { InterruptDecision, InterruptGateOptions } from "./interrupt-gate.js"; -export { FlatLedger } from "./flat-ledger.js"; +export { FlatLedger, setLedgerDir } from "./flat-ledger.js"; export type { LedgerEntry } from "./flat-ledger.js"; export { buildRepoMap, repoTreeString, minifiedTree, gitLsTree } from "./repo-mapper.js"; export type { RepoMap, RepoNode } from "./repo-mapper.js"; +export { runPipeline } from "./execution-pipeline.js"; +export type { PipelineSpec, PipelineResult } from "./execution-pipeline.js"; export { formatCapabilityReport, loadFactoryCapabilities, parseFactoryCapabilities, probeFactoryCapabilities } from "./factory-capabilities.js"; export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService } from "./factory-capabilities.js";