- 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
154 lines
4.5 KiB
TypeScript
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>;
|
|
};
|
|
}
|