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:
parent
e2301e8bd3
commit
61bc109982
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -49,10 +49,12 @@ export { runVerifyGate, verifyAgentTask, verifyAndClean } from "./verify-gate.js
|
||||||
export type { VerifyGateSpec, VerifyGateResult } from "./verify-gate.js";
|
export type { VerifyGateSpec, VerifyGateResult } from "./verify-gate.js";
|
||||||
export { humanInterruptGate, requireApproval } from "./interrupt-gate.js";
|
export { humanInterruptGate, requireApproval } from "./interrupt-gate.js";
|
||||||
export type { InterruptDecision, InterruptGateOptions } 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 type { LedgerEntry } from "./flat-ledger.js";
|
||||||
export { buildRepoMap, repoTreeString, minifiedTree, gitLsTree } from "./repo-mapper.js";
|
export { buildRepoMap, repoTreeString, minifiedTree, gitLsTree } from "./repo-mapper.js";
|
||||||
export type { RepoMap, RepoNode } 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 { formatCapabilityReport, loadFactoryCapabilities, parseFactoryCapabilities, probeFactoryCapabilities } from "./factory-capabilities.js";
|
||||||
export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService } from "./factory-capabilities.js";
|
export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService } from "./factory-capabilities.js";
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue