Merge pull request 'refactor(cli): nerve create workflow — role 拆成独立目录' (#207) from refactor/206-workflow-role-dirs into main
This commit was merged in pull request #207.
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user