From 7cd6f6fa2b0e0f45228fc026992963a9b426929c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Mon, 27 Apr 2026 22:02:47 +0800 Subject: [PATCH] =?UTF-8?q?refactor(cli):=20nerve=20create=20workflow=20?= =?UTF-8?q?=E2=80=94=20role=20=E6=8B=86=E6=88=90=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/src/__tests__/create-workflow.test.ts | 93 +++++++++++------- packages/cli/src/__tests__/e2e-create.test.ts | 5 +- packages/cli/src/commands/create.ts | 94 +++++++++++++++---- 3 files changed, 136 insertions(+), 56 deletions(-) diff --git a/packages/cli/src/__tests__/create-workflow.test.ts b/packages/cli/src/__tests__/create-workflow.test.ts index c450f5f..fef5dcd 100644 --- a/packages/cli/src/__tests__/create-workflow.test.ts +++ b/packages/cli/src/__tests__/create-workflow.test.ts @@ -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", + ); }); }); diff --git a/packages/cli/src/__tests__/e2e-create.test.ts b/packages/cli/src/__tests__/e2e-create.test.ts index 16a081b..f71802a 100644 --- a/packages/cli/src/__tests__/e2e-create.test.ts +++ b/packages/cli/src/__tests__/e2e-create.test.ts @@ -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 () => { diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 36b1c1d..d5dacbd 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -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; + +const workflow: WorkflowDefinition> = { + 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>> { + 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"); }, }); -- 2.43.0