feat: initialize standalone agent-workflow

This commit is contained in:
liujing
2026-07-16 20:56:24 +08:00
commit 35e8fa3717
31 changed files with 6972 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { startWorkflow, reviewWorkflow, decideWorkflow, retryExecuteWorkflow } from "./workflow-engine.js";
import { readState, runDir } from "./workflow-state.js";
import type { HostDecisionV1 } from "./workflow-types.js";
// Helper to create a fake CLI script
function writeFakeCli(dir: string, name: string, scriptBody: string): string {
const p = path.join(dir, name);
fs.writeFileSync(p, `#!/bin/bash\n${scriptBody}`, { mode: 0o755 });
return p;
}
describe("Workflow Integration (start -> review -> fix -> review -> accept)", () => {
let tmpDir: string;
let baseTmp: string;
let homeDir: string;
let workflowDir: string;
let originalHome: string | undefined;
let originalExit: typeof process.exit;
let originalWorkflowDir: string | undefined;
beforeEach(() => {
originalExit = process.exit;
(process as any).exit = (code?: number) => {
if (code === 0) throw new Error("MockExit0");
originalExit(code);
};
delete process.env.GIT_DIR;
delete process.env.GIT_WORK_TREE;
baseTmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-integration-")));
tmpDir = path.join(baseTmp, "John's 项目 空格+中文");
fs.mkdirSync(tmpDir);
homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-home-")));
workflowDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-workflows-")));
originalHome = process.env.HOME;
originalWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
process.env.HOME = homeDir;
process.env.AGENT_WORKFLOW_DIR = workflowDir;
// Init real git repo for baselineHead
execFileSync("git", ["init"], { cwd: tmpDir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: tmpDir });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tmpDir });
fs.writeFileSync(path.join(tmpDir, "index.ts"), "const x = 1;\n");
execFileSync("git", ["add", "index.ts"], { cwd: tmpDir });
execFileSync("git", ["commit", "-m", "init"], { cwd: tmpDir });
});
afterEach(() => {
fs.rmSync(baseTmp, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(workflowDir, { recursive: true, force: true });
if (originalHome !== undefined) {
process.env.HOME = originalHome;
} else {
delete process.env.HOME;
}
if (originalWorkflowDir !== undefined) {
process.env.AGENT_WORKFLOW_DIR = originalWorkflowDir;
} else {
delete process.env.AGENT_WORKFLOW_DIR;
}
process.exit = originalExit;
});
it("completes a full loop with reasonix", async () => {
// 1. Fake Executor CLI (Reasonix)
const executorScript = `
if [[ "$*" == *"--version"* ]]; then
echo "v1.0.0"
exit 0
fi
if [[ "$*" == *"--resume"* ]]; then
if [[ "$*" == *"# Scope Remediation"* ]]; then
if [[ ! -f "$HOME/scope-retry-ready" ]]; then
: > "$HOME/scope-retry-ready"
echo "session busy" >&2
exit 1
fi
# Cycle 2: remove only the listed SQLite sidecars.
rm -f "$3/tmp_oc.db-shm" "$3/tmp_oc.db-wal"
echo "Scope cleaned!"
else
# Cycle 3: fix the Codex finding.
echo "const x = 3;" > "$3/index.ts"
echo "Fixed!"
fi
exit 0
else
# Cycle 1: implement with a bug and accidentally leave SQLite sidecars.
echo "const x = 2; // bug" > "$3/index.ts"
: > "$3/tmp_oc.db-shm"
: > "$3/tmp_oc.db-wal"
echo "Done!"
# Create fake session file for reasonix
mkdir -p "$HOME/.reasonix/projects/mock-project/sessions"
echo '{"role": "system", "content": "Current workspace: \\"'"$3"'\\""}' > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl"
echo "{}" > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl.meta"
# Create a newer subagent session to verify it gets correctly ignored
mkdir -p "$HOME/.reasonix/projects/mock-project/sessions/subagents"
sleep 1
echo '{"role": "system", "content": "Current workspace: '"$3"'"}' > "$HOME/.reasonix/projects/mock-project/sessions/subagents/sub_session.jsonl"
echo "{}" > "$HOME/.reasonix/projects/mock-project/sessions/subagents/sub_session.jsonl.meta"
fi
`;
const executorBin = writeFakeCli(tmpDir, "fake-executor.sh", executorScript);
fs.writeFileSync(path.join(tmpDir, "agent-workflow.json"), JSON.stringify({
version: "1",
maxCycles: 3,
executors: {
reasonix: { binary: executorBin }
}
}));
const plan = {
version: "1",
title: "Add a file",
planMarkdown: "Add an index.ts file.",
scope: ["index.ts"],
acceptanceCriteria: ["x is 3"],
verificationCommands: [["grep", "const x = 3;", "index.ts"]],
} as const;
const planPath = path.join(tmpDir, "plan.json");
fs.writeFileSync(planPath, JSON.stringify(plan));
execFileSync("git", ["add", "."], { cwd: tmpDir });
execFileSync("git", ["commit", "-m", "config"], { cwd: tmpDir });
// --- STEP 1: START ---
const { runId } = await startWorkflow({
cwd: tmpDir,
executor: "reasonix",
planInput: planPath,
});
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: tmpDir, encoding: "utf8" }).trim();
let state = readState(runDir(repoRoot, runId))!;
expect(state.status).toBe("awaiting_review");
expect(state.currentCycle).toBe(1);
// --- STEP 2: SCOPE GATE (Cycle 1, Codex review bundle is not ready yet) ---
await reviewWorkflow({ runId, cwd: tmpDir });
let state2 = readState(runDir(repoRoot, runId))!;
expect(state2.status).toBe("awaiting_scope_resolution");
expect(state2.cycles[0].scopeViolations).toEqual([
{ id: "scope-1", path: "tmp_oc.db-shm", kind: "out_of_scope" },
{ id: "scope-2", path: "tmp_oc.db-wal", kind: "out_of_scope" },
]);
expect(state2.cycles[0].hostReviewFile).toBeUndefined();
const incompleteDecision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [{ findingId: "scope-1", disposition: "accept" }],
decidedAt: new Date().toISOString(),
};
const incompleteDecisionPath = path.join(process.env.HOME!, "decision-incomplete.json");
fs.writeFileSync(incompleteDecisionPath, JSON.stringify(incompleteDecision));
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteDecisionPath }))
.rejects.toThrow(/accept every current violation exactly once/);
expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_scope_resolution");
// --- STEP 3: DECIDE (Scope fix using the same Reasonix session) ---
const scopeDecision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [
{ findingId: "scope-1", disposition: "accept" },
{ findingId: "scope-2", disposition: "accept" },
],
decidedAt: new Date().toISOString()
};
const scopeDecisionPath = path.join(process.env.HOME!, "decision-scope.json");
fs.writeFileSync(scopeDecisionPath, JSON.stringify(scopeDecision));
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: scopeDecisionPath }))
.rejects.toThrow(/Executor failed/);
expect(readState(runDir(repoRoot, runId))?.status).toBe("blocked");
await retryExecuteWorkflow({ runId });
let state3 = readState(runDir(repoRoot, runId))!;
expect(state3.status).toBe("awaiting_review");
expect(state3.currentCycle).toBe(2);
expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-shm"))).toBe(false);
expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-wal"))).toBe(false);
// --- STEP 4: PREPARE CODEX REVIEW (Cycle 2) ---
await reviewWorkflow({ runId, cwd: tmpDir });
let state4 = readState(runDir(repoRoot, runId))!;
expect(state4.status).toBe("awaiting_host");
expect(state4.cycles[1].hostReviewFile).toBe("cycle-2-host-review.json");
const reviewBundle = JSON.parse(fs.readFileSync(
path.join(runDir(repoRoot, runId), state4.cycles[1].hostReviewFile!),
"utf8",
));
expect(reviewBundle.repoRoot).toBe(repoRoot);
expect(reviewBundle.diffFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2.diff"));
expect(reviewBundle.executorLogFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2-exec-retry-1.log"));
expect(state4.cycles[1].executorAttemptLogs).toEqual([
"cycle-2-exec.log",
"cycle-2-exec-retry-1.log",
]);
// --- STEP 5: CODEX DECIDES FIX ---
const incompleteFix: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [{ findingId: "codex-1", disposition: "accept" }],
decidedAt: new Date().toISOString(),
};
const incompleteFixPath = path.join(process.env.HOME!, "decision-fix-incomplete.json");
fs.writeFileSync(incompleteFixPath, JSON.stringify(incompleteFix));
await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteFixPath }))
.rejects.toThrow(/require a non-empty summary/);
expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_host");
const fixDecision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [{
findingId: "codex-1",
disposition: "accept",
summary: "index.ts sets x to 2 instead of the required value 3",
path: "index.ts",
}],
decidedAt: new Date().toISOString()
};
const fixDecisionPath = path.join(process.env.HOME!, "decision-fix.json");
fs.writeFileSync(fixDecisionPath, JSON.stringify(fixDecision));
await decideWorkflow({ runId, cwd: tmpDir, decisionInput: fixDecisionPath });
let state5 = readState(runDir(repoRoot, runId))!;
expect(state5.status).toBe("awaiting_review");
expect(state5.currentCycle).toBe(3);
// --- STEP 6: PREPARE CODEX RE-REVIEW (Cycle 3) ---
await reviewWorkflow({ runId, cwd: tmpDir });
let state6 = readState(runDir(repoRoot, runId))!;
expect(state6.status).toBe("awaiting_host");
expect(state6.cycles[2].hostReviewFile).toBe("cycle-3-host-review.json");
// --- STEP 7: DECIDE (Accept) ---
const acceptDecision: HostDecisionV1 = {
version: "1",
outcome: "accept",
findingDecisions: [],
verificationEvidence: ["verified output"],
decidedAt: new Date().toISOString()
};
const acceptDecisionPath = path.join(process.env.HOME!, "decision2.json");
fs.writeFileSync(acceptDecisionPath, JSON.stringify(acceptDecision));
try {
await decideWorkflow({ runId, cwd: tmpDir, decisionInput: acceptDecisionPath });
} catch (err: any) {
if (err.message !== "MockExit0") throw err;
}
let state7 = readState(runDir(repoRoot, runId))!;
expect(state7.status).toBe("completed");
});
});