小橘 a469f30b42 refactor(workflow-generator): multi-file DIP + Role Factory + esbuild bundle
- Split 500-line monolith into roles/{planner,coder,tester,committer}/
- Each role: index.ts (build function) + prompt.ts (pure function)
- Use createCursorRole/createLlmRole/createHermesRole factories
- DIP: env vars read in index.ts, injected via build.ts
- esbuild bundle to dist/index.js (24kb)
- Moderator logic preserved: planner→coder→tester→committer with retries

Fixes xiaoju/nerve-workspace#3
2026-04-28 08:48:23 +00:00

154 lines
4.5 KiB
TypeScript

import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
import { cursorAgent, isDryRun } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import type { CoderMeta } from "../coder/index.js";
import type { PlannerMeta } from "../planner/index.js";
import { testerPrompt } from "./prompt.js";
export const testerMetaSchema = z.object({
workflowName: z.string().default(""),
attempt: z.number().default(1),
passed: z.boolean().default(false),
dryRunLog: z.string().default(""),
reason: z.string().default(""),
});
export type TesterMeta = z.infer<typeof testerMetaSchema>;
export type BuildTesterDeps = {
nerveRoot: string;
};
function formatSpawnFailure(error: SpawnError): string {
if (error.kind === "spawn_failed") {
return error.message;
}
if (error.kind === "timeout") {
return `timeout stdout=${error.stdout.slice(0, 300)} stderr=${error.stderr.slice(0, 300)}`;
}
return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 500)}`;
}
function lastMetaForRole<M>(messages: WorkflowMessage[], role: string): M | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === role) {
return messages[i].meta as M;
}
}
return null;
}
export function buildTesterRole({ nerveRoot }: BuildTesterDeps): Role<TesterMeta> {
return async (start, messages) => {
const dry = isDryRun(start);
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
const coderMeta = lastMetaForRole<CoderMeta>(messages, "coder");
const attempt = messages.filter((m) => m.role === "tester").length + 1;
if (plannerMeta === null || coderMeta === null) {
return {
content: "tester cannot continue: missing planner/coder output",
meta: {
workflowName: "",
attempt,
passed: false,
dryRunLog: "",
reason: "missing planner/coder output",
},
} satisfies RoleResult<TesterMeta>;
}
if (!coderMeta.lintPassed || !coderMeta.buildPassed) {
return {
content: "tester blocked: coder has not passed lint+build",
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: false,
dryRunLog: `${coderMeta.lintLog}\n\n${coderMeta.buildLog}`,
reason: "coder did not pass lint+build",
},
} satisfies RoleResult<TesterMeta>;
}
if (dry) {
return {
content: "PASS — dry-run mode",
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: true,
dryRunLog: "[dry-run] tester skipped external checks",
reason: "dry-run mode",
},
} satisfies RoleResult<TesterMeta>;
}
const prompt = testerPrompt({
workflowName: coderMeta.workflowName,
plannerSpec: {
roles: plannerMeta.roles,
flowTransitions: plannerMeta.flowTransitions,
validationLoopsDesign: plannerMeta.validationLoopsDesign,
externalDeps: plannerMeta.externalDeps,
dataFlow: plannerMeta.dataFlow,
},
coderOutput: coderMeta.cursorOutput,
nerveRoot,
});
const run = await cursorAgent({
prompt,
mode: "ask",
cwd: nerveRoot,
env: null,
timeoutMs: null,
dryRun: false,
});
if (!run.ok) {
return {
content: "tester agent failed",
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: false,
dryRunLog: "",
reason: `tester agent failed: ${formatSpawnFailure(run.error)}`,
},
} satisfies RoleResult<TesterMeta>;
}
const text = run.value.trim();
const pass = text.startsWith("PASS|");
const fail = text.startsWith("FAIL|");
if (!pass && !fail) {
return {
content: "tester format invalid",
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: false,
dryRunLog: text,
reason: "tester format invalid",
},
} satisfies RoleResult<TesterMeta>;
}
const parts = text.split("|");
const reason = parts[1] ?? "no reason";
const log = parts.slice(2).join("|").trim();
return {
content: `${pass ? "PASS" : "FAIL"}${reason}`,
meta: {
workflowName: coderMeta.workflowName,
attempt,
passed: pass,
dryRunLog: log,
reason,
},
} satisfies RoleResult<TesterMeta>;
};
}