- 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
255 lines
7.8 KiB
TypeScript
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>;
|
|
};
|
|
}
|