feat: execution pipeline — wires 5 harness components end-to-end

runPipeline() chains worktree -> repo-map -> agent task -> verify-gate
-> interrupt-gate -> flat-ledger. One async function, ~90 lines.

4 integration tests: pass path, fail path, deny path, repo-map injection.
69 files, 295 tests.
This commit is contained in:
artale 2026-07-05 00:23:21 +02:00
parent e2301e8bd3
commit 61bc109982
3 changed files with 270 additions and 1 deletions

View File

@ -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<string> {
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("<repo-map>");
expect(capturedPrompt).toContain("</repo-map>");
expect(capturedPrompt).toContain("original prompt");
});
});

View File

@ -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<string>;
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<PipelineResult> {
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 = `<repo-map>\n${tree}\n</repo-map>\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,
};
}

View File

@ -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";