refactor(cli): nerve create workflow — role 拆成独立目录 (#206)

This commit is contained in:
2026-04-27 22:02:47 +08:00
parent ea7e064177
commit 7cd6f6fa2b
3 changed files with 136 additions and 56 deletions
@@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildWorkflowTemplate } from "../commands/create.js";
import { buildWorkflowScaffold } from "../commands/create.js";
let tmpDir: string;
@@ -21,60 +21,81 @@ afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("buildWorkflowTemplate", () => {
it("includes the workflow name in the template", () => {
const tpl = buildWorkflowTemplate("my-workflow");
expect(tpl).toContain("my-workflow started");
describe("buildWorkflowScaffold", () => {
it("includes the workflow name in the main role content", () => {
const { roleMainIndexTs } = buildWorkflowScaffold("my-workflow");
expect(roleMainIndexTs).toContain("my-workflow started");
});
it("contains WorkflowDefinition type import", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl).toContain("WorkflowDefinition");
expect(tpl).toContain("@uncaged/nerve-daemon");
it("root index contains WorkflowDefinition import from nerve-core", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("WorkflowDefinition");
expect(indexTs).toContain("@uncaged/nerve-core");
});
it("contains a moderate function that returns null to signal completion", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl).toContain("return null");
expect(tpl).toContain("moderate");
it("root index wires moderator and END", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("moderator");
expect(indexTs).toContain("END");
});
it("contains a roles map with main role", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl).toContain("roles:");
expect(tpl).toContain("main:");
it("root index imports main role and sets name field", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain('name: "test"');
expect(indexTs).toContain("main: mainRole");
expect(indexTs).toContain("./roles/main/index.js");
});
it("main role module exports mainRole function", () => {
const { roleMainIndexTs } = buildWorkflowScaffold("test");
expect(roleMainIndexTs).toContain("export async function mainRole");
});
it("uses different names per call", () => {
const a = buildWorkflowTemplate("workflow-a");
const b = buildWorkflowTemplate("workflow-b");
expect(a).toContain("workflow-a started");
expect(b).toContain("workflow-b started");
expect(a).not.toContain("workflow-b");
const a = buildWorkflowScaffold("workflow-a");
const b = buildWorkflowScaffold("workflow-b");
expect(a.roleMainIndexTs).toContain("workflow-a started");
expect(b.roleMainIndexTs).toContain("workflow-b started");
expect(a.roleMainIndexTs).not.toContain("workflow-b");
});
it("produces valid TypeScript syntax (no unclosed braces)", () => {
const tpl = buildWorkflowTemplate("test");
const opens = (tpl.match(/\{/g) ?? []).length;
const closes = (tpl.match(/\}/g) ?? []).length;
it("produces valid TypeScript syntax for index (no unclosed braces)", () => {
const { indexTs } = buildWorkflowScaffold("test");
const opens = (indexTs.match(/\{/g) ?? []).length;
const closes = (indexTs.match(/\}/g) ?? []).length;
expect(opens).toBe(closes);
});
it("ends with export default workflow", () => {
const tpl = buildWorkflowTemplate("test");
expect(tpl.trim().endsWith("export default workflow;")).toBe(true);
it("ends root index with export default workflow", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs.trim().endsWith("export default workflow;")).toBe(true);
});
it("prompt markdown names the workflow", () => {
const { roleMainPromptMd } = buildWorkflowScaffold("my-flow");
expect(roleMainPromptMd).toContain("# my-flow — main role");
});
});
describe("workflow scaffold file writing (simulated)", () => {
it("writes the template to disk correctly", () => {
it("writes all scaffold files to disk correctly", () => {
const workflowDir = join(tmpDir, "workflows", "my-task");
mkdirSync(workflowDir, { recursive: true });
const content = buildWorkflowTemplate("my-task");
writeFileSync(join(workflowDir, "index.ts"), content, "utf8");
mkdirSync(join(workflowDir, "roles", "main"), { recursive: true });
const scaffold = buildWorkflowScaffold("my-task");
writeFileSync(join(workflowDir, "index.ts"), scaffold.indexTs, "utf8");
writeFileSync(join(workflowDir, "roles", "main", "index.ts"), scaffold.roleMainIndexTs, "utf8");
writeFileSync(
join(workflowDir, "roles", "main", "prompt.md"),
scaffold.roleMainPromptMd,
"utf8",
);
const read = readFileSync(join(workflowDir, "index.ts"), "utf8");
expect(read).toContain("my-task started");
expect(read).toContain("WorkflowDefinition");
expect(readFileSync(join(workflowDir, "index.ts"), "utf8")).toContain('name: "my-task"');
expect(readFileSync(join(workflowDir, "roles", "main", "index.ts"), "utf8")).toContain(
"my-task started",
);
expect(readFileSync(join(workflowDir, "roles", "main", "prompt.md"), "utf8")).toContain(
"# my-task — main role",
);
});
});
@@ -132,8 +132,11 @@ describe("e2e create", () => {
expect(wf.stdout).toContain("✅");
const indexPath = join(nerveRoot, "workflows", "e2e-flow", "index.ts");
const mainRolePath = join(nerveRoot, "workflows", "e2e-flow", "roles", "main", "index.ts");
expect(existsSync(indexPath)).toBe(true);
expect(readFileSync(indexPath, "utf8")).toContain("e2e-flow started");
expect(existsSync(mainRolePath)).toBe(true);
expect(readFileSync(indexPath, "utf8")).toContain('name: "e2e-flow"');
expect(readFileSync(mainRolePath, "utf8")).toContain("e2e-flow started");
});
it("create sense scaffolds index.js, schema.ts, and migration", { timeout: 10_000 }, async () => {
+75 -19
View File
@@ -15,25 +15,38 @@ export function validateResourceName(name: string, type: string): string | null
return null;
}
export function buildWorkflowTemplate(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
export type WorkflowScaffoldFiles = {
indexTs: string;
roleMainIndexTs: string;
roleMainPromptMd: string;
};
const workflow: WorkflowDefinition = {
export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
return {
indexTs: buildWorkflowIndexTs(name),
roleMainIndexTs: buildWorkflowMainRoleIndexTs(name),
roleMainPromptMd: buildWorkflowMainRolePromptMd(name),
};
}
function buildWorkflowIndexTs(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { mainRole } from "./roles/main/index.js";
type MainMeta = Record<string, unknown>;
const workflow: WorkflowDefinition<Record<"main", MainMeta>> = {
name: "${name}",
roles: {
main: {
async execute(prompt, ctx) {
ctx.log("${name} started");
// TODO: implement your role logic here
return { type: "done" };
},
},
main: mainRole,
},
moderate(thread, event) {
if (event.type === "thread_start") {
return { role: "main", prompt: {} };
moderator({ steps }) {
if (steps.length === 0) {
return "main";
}
return null; // workflow complete
return END;
},
};
@@ -41,6 +54,38 @@ export default workflow;
`;
}
function buildWorkflowMainRoleIndexTs(name: string): string {
return `import type { RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
/**
* Main role — implement LLM calls, scripts, HTTP, etc.
* Optional: align behavior with \`prompt.md\` in this directory.
*/
export async function mainRole(
start: StartStep,
messages: WorkflowMessage[],
): Promise<RoleResult<Record<string, unknown>>> {
void start;
void messages;
// TODO: implement your role logic here
return {
content: "${name} started",
meta: {},
};
}
`;
}
function buildWorkflowMainRolePromptMd(name: string): string {
return `# ${name} — main role
Starter template for this role's system or task instructions.
The scaffolded \`index.ts\` returns a fixed content line; replace that with real logic
and optionally load this file at runtime if you keep prompts outside code.
`;
}
function senseIdToSqlTableName(id: string): string {
return id.replaceAll("-", "_");
}
@@ -133,17 +178,28 @@ const createWorkflowCommand = defineCommand({
}
mkdirSync(workflowDir, { recursive: true });
writeFile(join(workflowDir, "index.ts"), buildWorkflowTemplate(args.name));
const scaffold = buildWorkflowScaffold(args.name);
writeFile(join(workflowDir, "index.ts"), scaffold.indexTs);
writeFile(join(workflowDir, "roles", "main", "index.ts"), scaffold.roleMainIndexTs);
writeFile(join(workflowDir, "roles", "main", "prompt.md"), scaffold.roleMainPromptMd);
process.stdout.write(`✅ Workflow scaffolded: ${workflowDir}/index.ts\n`);
process.stdout.write("✅ Workflow scaffolded:\n");
process.stdout.write(` ${join(workflowDir, "index.ts")}\n`);
process.stdout.write(` ${join(workflowDir, "roles", "main", "index.ts")}\n`);
process.stdout.write(` ${join(workflowDir, "roles", "main", "prompt.md")}\n`);
process.stdout.write("\n💡 Next steps:\n");
process.stdout.write(" 1. Add to nerve.yaml:\n");
process.stdout.write(" workflows:\n");
process.stdout.write(` ${args.name}:\n`);
process.stdout.write(" concurrency: 1\n");
process.stdout.write(" overflow: drop\n");
process.stdout.write(` 2. Edit ${workflowDir}/index.ts to implement your roles.\n`);
process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n");
process.stdout.write(
` 2. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
);
process.stdout.write(
` 3. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\n`,
);
process.stdout.write(" 4. Run `nerve start` to launch the daemon.\n");
},
});