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

191 lines
5.9 KiB
TypeScript

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 { committerPrompt } from "./prompt.js";
export const committerMetaSchema = z.object({
invoked: z.boolean().default(false),
success: z.boolean().default(false),
branch: z.string().nullable().default(null),
commitHash: z.string().nullable().default(null),
pushed: z.boolean().nullable().default(null),
log: z.string().default(""),
error: z.string().nullable().default(null),
});
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
export type BuildCommitterDeps = {
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;
}
function inferWorkflowName(messages: WorkflowMessage[]): string {
const tester = lastMetaForRole<TesterMeta>(messages, "tester");
if (tester !== null && tester.workflowName.trim().length > 0) {
return tester.workflowName.trim();
}
const coder = lastMetaForRole<{ workflowName: string }>(messages, "coder");
if (coder !== null && coder.workflowName.trim().length > 0) {
return coder.workflowName.trim();
}
const planner = lastMetaForRole<PlannerMeta>(messages, "planner");
if (planner !== null && planner.workflowName.trim().length > 0) {
return planner.workflowName.trim();
}
return "";
}
async function runHermesCommitter(
task: string,
nerveRoot: string,
): Promise<CommitterMeta> {
const commandAttempts: Array<{ cmd: string; args: string[] }> = [
{ cmd: "hermes-agent", args: ["--cwd", nerveRoot, "--task", task] },
{ cmd: "hermes", args: ["agent", "--cwd", nerveRoot, "--task", task] },
];
for (const candidate of commandAttempts) {
const run = await spawnSafe(candidate.cmd, candidate.args, {
cwd: nerveRoot,
env: null,
timeoutMs: 600_000,
dryRun: false,
});
if (!run.ok) {
continue;
}
const text = `${run.value.stdout}\n${run.value.stderr}`;
const branch = text.match(/^BRANCH=(.*)$/m)?.[1]?.trim() ?? null;
const commitHash = text.match(/^COMMIT=(.*)$/m)?.[1]?.trim() ?? null;
const pushedText = text.match(/^PUSHED=(.*)$/m)?.[1]?.trim().toLowerCase() ?? "unknown";
const pushed = pushedText === "true" ? true : pushedText === "false" ? false : null;
return {
invoked: true,
success: true,
branch: branch && branch.length > 0 ? branch : null,
commitHash: commitHash && commitHash.length > 0 ? commitHash : null,
pushed,
log: text.slice(0, 20_000),
error: null,
};
}
const fallback = await cursorAgent({
prompt: `Run this git committer task in repository ${nerveRoot}:\n\n${task}`,
mode: "default",
cwd: nerveRoot,
env: null,
timeoutMs: null,
dryRun: false,
});
if (!fallback.ok) {
return {
invoked: true,
success: false,
branch: null,
commitHash: null,
pushed: null,
log: "",
error: `hermes and fallback both failed: ${formatSpawnFailure(fallback.error)}`,
};
}
const out = fallback.value;
const branch = out.match(/(?:branch|BRANCH)\s*[:=]\s*([^\s]+)/)?.[1] ?? null;
const commitHash = out.match(/[a-f0-9]{7,40}/)?.[0] ?? null;
return {
invoked: true,
success: true,
branch,
commitHash,
pushed: out.toLowerCase().includes("push") ? true : null,
log: out.slice(0, 20_000),
error: null,
};
}
export function buildCommitterRole({ nerveRoot }: BuildCommitterDeps): Role<CommitterMeta> {
return async (start, messages) => {
const dry = isDryRun(start);
const planner = lastMetaForRole<PlannerMeta>(messages, "planner");
const tester = lastMetaForRole<TesterMeta>(messages, "tester");
const workflowName = inferWorkflowName(messages);
const skipMeta: CommitterMeta = {
invoked: false,
success: false,
branch: null,
commitHash: null,
pushed: null,
log: "",
error: null,
};
if (planner === null || tester === null || workflowName.length === 0) {
return {
content: "committer skipped: missing planner/tester/workflowName context",
meta: { ...skipMeta, error: "missing committer context" },
} satisfies RoleResult<CommitterMeta>;
}
if (!tester.passed) {
return {
content: "committer skipped: tester not passed",
meta: { ...skipMeta, error: "tester not passed" },
} satisfies RoleResult<CommitterMeta>;
}
if (dry) {
return {
content: "[dry-run] skipped hermes committer",
meta: {
invoked: true,
success: true,
branch: "wf/dry-run",
commitHash: null,
pushed: null,
log: "[dry-run] skipped hermes committer",
error: null,
},
} satisfies RoleResult<CommitterMeta>;
}
const task = committerPrompt({
nerveRoot,
workflowName,
userPrompt: planner.userPrompt,
testerReason: tester.reason,
});
const committed = await runHermesCommitter(task, nerveRoot);
return {
content: committed.success
? committed.log
: `committer failed: ${committed.error ?? "unknown"}`,
meta: committed,
} satisfies RoleResult<CommitterMeta>;
};
}