From 2d63639ed19531925b32c71effb9dad21498808e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 28 Apr 2026 02:30:12 +0000 Subject: [PATCH] refactor(sense-generator): split roles into separate directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following nerve-dev best practice: each role gets its own directory. Structure: index.ts — 31 lines (WorkflowDefinition + moderator) roles/planner/index.ts — 48 lines (createCursorRole) roles/coder/index.ts — 33 lines (createCursorRole) roles/tester/index.ts — 122 lines (hand-written smoke test) roles/shared.ts — 63 lines (providers, helpers) roles/types.ts — 5 lines (SenseMeta) Was: single 416-line index.ts Refs uncaged/nerve#210 小橘 🍊(NEKO Team) --- workflows/sense-generator/index.ts | 303 +----------------- .../sense-generator/roles/coder/index.ts | 33 ++ .../sense-generator/roles/planner/index.ts | 48 +++ workflows/sense-generator/roles/shared.ts | 63 ++++ .../sense-generator/roles/tester/index.ts | 122 +++++++ workflows/sense-generator/roles/types.ts | 5 + 6 files changed, 279 insertions(+), 295 deletions(-) create mode 100644 workflows/sense-generator/roles/coder/index.ts create mode 100644 workflows/sense-generator/roles/planner/index.ts create mode 100644 workflows/sense-generator/roles/shared.ts create mode 100644 workflows/sense-generator/roles/tester/index.ts create mode 100644 workflows/sense-generator/roles/types.ts diff --git a/workflows/sense-generator/index.ts b/workflows/sense-generator/index.ts index d0351fc..20468cc 100644 --- a/workflows/sense-generator/index.ts +++ b/workflows/sense-generator/index.ts @@ -1,305 +1,18 @@ -import type { - RoleResult, - StartStep, - WorkflowDefinition, - WorkflowMessage, -} from "@uncaged/nerve-core"; +import type { WorkflowDefinition } from "@uncaged/nerve-core"; import { END } from "@uncaged/nerve-core"; -import { createCursorRole, spawnSafe } from "@uncaged/nerve-workflow-utils"; -import type { SpawnError } from "@uncaged/nerve-workflow-utils"; -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { z } from "zod"; +import { buildPlannerRole } from "./roles/planner/index.js"; +import { buildCoderRole } from "./roles/coder/index.js"; +import { tester } from "./roles/tester/index.js"; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const HOME = process.env.HOME ?? "/home/azureuser"; -const NERVE_ROOT = join(HOME, ".uncaged-nerve"); -const SENSES_DIR = join(NERVE_ROOT, "senses"); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function formatSpawnFailure(error: SpawnError): string { - if (error.kind === "spawn_failed") return error.message; - if (error.kind === "timeout") return `timeout (stdout=${error.stdout.slice(0, 200)})`; - return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`; -} - -async function cfgGet(key: string): Promise { - const result = await spawnSafe("cfg", ["get", key], { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 10_000, - }); - if (!result.ok) return null; - return result.value.stdout.trim() || null; -} - -async function resolveDashScopeProvider(): Promise<{ - baseUrl: string; - apiKey: string; - model: string; -} | null> { - const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY")); - const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL")); - const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus"; - if (!apiKey || !baseUrl) return null; - return { apiKey, baseUrl, model }; -} - -function getNerveYaml(): string { - try { - return readFileSync(join(NERVE_ROOT, "nerve.yaml"), "utf-8"); - } catch { - return "# nerve.yaml unavailable"; - } -} - -function buildSenseExamples(): string { - const examples: string[] = []; - for (const name of ["cpu-usage", "linux-system-health"]) { - const dir = join(SENSES_DIR, name); - if (!existsSync(dir)) continue; - const indexFile = existsSync(join(dir, "index.js")) - ? readFileSync(join(dir, "index.js"), "utf-8") - : ""; - const schema = existsSync(join(dir, "schema.ts")) - ? readFileSync(join(dir, "schema.ts"), "utf-8") - : ""; - const migrationDir = join(dir, "migrations"); - let migration = ""; - if (existsSync(join(migrationDir, "0001_init.sql"))) { - migration = readFileSync(join(migrationDir, "0001_init.sql"), "utf-8"); - } - examples.push( - `### Example sense: ${name}\n\n` + - `**index.js:**\n\`\`\`js\n${indexFile}\n\`\`\`\n\n` + - `**schema.ts:**\n\`\`\`ts\n${schema}\n\`\`\`\n\n` + - `**migrations/0001_init.sql:**\n\`\`\`sql\n${migration}\n\`\`\``, - ); - } - return examples.join("\n\n---\n\n"); -} - -async function runSenseSmokeTest( - senseName: string, -): Promise<{ ok: boolean; log: string; reason: string }> { - const logParts: string[] = []; - - const runNerve = async ( - args: string[], - ): Promise<{ ok: true; out: string } | { ok: false; err: string }> => { - const result = await spawnSafe("nerve", args, { - cwd: NERVE_ROOT, - env: null, - timeoutMs: 300_000, - }); - if (!result.ok) return { ok: false, err: formatSpawnFailure(result.error) }; - return { ok: true, out: result.value.stdout }; - }; - - const statusRun = await runNerve(["status"]); - if (!statusRun.ok) { - return { - ok: false, - log: `=== nerve status ===\nERROR: ${statusRun.err}`, - reason: `Smoke test command failed: ${statusRun.err}`, - }; - } - logParts.push("=== nerve status ===\n" + statusRun.out); - if (!statusRun.out.includes(senseName)) { - return { - ok: false, - log: logParts.join("\n\n"), - reason: `Sense "${senseName}" not listed in \`nerve status\` output`, - }; - } - - const triggerRun = await runNerve(["sense", "trigger", senseName]); - if (!triggerRun.ok) { - logParts.push(`=== nerve sense trigger ===\nERROR: ${triggerRun.err}`); - return { ok: false, log: logParts.join("\n\n"), reason: `Trigger failed: ${triggerRun.err}` }; - } - logParts.push("=== nerve sense trigger ===\n" + triggerRun.out); - - let lastQuery = ""; - for (let i = 0; i < 25; i++) { - await new Promise((r) => setTimeout(r, 1000)); - const queryRun = await runNerve(["sense", "query", senseName]); - if (!queryRun.ok) { - logParts.push(`=== query attempt ${i + 1} ===\nERROR: ${queryRun.err}`); - } else { - lastQuery = queryRun.out; - logParts.push(`=== query attempt ${i + 1} ===\n${lastQuery}`); - if (!lastQuery.includes("(0 rows)")) { - return { - ok: true, - log: logParts.join("\n\n"), - reason: "Trigger succeeded and query returned at least one row", - }; - } - } - } - - return { - ok: false, - log: logParts.join("\n\n"), - reason: lastQuery.includes("(0 rows)") - ? "Query still returned 0 rows after trigger" - : "Timed out waiting for successful sense query", - }; -} - -// --------------------------------------------------------------------------- -// Meta — routing-only signals for the moderator -// --------------------------------------------------------------------------- - -type SenseMeta = { - planner: { senseName: string }; - coder: { filesCreated: boolean }; - tester: { passed: boolean; attempt: number }; -}; - -// --------------------------------------------------------------------------- -// Bake static context (read once at module load, not per-call) -// --------------------------------------------------------------------------- - -const senseExamples = buildSenseExamples(); -const nerveYaml = getNerveYaml(); - -// --------------------------------------------------------------------------- -// Roles -// --------------------------------------------------------------------------- - -async function buildPlannerRole() { - const provider = await resolveDashScopeProvider(); - if (provider === null) { - throw new Error("Cannot create planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); - } - return createCursorRole({ - cwd: NERVE_ROOT, - mode: "ask", - prompt: async (threadId) => - `You are planning a new Nerve sense. - -Read the workflow thread for the user's request: \`nerve thread ${threadId}\` - -Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown: - -## Sense Design -### Name — kebab-case -### Fields — name, type (integer/real/text), description -### Compute Logic — step-by-step, specific Node.js APIs or shell commands -### Trigger Config — group, interval, throttle, timeout - -Reference senses: -${senseExamples} - -Current nerve.yaml: -\`\`\`yaml -${nerveYaml} -\`\`\` - -Output ONLY the plan. Be precise and implementation-ready.`, - extract: { - provider, - schema: z.object({ - senseName: z.string().describe("kebab-case sense name from the plan"), - }), - }, - }); -} - -async function buildCoderRole() { - const provider = await resolveDashScopeProvider(); - if (provider === null) { - throw new Error("Cannot create coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); - } - return createCursorRole({ - cwd: NERVE_ROOT, - mode: "default", - prompt: async (threadId) => - `Read the workflow thread for the planner's sense design: \`nerve thread ${threadId}\` - -Implement the sense. Create exactly: -1. The sense directory under ${SENSES_DIR}// -2. index.js — export async function compute(db, _peers), import schema from "./schema.ts" -3. schema.ts — drizzle-orm/sqlite-core -4. migrations/0001_init.sql — must match schema.ts -5. Update ${NERVE_ROOT}/nerve.yaml — add sense config + reflex entry - -Follow the patterns from existing senses. Create all files now.`, - extract: { - provider, - schema: z.object({ - filesCreated: z.boolean().describe("true if the sense files were created"), - }), - }, - }); -} - -// Tester: pure CLI logic — stays hand-written -async function tester( - _start: StartStep, - messages: WorkflowMessage[], -): Promise> { - const attempt = messages.filter((m) => m.role === "tester").length + 1; - - // Get senseName from planner meta - const plannerStep = messages.find((m) => m.role === "planner"); - const senseName = plannerStep - ? (plannerStep.meta as SenseMeta["planner"]).senseName - : ""; - - if (senseName.length === 0) { - return { - content: "FAIL — no senseName from planner", - meta: { passed: false, attempt }, - }; - } - - // Check files exist - const senseDir = join(SENSES_DIR, senseName); - const missing = [ - existsSync(join(senseDir, "index.js")) ? null : "index.js", - existsSync(join(senseDir, "schema.ts")) ? null : "schema.ts", - existsSync(join(senseDir, "migrations", "0001_init.sql")) ? null : "migrations/0001_init.sql", - ].filter((x) => x !== null); - - if (missing.length > 0) { - return { - content: `FAIL — missing files: ${missing.join(", ")}`, - meta: { passed: false, attempt }, - }; - } - - // Smoke test - const smoke = await runSenseSmokeTest(senseName); - return { - content: `${smoke.ok ? "PASS" : "FAIL"} — ${smoke.reason}`, - meta: { passed: smoke.ok, attempt }, - }; -} - -// --------------------------------------------------------------------------- -// Workflow definition -// --------------------------------------------------------------------------- +import type { SenseMeta } from "./roles/types.js"; async function buildWorkflow(): Promise> { - const plannerRole = await buildPlannerRole(); - const coderRole = await buildCoderRole(); + const planner = await buildPlannerRole(); + const coder = await buildCoderRole(); return { name: "sense-generator", - roles: { - planner: plannerRole, - coder: coderRole, - tester, - }, + roles: { planner, coder, tester }, moderator(context) { if (context.steps.length === 0) return "planner"; const last = context.steps[context.steps.length - 1]; diff --git a/workflows/sense-generator/roles/coder/index.ts b/workflows/sense-generator/roles/coder/index.ts new file mode 100644 index 0000000..fbf51ee --- /dev/null +++ b/workflows/sense-generator/roles/coder/index.ts @@ -0,0 +1,33 @@ +import { createCursorRole } from "@uncaged/nerve-workflow-utils"; +import { z } from "zod"; +import { resolveDashScopeProvider, NERVE_ROOT, SENSES_DIR } from "../shared.js"; + +import type { SenseMeta } from "../types.js"; + +export async function buildCoderRole() { + const provider = await resolveDashScopeProvider(); + if (provider === null) { + throw new Error("Cannot create coder: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); + } + return createCursorRole({ + cwd: NERVE_ROOT, + mode: "default", + prompt: async (threadId) => + `Read the workflow thread for the planner's sense design: \`nerve thread ${threadId}\` + +Implement the sense. Create exactly: +1. The sense directory under ${SENSES_DIR}// +2. index.js — export async function compute(db, _peers), import schema from "./schema.ts" +3. schema.ts — drizzle-orm/sqlite-core +4. migrations/0001_init.sql — must match schema.ts +5. Update ${NERVE_ROOT}/nerve.yaml — add sense config + reflex entry + +Follow the patterns from existing senses. Create all files now.`, + extract: { + provider, + schema: z.object({ + filesCreated: z.boolean().describe("true if the sense files were created"), + }), + }, + }); +} diff --git a/workflows/sense-generator/roles/planner/index.ts b/workflows/sense-generator/roles/planner/index.ts new file mode 100644 index 0000000..07c111f --- /dev/null +++ b/workflows/sense-generator/roles/planner/index.ts @@ -0,0 +1,48 @@ +import { createCursorRole } from "@uncaged/nerve-workflow-utils"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { z } from "zod"; +import { resolveDashScopeProvider, buildSenseExamples, getNerveYaml, NERVE_ROOT } from "../shared.js"; +import type { SenseMeta } from "../types.js"; + +const senseExamples = buildSenseExamples(); +const nerveYaml = getNerveYaml(); + +export async function buildPlannerRole() { + const provider = await resolveDashScopeProvider(); + if (provider === null) { + throw new Error("Cannot create planner: set DASHSCOPE_API_KEY and DASHSCOPE_BASE_URL"); + } + return createCursorRole({ + cwd: NERVE_ROOT, + mode: "ask", + prompt: async (threadId) => + `You are planning a new Nerve sense. + +Read the workflow thread for the user's request: \`nerve thread ${threadId}\` + +Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown: + +## Sense Design +### Name — kebab-case +### Fields — name, type (integer/real/text), description +### Compute Logic — step-by-step, specific Node.js APIs or shell commands +### Trigger Config — group, interval, throttle, timeout + +Reference senses: +${senseExamples} + +Current nerve.yaml: +\`\`\`yaml +${nerveYaml} +\`\`\` + +Output ONLY the plan. Be precise and implementation-ready.`, + extract: { + provider, + schema: z.object({ + senseName: z.string().describe("kebab-case sense name from the plan"), + }), + }, + }); +} diff --git a/workflows/sense-generator/roles/shared.ts b/workflows/sense-generator/roles/shared.ts new file mode 100644 index 0000000..3efa84b --- /dev/null +++ b/workflows/sense-generator/roles/shared.ts @@ -0,0 +1,63 @@ +import { spawnSafe } from "@uncaged/nerve-workflow-utils"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const HOME = process.env.HOME ?? "/home/azureuser"; +export const NERVE_ROOT = join(HOME, ".uncaged-nerve"); +export const SENSES_DIR = join(NERVE_ROOT, "senses"); + +export async function cfgGet(key: string): Promise { + const result = await spawnSafe("cfg", ["get", key], { + cwd: NERVE_ROOT, + env: null, + timeoutMs: 10_000, + }); + if (!result.ok) return null; + return result.value.stdout.trim() || null; +} + +export async function resolveDashScopeProvider(): Promise<{ + baseUrl: string; + apiKey: string; + model: string; +} | null> { + const apiKey = process.env.DASHSCOPE_API_KEY ?? (await cfgGet("DASHSCOPE_API_KEY")); + const baseUrl = process.env.DASHSCOPE_BASE_URL ?? (await cfgGet("DASHSCOPE_BASE_URL")); + const model = process.env.DASHSCOPE_MODEL ?? (await cfgGet("DASHSCOPE_MODEL")) ?? "qwen-plus"; + if (!apiKey || !baseUrl) return null; + return { apiKey, baseUrl, model }; +} + +export function getNerveYaml(): string { + try { + return readFileSync(join(NERVE_ROOT, "nerve.yaml"), "utf-8"); + } catch { + return "# nerve.yaml unavailable"; + } +} + +export function buildSenseExamples(): string { + const examples: string[] = []; + for (const name of ["cpu-usage", "linux-system-health"]) { + const dir = join(SENSES_DIR, name); + if (!existsSync(dir)) continue; + const indexFile = existsSync(join(dir, "index.js")) + ? readFileSync(join(dir, "index.js"), "utf-8") + : ""; + const schema = existsSync(join(dir, "schema.ts")) + ? readFileSync(join(dir, "schema.ts"), "utf-8") + : ""; + const migrationDir = join(dir, "migrations"); + let migration = ""; + if (existsSync(join(migrationDir, "0001_init.sql"))) { + migration = readFileSync(join(migrationDir, "0001_init.sql"), "utf-8"); + } + examples.push( + `### Example sense: ${name}\n\n` + + `**index.js:**\n\`\`\`js\n${indexFile}\n\`\`\`\n\n` + + `**schema.ts:**\n\`\`\`ts\n${schema}\n\`\`\`\n\n` + + `**migrations/0001_init.sql:**\n\`\`\`sql\n${migration}\n\`\`\``, + ); + } + return examples.join("\n\n---\n\n"); +} diff --git a/workflows/sense-generator/roles/tester/index.ts b/workflows/sense-generator/roles/tester/index.ts new file mode 100644 index 0000000..9a343eb --- /dev/null +++ b/workflows/sense-generator/roles/tester/index.ts @@ -0,0 +1,122 @@ +import type { RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core"; +import { spawnSafe } from "@uncaged/nerve-workflow-utils"; +import type { SpawnError } from "@uncaged/nerve-workflow-utils"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { NERVE_ROOT, SENSES_DIR } from "../shared.js"; + +import type { SenseMeta } from "../types.js"; + +function formatSpawnFailure(error: SpawnError): string { + if (error.kind === "spawn_failed") return error.message; + if (error.kind === "timeout") return `timeout (stdout=${error.stdout.slice(0, 200)})`; + return `exit ${error.exitCode} stderr=${error.stderr.slice(0, 400)}`; +} + +async function runSenseSmokeTest( + senseName: string, +): Promise<{ ok: boolean; log: string; reason: string }> { + const logParts: string[] = []; + + const runNerve = async ( + args: string[], + ): Promise<{ ok: true; out: string } | { ok: false; err: string }> => { + const result = await spawnSafe("nerve", args, { + cwd: NERVE_ROOT, + env: null, + timeoutMs: 300_000, + }); + if (!result.ok) return { ok: false, err: formatSpawnFailure(result.error) }; + return { ok: true, out: result.value.stdout }; + }; + + const statusRun = await runNerve(["status"]); + if (!statusRun.ok) { + return { + ok: false, + log: `=== nerve status ===\nERROR: ${statusRun.err}`, + reason: `Smoke test command failed: ${statusRun.err}`, + }; + } + logParts.push("=== nerve status ===\n" + statusRun.out); + if (!statusRun.out.includes(senseName)) { + return { + ok: false, + log: logParts.join("\n\n"), + reason: `Sense "${senseName}" not listed in \`nerve status\` output`, + }; + } + + const triggerRun = await runNerve(["sense", "trigger", senseName]); + if (!triggerRun.ok) { + logParts.push(`=== nerve sense trigger ===\nERROR: ${triggerRun.err}`); + return { ok: false, log: logParts.join("\n\n"), reason: `Trigger failed: ${triggerRun.err}` }; + } + logParts.push("=== nerve sense trigger ===\n" + triggerRun.out); + + let lastQuery = ""; + for (let i = 0; i < 25; i++) { + await new Promise((r) => setTimeout(r, 1000)); + const queryRun = await runNerve(["sense", "query", senseName]); + if (!queryRun.ok) { + logParts.push(`=== query attempt ${i + 1} ===\nERROR: ${queryRun.err}`); + } else { + lastQuery = queryRun.out; + logParts.push(`=== query attempt ${i + 1} ===\n${lastQuery}`); + if (!lastQuery.includes("(0 rows)")) { + return { + ok: true, + log: logParts.join("\n\n"), + reason: "Trigger succeeded and query returned at least one row", + }; + } + } + } + + return { + ok: false, + log: logParts.join("\n\n"), + reason: lastQuery.includes("(0 rows)") + ? "Query still returned 0 rows after trigger" + : "Timed out waiting for successful sense query", + }; +} + +export async function tester( + _start: StartStep, + messages: WorkflowMessage[], +): Promise> { + const attempt = messages.filter((m) => m.role === "tester").length + 1; + + const plannerStep = messages.find((m) => m.role === "planner"); + const senseName = plannerStep + ? (plannerStep.meta as SenseMeta["planner"]).senseName + : ""; + + if (senseName.length === 0) { + return { + content: "FAIL — no senseName from planner", + meta: { passed: false, attempt }, + }; + } + + const senseDir = join(SENSES_DIR, senseName); + const missing = [ + existsSync(join(senseDir, "index.js")) ? null : "index.js", + existsSync(join(senseDir, "schema.ts")) ? null : "schema.ts", + existsSync(join(senseDir, "migrations", "0001_init.sql")) ? null : "migrations/0001_init.sql", + ].filter((x) => x !== null); + + if (missing.length > 0) { + return { + content: `FAIL — missing files: ${missing.join(", ")}`, + meta: { passed: false, attempt }, + }; + } + + const smoke = await runSenseSmokeTest(senseName); + return { + content: `${smoke.ok ? "PASS" : "FAIL"} — ${smoke.reason}`, + meta: { passed: smoke.ok, attempt }, + }; +} diff --git a/workflows/sense-generator/roles/types.ts b/workflows/sense-generator/roles/types.ts new file mode 100644 index 0000000..3abf1e7 --- /dev/null +++ b/workflows/sense-generator/roles/types.ts @@ -0,0 +1,5 @@ +export type SenseMeta = { + planner: { senseName: string }; + coder: { filesCreated: boolean }; + tester: { passed: boolean; attempt: number }; +};