refactor(cli,docs): RFC-005 Phase 4 — update templates, tests, docs (closes #271)

This commit is contained in:
2026-04-30 07:24:11 +00:00
parent d13b59e787
commit f799cee51f
7 changed files with 96 additions and 44 deletions
+4 -4
View File
@@ -6,8 +6,8 @@ Stateful multi-step execution driven by Roles and a Moderator.
- **Workflow** — definition with concurrency strategy
- **Thread** — one execution instance, unique `runId`
- **Role** — executes actions (has side effects). `(start, messages) → { content, meta }`
- **Moderator** — pure routing function. `(context) → next role | END`
- **Role** — executes actions (has side effects). `(ctx: ThreadContext) → Promise<RoleResult<M>>`
- **Moderator** — pure routing function. `(ctx: ThreadContext) → next role | END`
## Thread Lifecycle
@@ -54,7 +54,7 @@ const workflow: WorkflowDefinition<MyMeta> = {
```
- `adapter: AgentFn` — direct function reference
- `prompt: string | ((start, messages) => Promise<string>)` — static or dynamic
- `prompt: string | ((ctx: ThreadContext) => Promise<string>)` — static or dynamic
- `meta: z.ZodType<M>` — Zod schema, directly (no wrapper needed)
- `extract: LlmExtractorConfig` — provider for structured extraction
@@ -85,7 +85,7 @@ const workflow: WorkflowDefinition<MyMeta> = {
- Pure function constraint: cannot perform side effects
**Causal Chain Integrity**:
- Moderator receives immutable history: `{ start, steps }`
- Moderator receives immutable **ThreadContext**: `{ threadId, start, steps }`
- Steps array contains ALL role outputs in chronological order
- No role can modify prior steps or start metadata
- Thread context built from log store on crash recovery
+25
View File
@@ -98,6 +98,31 @@ type ComputeResult<T> =
| { signal: T; workflow: WorkflowTrigger | null };
```
### Workflow authoring (user modules)
Roles and moderators take **ThreadContext** (`threadId`, `start`, `steps`) — not separate `StartStep` / message arrays.
```typescript
import type { RoleResult, ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
type MyMeta = { round: number };
async function planner(ctx: ThreadContext): Promise<RoleResult<MyMeta>> {
void ctx.start;
void ctx.steps;
return { content: "plan", meta: { round: ctx.steps.length } };
}
const workflow: WorkflowDefinition<Record<"planner", MyMeta>> = {
name: "example",
roles: { planner },
moderator(ctx: ThreadContext<Record<"planner", MyMeta>>) {
return ctx.steps.length === 0 ? "planner" : END;
},
};
```
## Modules & Exports
- Always named exports, never default exports
+1
View File
@@ -7,6 +7,7 @@
"scripts": {
"prepare": "husky",
"build": "pnpm -r run build",
"test": "pnpm -r run test",
"check": "biome check .",
"format": "biome format --write .",
"link:dev": "bash scripts/link-dev.sh"
@@ -33,9 +33,11 @@ describe("buildWorkflowScaffold", () => {
expect(indexTs).toContain("@uncaged/nerve-core");
});
it("root index wires moderator and END", () => {
it("root index wires moderator with ThreadContext and END", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("moderator");
expect(indexTs).toContain("ThreadContext");
expect(indexTs).toContain("ctx.steps.length");
expect(indexTs).toContain("END");
});
@@ -46,9 +48,13 @@ describe("buildWorkflowScaffold", () => {
expect(indexTs).toContain("./roles/main/index.js");
});
it("main role module exports mainRole function", () => {
it("main role module exports mainRole with ThreadContext", () => {
const { roleMainIndexTs } = buildWorkflowScaffold("test");
expect(roleMainIndexTs).toContain("export async function mainRole");
expect(roleMainIndexTs).toContain("ThreadContext");
expect(roleMainIndexTs).toContain("RoleResult");
expect(roleMainIndexTs).not.toContain("StartStep");
expect(roleMainIndexTs).not.toContain("WorkflowMessage");
});
it("uses different names per call", () => {
+49 -21
View File
@@ -41,6 +41,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import type { NerveConfig } from "@uncaged/nerve-core";
import { createKernel } from "@uncaged/nerve-daemon";
@@ -61,6 +62,9 @@ const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.j
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js");
const requireDaemon = createRequire(join(nerveDaemonRoot, "package.json"));
const drizzleSqliteCoreHref = pathToFileURL(requireDaemon.resolve("drizzle-orm/sqlite-core")).href;
const nerveYamlTemplate = `senses:
counter:
group: e2e
@@ -88,17 +92,17 @@ const echoWorkflowIndexJs = `const END = "__end__";
export default {
name: "echo",
roles: {
echo: async (start, _messages) => {
echo: async (ctx) => {
await new Promise((r) => setTimeout(r, 350));
const p = typeof start.content === "string" ? start.content : "";
const p = typeof ctx.start.content === "string" ? ctx.start.content : "";
return {
content: p.length > 0 ? "echo:" + p : "echo:empty",
meta: {},
};
},
},
moderator({ steps }) {
if (steps.length === 0) return "echo";
moderator(ctx) {
if (ctx.steps.length === 0) return "echo";
return END;
},
};
@@ -121,30 +125,47 @@ api:
host: 127.0.0.1
`;
/** Empty migration — counter sense uses only `_signals` (auto-created by daemon). */
const counterMigration = `-- no-op migration for e2e counter sense
SELECT 1;
/** Schema for \`table\` export — rows mirror signal payloads inserted by the runtime. */
const counterMigration = `CREATE TABLE IF NOT EXISTS sense_payload (
count INTEGER,
launched INTEGER,
idle INTEGER
);
`;
/**
* Minimal counter sense — each compute returns an incrementing count.
* Does NOT touch the DB directly; signal persistence is handled by the daemon
* (`runtime.persistSignal`) which writes to `_signals` automatically.
*/
const counterIndexJs = `let _count = 0;
export async function compute(_db, _peers, _options) {
function buildCounterIndexJs(): string {
return `import { integer, sqliteTable } from "${drizzleSqliteCoreHref}";
export const table = sqliteTable("sense_payload", {
count: integer("count"),
launched: integer("launched"),
idle: integer("idle"),
});
let _count = 0;
export async function compute() {
_count += 1;
return { signal: { count: _count }, workflow: null };
}
`;
}
/** First trigger launches local noop workflow; later triggers emit a plain signal. */
const counterIndexJsWithNoopWorkflow = `let _launched = false;
export async function compute(_db, _peers, _options) {
function buildCounterIndexJsWithNoopWorkflow(): string {
return `import { integer, sqliteTable } from "${drizzleSqliteCoreHref}";
export const table = sqliteTable("sense_payload", {
count: integer("count"),
launched: integer("launched"),
idle: integer("idle"),
});
let _launched = false;
export async function compute() {
if (!_launched) {
_launched = true;
return {
signal: { launched: true },
signal: { launched: 1 },
workflow: {
name: "noop",
maxRounds: 3,
@@ -153,18 +174,25 @@ export async function compute(_db, _peers, _options) {
},
};
}
return { signal: { idle: true }, workflow: null };
return { signal: { idle: 1 }, workflow: null };
}
`;
}
/** Minimal workflow: moderator ends immediately (no role rounds). */
const noopWorkflowIndexJs = `const END = "__end__";
export default {
name: "noop",
roles: {
bot: async () => ({ content: "ok", meta: {} }),
bot: async (ctx) => {
void ctx;
return { content: "ok", meta: {} };
},
},
moderator(ctx) {
void ctx;
return END;
},
moderator: () => END,
};
`;
@@ -222,7 +250,7 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi
);
writeFileSync(
join(nerveRoot, "senses", "counter", "index.js"),
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
withNoopWorkflow ? buildCounterIndexJsWithNoopWorkflow() : buildCounterIndexJs(),
"utf8",
);
writeFileSync(
+6 -8
View File
@@ -52,7 +52,7 @@ export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
}
function buildWorkflowIndexTs(name: string): string {
return `import type { WorkflowDefinition } from "@uncaged/nerve-core";
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { mainRole } from "./roles/main/index.js";
@@ -64,8 +64,8 @@ const workflow: WorkflowDefinition<Record<"main", MainMeta>> = {
roles: {
main: mainRole,
},
moderator({ steps }) {
if (steps.length === 0) {
moderator(ctx: ThreadContext<Record<"main", MainMeta>>) {
if (ctx.steps.length === 0) {
return "main";
}
return END;
@@ -77,18 +77,16 @@ export default workflow;
}
function buildWorkflowMainRoleIndexTs(name: string): string {
return `import type { RoleResult, StartStep, WorkflowMessage } from "@uncaged/nerve-core";
return `import type { RoleResult, ThreadContext } 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[],
ctx: ThreadContext,
): Promise<RoleResult<Record<string, unknown>>> {
void start;
void messages;
void ctx;
// TODO: implement your role logic here
return {
content: "${name} started",
+3 -9
View File
@@ -19,7 +19,7 @@ import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { NerveConfig, WorkflowTrigger } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import type { WorkerToParentMessage } from "./ipc.js";
import { parseParentMessage } from "./ipc.js";
@@ -49,10 +49,6 @@ function sendError(sense: string, error: string): void {
send({ type: "error", sense, error });
}
function sendWorkflowTrigger(sense: string, workflow: WorkflowTrigger): void {
send({ type: "sense-workflow-trigger", sense, workflow });
}
// ---------------------------------------------------------------------------
// Initialisation helpers
// ---------------------------------------------------------------------------
@@ -154,10 +150,8 @@ async function runCompute(
}
clearGracePeriodTimer(senseName);
if (result.value != null) {
sendSignal(senseName, result.value.signal);
if (result.value.workflow !== null) {
sendWorkflowTrigger(senseName, result.value.workflow);
}
// Full ComputeResult — kernel `routeSenseComputeOutput` extracts signal + optional workflow.
sendSignal(senseName, result.value);
}
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);