小橘 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

255 lines
7.8 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { Role, RoleResult, WorkflowMessage } from "@uncaged/nerve-core";
import type { SpawnError } from "@uncaged/nerve-workflow-utils";
import { cursorAgent, isDryRun, spawnSafe } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
import type { PlannerMeta } from "../planner/index.js";
import type { TesterMeta } from "../tester/index.js";
import { coderPrompt } from "./prompt.js";
export const coderMetaSchema = z.object({
workflowName: z.string().default(""),
attempt: z.number().default(1),
files: z
.object({
indexTs: z.boolean().default(false),
packageJson: z.boolean().default(false),
tsconfigJson: z.boolean().default(false),
})
.default({ indexTs: false, packageJson: false, tsconfigJson: false }),
lintPassed: z.boolean().default(false),
buildPassed: z.boolean().default(false),
lintLog: z.string().default(""),
buildLog: z.string().default(""),
cursorOutput: z.string().default(""),
reason: z.string().nullable().default(null),
});
export type CoderMeta = z.infer<typeof coderMetaSchema>;
export type BuildCoderDeps = {
nerveRoot: string;
workflowsDir: 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 scanGeneratedCodePitfalls(source: string): string[] {
const issues: string[] = [];
if (/\bawait\s+import\s*\(/.test(source)) {
issues.push("Found await import() in generated workflow code");
}
if (/\bimport\s*\(\s*["'`]/.test(source) && !source.includes("Dynamic import required")) {
issues.push("Found undocumented dynamic import() call");
}
if (!/\bexport\s+default\s+/.test(source)) {
issues.push("Missing default export of WorkflowDefinition");
}
return issues;
}
async function runLintAndBuild(
workflowDir: string,
dry: boolean,
): Promise<{
lintPassed: boolean;
buildPassed: boolean;
lintLog: string;
buildLog: string;
reason: string | null;
}> {
const lintRun = await spawnSafe("pnpm", ["run", "check"], {
cwd: workflowDir,
env: null,
timeoutMs: 300_000,
dryRun: dry,
});
if (!lintRun.ok) {
return {
lintPassed: false,
buildPassed: false,
lintLog: formatSpawnFailure(lintRun.error),
buildLog: "",
reason: `lint failed: ${formatSpawnFailure(lintRun.error)}`,
};
}
const lintLog = lintRun.value.stderr.trim() || lintRun.value.stdout.trim() || "(no output)";
const tscRun = await spawnSafe("npx", ["tsc", "--noEmit"], {
cwd: workflowDir,
env: null,
timeoutMs: 300_000,
dryRun: dry,
});
if (!tscRun.ok) {
return {
lintPassed: true,
buildPassed: false,
lintLog,
buildLog: formatSpawnFailure(tscRun.error),
reason: `build failed: ${formatSpawnFailure(tscRun.error)}`,
};
}
const buildLog = tscRun.value.stderr.trim() || tscRun.value.stdout.trim() || "(no output)";
return { lintPassed: true, buildPassed: true, lintLog, buildLog, reason: null };
}
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 buildCoderRole({ nerveRoot, workflowsDir }: BuildCoderDeps): Role<CoderMeta> {
return async (start, messages) => {
const dry = isDryRun(start);
const plannerMeta = lastMetaForRole<PlannerMeta>(messages, "planner");
const previousTester = lastMetaForRole<TesterMeta>(messages, "tester");
const attempt = messages.filter((m) => m.role === "coder").length + 1;
if (plannerMeta === null || plannerMeta.workflowName.trim().length === 0) {
return {
content: "coder cannot continue: missing planner output",
meta: {
workflowName: "",
attempt,
files: { indexTs: false, packageJson: false, tsconfigJson: false },
lintPassed: false,
buildPassed: false,
lintLog: "",
buildLog: "",
cursorOutput: "",
reason: "missing planner output",
},
} satisfies RoleResult<CoderMeta>;
}
const wfName = plannerMeta.workflowName.trim();
const feedback =
previousTester !== null && previousTester.passed === false
? `\n\nPrevious tester failure to fix:\n${previousTester.reason}\n${previousTester.dryRunLog}\n`
: "";
const prompt = coderPrompt({
workflowsDir,
wfName,
planMarkdown: plannerMeta.planMarkdown,
plannerStructured: {
workflowName: plannerMeta.workflowName,
roles: plannerMeta.roles,
flowTransitions: plannerMeta.flowTransitions,
validationLoopsDesign: plannerMeta.validationLoopsDesign,
externalDeps: plannerMeta.externalDeps,
dataFlow: plannerMeta.dataFlow,
},
feedback,
nerveRoot,
});
const agentRun = await cursorAgent({
prompt,
mode: "default",
cwd: nerveRoot,
env: null,
timeoutMs: null,
dryRun: dry,
});
const workflowDir = join(workflowsDir, wfName);
const files = {
indexTs: existsSync(join(workflowDir, "index.ts")),
packageJson: existsSync(join(workflowDir, "package.json")),
tsconfigJson: existsSync(join(workflowDir, "tsconfig.json")),
};
const missing = [
files.indexTs ? null : "index.ts",
files.packageJson ? null : "package.json",
files.tsconfigJson ? null : "tsconfig.json",
].filter((x) => x !== null) as string[];
if (!agentRun.ok) {
return {
content: `coder failed: ${formatSpawnFailure(agentRun.error)}`,
meta: {
workflowName: wfName,
attempt,
files,
lintPassed: false,
buildPassed: false,
lintLog: "",
buildLog: "",
cursorOutput: "",
reason: formatSpawnFailure(agentRun.error),
},
} satisfies RoleResult<CoderMeta>;
}
if (missing.length > 0) {
return {
content: `coder failed: missing required files (${missing.join(", ")})`,
meta: {
workflowName: wfName,
attempt,
files,
lintPassed: false,
buildPassed: false,
lintLog: "",
buildLog: "",
cursorOutput: agentRun.value,
reason: `missing files: ${missing.join(", ")}`,
},
} satisfies RoleResult<CoderMeta>;
}
const source = readFileSync(join(workflowDir, "index.ts"), "utf-8");
const pitfalls = scanGeneratedCodePitfalls(source);
if (pitfalls.length > 0) {
return {
content: `coder static check failed:\n${pitfalls.join("\n")}`,
meta: {
workflowName: wfName,
attempt,
files,
lintPassed: false,
buildPassed: false,
lintLog: pitfalls.join("\n"),
buildLog: "",
cursorOutput: agentRun.value,
reason: pitfalls.join("; "),
},
} satisfies RoleResult<CoderMeta>;
}
const check = await runLintAndBuild(workflowDir, dry);
const passed = check.lintPassed && check.buildPassed;
return {
content: passed
? `coder PASS: lint+build ok\n\n${check.lintLog}\n\n${check.buildLog}`
: `coder FAIL: ${check.reason ?? "unknown error"}`,
meta: {
workflowName: wfName,
attempt,
files,
lintPassed: check.lintPassed,
buildPassed: check.buildPassed,
lintLog: check.lintLog,
buildLog: check.buildLog,
cursorOutput: agentRun.value,
reason: check.reason,
},
} satisfies RoleResult<CoderMeta>;
};
}