diff --git a/.knowledge/workflow.md b/.knowledge/workflow.md index f9d0d50..1ec8e95 100644 --- a/.knowledge/workflow.md +++ b/.knowledge/workflow.md @@ -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>` +- **Moderator** — pure routing function. `(ctx: ThreadContext) → next role | END` ## Thread Lifecycle @@ -54,7 +54,7 @@ const workflow: WorkflowDefinition = { ``` - `adapter: AgentFn` — direct function reference -- `prompt: string | ((start, messages) => Promise)` — static or dynamic +- `prompt: string | ((ctx: ThreadContext) => Promise)` — static or dynamic - `meta: z.ZodType` — Zod schema, directly (no wrapper needed) - `extract: LlmExtractorConfig` — provider for structured extraction @@ -85,7 +85,7 @@ const workflow: WorkflowDefinition = { - 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 diff --git a/CLAUDE.md b/CLAUDE.md index 5c203e0..0c1fe51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,31 @@ type ComputeResult = | { 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> { + void ctx.start; + void ctx.steps; + return { content: "plan", meta: { round: ctx.steps.length } }; +} + +const workflow: WorkflowDefinition> = { + name: "example", + roles: { planner }, + moderator(ctx: ThreadContext>) { + return ctx.steps.length === 0 ? "planner" : END; + }, +}; +``` + ## Modules & Exports - Always named exports, never default exports diff --git a/package.json b/package.json index f324577..d10b97d 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/cli/src/__tests__/create-workflow.test.ts b/packages/cli/src/__tests__/create-workflow.test.ts index d1593b1..212c654 100644 --- a/packages/cli/src/__tests__/create-workflow.test.ts +++ b/packages/cli/src/__tests__/create-workflow.test.ts @@ -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", () => { diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index bffc4ce..b0375f6 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -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( diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index c8685a2..0db440e 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -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> = { roles: { main: mainRole, }, - moderator({ steps }) { - if (steps.length === 0) { + moderator(ctx: ThreadContext>) { + 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>> { - void start; - void messages; + void ctx; // TODO: implement your role logic here return { content: "${name} started", diff --git a/packages/daemon/src/sense-worker.ts b/packages/daemon/src/sense-worker.ts index e6d2b35..5933050 100644 --- a/packages/daemon/src/sense-worker.ts +++ b/packages/daemon/src/sense-worker.ts @@ -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);