diff --git a/packages/cli/package.json b/packages/cli/package.json index 49fbe90..520ebc4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,9 +10,7 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/cli/src/__tests__/logs.test.ts b/packages/cli/src/__tests__/logs.test.ts index 470de86..a15a1c2 100644 --- a/packages/cli/src/__tests__/logs.test.ts +++ b/packages/cli/src/__tests__/logs.test.ts @@ -234,7 +234,11 @@ describe("logsCommand negative offset", () => { it("exits with code 1 and writes to stderr when offset is negative", async () => { await expect( - logsCommand.run!({ args: { n: "50", offset: "-5", follow: false }, rawArgs: [], cmd: logsCommand as never }), + logsCommand.run!({ + args: { n: "50", offset: "-5", follow: false }, + rawArgs: [], + cmd: logsCommand as never, + }), ).rejects.toThrow("process.exit(1)"); expect(exitCode).toBe(1); expect(stderrOutput).toContain("--offset must be a non-negative integer"); @@ -243,7 +247,11 @@ describe("logsCommand negative offset", () => { it("exits with code 1 for offset=-1", async () => { await expect( - logsCommand.run!({ args: { n: "10", offset: "-1", follow: false }, rawArgs: [], cmd: logsCommand as never }), + logsCommand.run!({ + args: { n: "10", offset: "-1", follow: false }, + rawArgs: [], + cmd: logsCommand as never, + }), ).rejects.toThrow("process.exit(1)"); expect(exitCode).toBe(1); }); diff --git a/packages/cli/src/__tests__/workflow.test.ts b/packages/cli/src/__tests__/workflow.test.ts index 485d613..0ea583b 100644 --- a/packages/cli/src/__tests__/workflow.test.ts +++ b/packages/cli/src/__tests__/workflow.test.ts @@ -16,15 +16,15 @@ import { createLogStore } from "@uncaged/nerve-daemon"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + DEFAULT_THREAD_BUDGET_CHARS, buildInspectOutput, buildListOutput, buildThreadCommandOutput, - DEFAULT_THREAD_BUDGET_CHARS, formatThreadRoundBlock, formatTs, getAllWorkflowRuns, - partitionCommandEvent, parseIntArg, + partitionWorkflowMessage, statusIcon, } from "../commands/workflow.js"; import { triggerWorkflowViaDaemon } from "../daemon-client.js"; @@ -330,22 +330,20 @@ describe("workflow list — integration with real store", () => { // nerve workflow thread — formatting helpers // --------------------------------------------------------------------------- -describe("partitionCommandEvent", () => { - it("splits reserved type, role, content from rest", () => { - const p = partitionCommandEvent({ - type: "scan_done", +describe("partitionWorkflowMessage", () => { + it("extracts role, content, and meta", () => { + const p = partitionWorkflowMessage({ role: "scanner", content: "ok", - items: [1, 2], + meta: { items: [1, 2] }, }); - expect(p.typeStr).toBe("scan_done"); expect(p.roleStr).toBe("scanner"); expect(p.contentBody).toBe("ok"); - expect(p.rest).toEqual({ items: [1, 2] }); + expect(p.meta).toEqual({ items: [1, 2] }); }); it("uses fallback role and stringifies non-string content", () => { - const p = partitionCommandEvent({ type: "x", content: { n: 1 } }); + const p = partitionWorkflowMessage({ content: { n: 1 } }); expect(p.roleStr).toBe("?"); expect(p.contentBody).toBe('{"n":1}'); }); @@ -356,17 +354,15 @@ describe("formatThreadRoundBlock", () => { round: 2, logId: 99, ts: new Date("2026-01-02T03:04:05.006Z").getTime(), - event: { type: "reply", role: "bot", content: "hi", score: 0.5 }, + message: { role: "bot", content: "hi", meta: { score: 0.5 }, timestamp: 1735783445006 }, }; - it("includes header, YAML frontmatter for rest, and body", () => { + it("includes header, YAML frontmatter for meta, and body", () => { const text = formatThreadRoundBlock(row); expect(text).toContain("[#2 bot]"); - expect(text).toContain("type=reply"); expect(text).toContain("---\n"); expect(text).toContain("score: 0.5"); expect(text).toContain("hi"); - expect(text).not.toContain("role:"); }); }); @@ -376,7 +372,7 @@ describe("buildThreadCommandOutput", () => { round: n, logId: 10 + n, ts: 1000 + n, - event: { type: "ev", role: "r", content, extra: n }, + message: { role: "r", content, meta: { extra: n }, timestamp: 1000 + n }, }; } diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index e7386e9..d6185e0 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -183,45 +183,43 @@ export function buildInspectOutput( // nerve workflow thread — agent-oriented role rounds // --------------------------------------------------------------------------- -export type PartitionedEvent = { - typeStr: string; +export type PartitionedMessage = { roleStr: string; contentBody: string; - rest: Record; + meta: Record; }; /** - * Split a CommandEvent: `type`, `role`, and `content` are reserved for the - * header / body; all other fields are serialized as YAML frontmatter. + * Extract display fields from a WorkflowMessage-shaped object. + * `role` and `content` are used for header/body; `meta` is serialized as YAML frontmatter. */ -export function partitionCommandEvent(event: Record): PartitionedEvent { - const typeStr = - typeof event.type === "string" ? event.type : String(event.type === undefined ? "?" : event.type); - const roleStr = typeof event.role === "string" ? event.role : "?"; - const contentRaw = event.content; +export function partitionWorkflowMessage(msg: Record): PartitionedMessage { + const roleStr = typeof msg.role === "string" ? msg.role : "?"; + const contentRaw = msg.content; const contentBody = contentRaw === undefined || contentRaw === null ? "" : typeof contentRaw === "string" ? contentRaw : JSON.stringify(contentRaw); - const rest: Record = {}; - for (const key of Object.keys(event)) { - if (key === "type" || key === "role" || key === "content") continue; - rest[key] = event[key]; - } - return { typeStr, roleStr, contentBody, rest }; + const meta: Record = + msg.meta !== null && msg.meta !== undefined && typeof msg.meta === "object" + ? (msg.meta as Record) + : {}; + return { roleStr, contentBody, meta }; } /** - * One role round as plain text: header line, YAML frontmatter (`rest` only), body (`content`). + * One role round as plain text: header line, YAML frontmatter (meta only), body (content). */ export function formatThreadRoundBlock(row: ThreadRoundRow): string { - const { typeStr, roleStr, contentBody, rest } = partitionCommandEvent(row.event); + const { roleStr, contentBody, meta } = partitionWorkflowMessage( + row.message as unknown as Record, + ); const yamlBlock = - Object.keys(rest).length === 0 ? "{}\n" : `${stringify(rest, { lineWidth: 100 })}\n`; + Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`; return ( - `[#${row.round} ${roleStr}] ${formatTs(row.ts)} type=${typeStr}\n` + + `[#${row.round} ${roleStr}] ${formatTs(row.ts)}\n` + `---\n` + yamlBlock + `---\n` + @@ -259,13 +257,13 @@ export function buildThreadCommandOutput( continue; } if (picked.length === 0) { - const { typeStr, roleStr, contentBody, rest } = partitionCommandEvent(row.event); + const { roleStr, contentBody, meta } = partitionWorkflowMessage( + row.message as unknown as Record, + ); const yamlBlock = - Object.keys(rest).length === 0 - ? "{}\n" - : `${stringify(rest, { lineWidth: 100 })}\n`; + Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`; const header = - `[#${row.round} ${roleStr}] ${formatTs(row.ts)} type=${typeStr}\n` + `---\n` + yamlBlock + `---\n`; + `[#${row.round} ${roleStr}] ${formatTs(row.ts)}\n` + `---\n` + yamlBlock + `---\n`; const maxBody = Math.max(0, remaining - header.length - `[truncated]\n`.length); const truncated = maxBody > 0 && contentBody.length > maxBody diff --git a/packages/cli/src/daemon-types.ts b/packages/cli/src/daemon-types.ts index edb2fc2..e30d149 100644 --- a/packages/cli/src/daemon-types.ts +++ b/packages/cli/src/daemon-types.ts @@ -63,7 +63,7 @@ export type ThreadRoundRow = { round: number; logId: number; ts: number; - event: { type: string; [key: string]: unknown }; + message: { role: string; content: string; meta: unknown; timestamp: number }; }; /** Keep in sync with daemon `log-store` `GetThreadRoundsParams`. */ diff --git a/packages/core/package.json b/packages/core/package.json index 2a5cc48..7f5b071 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,9 +3,7 @@ "version": "0.3.0", "type": "module", "main": "dist/index.js", - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index 58114b3..2dcc23c 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -18,9 +18,6 @@ reflexes: - sense: memory on: - high_usage - - workflow: alert - on: - - cpu workflows: alert: @@ -48,7 +45,7 @@ describe("parseNerveConfig", () => { timeout: 10_000, gracePeriod: 3000, }); - expect(result.value.reflexes).toHaveLength(3); + expect(result.value.reflexes).toHaveLength(2); expect(result.value.reflexes[0]).toEqual({ kind: "sense", sense: "cpu", @@ -61,11 +58,6 @@ describe("parseNerveConfig", () => { interval: null, on: ["high_usage"], }); - expect(result.value.reflexes[2]).toEqual({ - kind: "workflow", - workflow: "alert", - on: ["cpu"], - }); expect(result.value.workflows?.alert).toEqual({ concurrency: 2, overflow: "queue", diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 577aa06..7eddeac 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -4,6 +4,8 @@ import type { Result } from "./result.js"; import { err, ok } from "./result.js"; import type { NerveConfig, ReflexConfig, SenseConfig, WorkflowConfig } from "./types.js"; +const DEFAULT_ENGINE_MAX_ROUNDS = 100; + const DURATION_RE = /^(\d+)([smh])$/; const DURATION_MULTIPLIERS: Record = { @@ -112,26 +114,6 @@ function parseSenseReflex( }); } -function parseWorkflowReflex( - index: number, - obj: Record, - on: string[] | null, -): Result { - if (typeof obj.workflow !== "string") { - return err(new Error(`reflexes[${index}].workflow: must be a string`)); - } - if (obj.interval !== undefined) { - return err( - new Error(`reflexes[${index}]: workflow reflex does not support "interval" (use "on")`), - ); - } - return ok({ - kind: "workflow" as const, - workflow: obj.workflow, - on, - }); -} - function validateReflexConfig( index: number, raw: unknown, @@ -143,22 +125,37 @@ function validateReflexConfig( const obj = raw as Record; const hasSense = obj.sense !== undefined; - const hasWorkflow = obj.workflow !== undefined; + const hasWorkflowKey = Object.hasOwn(obj, "workflow"); - if (hasSense && hasWorkflow) { - return err(new Error(`reflexes[${index}]: cannot have both "sense" and "workflow"`)); + if (hasWorkflowKey) { + return err( + new Error( + `reflexes[${index}]: YAML "workflow" entries are not supported — start workflows from a Sense compute return value using a "workflow" string field (format: name|maxRounds|prompt)`, + ), + ); } - if (!hasSense && !hasWorkflow) { - return err(new Error(`reflexes[${index}]: must have either "sense" or "workflow"`)); + if (!hasSense) { + return err(new Error(`reflexes[${index}]: must include "sense"`)); } const onResult = parseOnField(index, obj); if (!onResult.ok) return onResult; - if (hasSense) { - return parseSenseReflex(index, obj, senseNames, onResult.value); + return parseSenseReflex(index, obj, senseNames, onResult.value); +} + +function parseEngineMaxRounds(obj: Record): Result { + if (obj.max_rounds === undefined || obj.max_rounds === null) { + return ok(DEFAULT_ENGINE_MAX_ROUNDS); } - return parseWorkflowReflex(index, obj, onResult.value); + if ( + typeof obj.max_rounds !== "number" || + !Number.isInteger(obj.max_rounds) || + obj.max_rounds < 1 + ) { + return err(new Error("max_rounds: must be a positive integer")); + } + return ok(obj.max_rounds); } function validateWorkflowConfig(name: string, raw: unknown): Result { @@ -295,16 +292,11 @@ export function parseNerveConfig(raw: string): Result { const workflowsResult = parseWorkflows(obj); if (!workflowsResult.ok) return workflowsResult; - // Cross-validate: workflow reflexes must reference defined workflows - const workflowNames = new Set(workflowsResult.value ? Object.keys(workflowsResult.value) : []); - for (let i = 0; i < reflexesResult.value.length; i++) { - const reflex = reflexesResult.value[i]; - if (reflex.kind === "workflow" && !workflowNames.has(reflex.workflow)) { - return err(new Error(`reflexes[${i}].workflow: "${reflex.workflow}" not found in workflows`)); - } - } + const maxRoundsResult = parseEngineMaxRounds(obj); + if (!maxRoundsResult.ok) return maxRoundsResult; return ok({ + maxRounds: maxRoundsResult.value, senses, reflexes: reflexesResult.value, workflows: workflowsResult.value, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6562f00..36ea1db 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,21 +3,32 @@ export type { SenseConfig, SenseInfo, SenseReflexConfig, - WorkflowReflexConfig, ReflexConfig, DropOverflowConfig, QueueOverflowConfig, WorkflowConfig, NerveConfig, - CommandEvent, - ThreadState, - ModerateResult, - WorkflowContext, - RoleExecuteFn, + WorkflowMessage, + RoleResult, Role, - ModerateFn, + RoleMeta, + StartSignal, + RoleSignal, + Moderator, WorkflowDefinition, + SenseResult, } from "./types.js"; +export { START, END } from "./types.js"; export type { Result } from "./result.js"; export { ok, err } from "./result.js"; export { parseNerveConfig } from "./config.js"; + +export function parseWorkflowField(field: string): { name: string; maxRounds: number; prompt: string } { + const [name, rounds, ...rest] = field.split("|"); + const prompt = rest.join("|"); + const maxRounds = parseInt(rounds, 10); + return { name: name ?? "", maxRounds, prompt }; +} + +export type { ParsedSenseWorkflowDirective, SenseComputeRoute } from "./sense-workflow-directive.js"; +export { parseSenseWorkflowDirective, routeSenseComputeOutput } from "./sense-workflow-directive.js"; diff --git a/packages/core/src/sense-workflow-directive.ts b/packages/core/src/sense-workflow-directive.ts new file mode 100644 index 0000000..e4ec75c --- /dev/null +++ b/packages/core/src/sense-workflow-directive.ts @@ -0,0 +1,76 @@ +import type { Result } from "./result.js"; +import { err, ok } from "./result.js"; + +/** Parsed `workflow-name|maxRounds|prompt` from a Sense compute return value. */ +export type ParsedSenseWorkflowDirective = { + workflowName: string; + maxRounds: number; + prompt: string; +}; + +/** + * Parses the pipe-separated `workflow` field from a Sense compute result. + * `prompt` may contain `|` — only the first two pipes delimit name and rounds. + */ +export function parseSenseWorkflowDirective(field: string): Result { + const trimmed = field.trim(); + if (trimmed.length === 0) { + return err(new Error("workflow directive is empty")); + } + const parts = trimmed.split("|"); + if (parts.length < 3) { + return err( + new Error( + `workflow directive must be "name|maxRounds|prompt" (got ${String(parts.length)} segment(s))`, + ), + ); + } + const workflowName = (parts[0] ?? "").trim(); + if (workflowName.length === 0) { + return err(new Error("workflow directive: empty workflow name")); + } + const roundsRaw = (parts[1] ?? "").trim(); + const maxRounds = Number.parseInt(roundsRaw, 10); + if (!Number.isInteger(maxRounds) || maxRounds < 1) { + return err(new Error(`workflow directive: invalid maxRounds "${roundsRaw}"`)); + } + const prompt = parts.slice(2).join("|"); + return ok({ workflowName, maxRounds, prompt }); +} + +export type SenseComputeRoute = + | { kind: "launch"; launch: ParsedSenseWorkflowDirective } + | { kind: "signal"; payload: unknown }; + +function stripWorkflowKey(payload: Record): Record { + const { workflow: _drop, ...rest } = payload; + return rest; +} + +/** + * Interprets a Sense compute non-null return value for the engine: + * - `workflow` missing → normal signal with full payload + * - `workflow: null` or `""` → normal signal; `workflow` key stripped from emitted payload + * - `workflow: "name|n|prompt"` → launch workflow; no Signal is emitted to the bus + */ +export function routeSenseComputeOutput(payload: unknown): SenseComputeRoute { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + return { kind: "signal", payload }; + } + const obj = payload as Record; + if (!Object.hasOwn(obj, "workflow")) { + return { kind: "signal", payload }; + } + const w = obj.workflow; + if (w === null || w === "") { + return { kind: "signal", payload: stripWorkflowKey(obj) }; + } + if (typeof w !== "string") { + return { kind: "signal", payload }; + } + const parsed = parseSenseWorkflowDirective(w); + if (!parsed.ok) { + return { kind: "signal", payload: stripWorkflowKey(obj) }; + } + return { kind: "launch", launch: parsed.value }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3c7c0bb..63482d4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -28,13 +28,8 @@ export type SenseReflexConfig = { on: string[] | null; }; -export type WorkflowReflexConfig = { - kind: "workflow"; - workflow: string; - on: string[] | null; -}; - -export type ReflexConfig = SenseReflexConfig | WorkflowReflexConfig; +/** Reflexes only schedule Senses; workflow launches come from Sense return values. */ +export type ReflexConfig = SenseReflexConfig; export type DropOverflowConfig = { concurrency: number; @@ -50,62 +45,75 @@ export type QueueOverflowConfig = { export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig; export type NerveConfig = { + /** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */ + maxRounds: number; senses: Record; reflexes: ReflexConfig[]; workflows: Record | null; }; // --------------------------------------------------------------------------- -// Workflow Engine types (RFC-002) +// Workflow Automaton types (issue #80) // --------------------------------------------------------------------------- -/** A single event in the command event stream that drives a workflow thread. */ -export type CommandEvent = { - type: string; - [key: string]: unknown; -}; +export const START = "__start__" as const; +export const END = "__end__" as const; +export type START = typeof START; +export type END = typeof END; -/** Accumulated state of a running thread — the event history for moderate(). */ -export type ThreadState = { - runId: string; - /** All events so far, including the initial thread_start event. */ - events: CommandEvent[]; -}; - -/** The result of moderate() — which role to hand to next, and what prompt to pass. */ -export type ModerateResult = { +/** A single message in the workflow conversation chain (runtime, type-erased). */ +export type WorkflowMessage = { role: string; - prompt: unknown; + content: string; + meta: unknown; + timestamp: number; }; -/** Context injected into every role execute() call. */ -export type WorkflowContext = { - runId: string; - workflowName: string; - /** Emit a log message back to the parent process. */ - log: (message: string) => void; -}; +/** The typed output of a Role execution. */ +export type RoleResult = { content: string; meta: Meta }; /** - * A role's execute function. Has side effects (API calls, file I/O, etc.). - * Returns a CommandEvent that is fed back into moderate(). + * A Role is a pure async function: receives the full message chain, + * returns typed content + meta. Implementation can be an agent, LLM call, + * script, HTTP request, etc. */ -export type RoleExecuteFn = (prompt: unknown, ctx: WorkflowContext) => Promise; +export type Role = (messages: WorkflowMessage[]) => Promise>; -/** A role in a workflow — a named unit of execution with side effects. */ -export type Role = { - execute: RoleExecuteFn; +/** Maps role names to their meta types — the single generic that drives all inference. */ +export type RoleMeta = Record>; + +/** First message in the thread chain (`messages[0]`) — passed to the moderator on start. */ +export type StartSignal = { + role: START; + content: string; + meta: { maxRounds: number }; + timestamp: number; }; +/** A discriminated union of signals from each role, derived from the meta map. */ +export type RoleSignal = { + [K in keyof M & string]: { role: K; meta: M[K] }; +}[keyof M & string]; + /** - * The moderator function — pure, no side effects. - * Decides which role to pass control to next. - * Returns null to signal thread completion. + * The moderator — a pure routing function. Receives the last signal, + * current round, and maxRounds. Returns the next role name or END. */ -export type ModerateFn = (thread: ThreadState, event: CommandEvent) => ModerateResult | null; +export type Moderator = ( + signal: StartSignal | RoleSignal, + round: number, + maxRounds: number, +) => (keyof M & string) | END; /** The complete definition of a workflow, as authored by users. */ -export type WorkflowDefinition = { - roles: Record; - moderate: ModerateFn; +export type WorkflowDefinition = { + name: string; + roles: { [K in keyof M & string]: Role }; + moderator: Moderator; +}; + +/** The result of a Sense compute — payload plus optional workflow directive. */ +export type SenseResult = { + payload: unknown; + workflow: string | null; }; diff --git a/packages/daemon/package.json b/packages/daemon/package.json index f26d047..ca2272d 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -4,9 +4,7 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "publishConfig": { "access": "public" }, diff --git a/packages/daemon/src/__tests__/crash-recovery.test.ts b/packages/daemon/src/__tests__/crash-recovery.test.ts index 52f99f5..119cc20 100644 --- a/packages/daemon/src/__tests__/crash-recovery.test.ts +++ b/packages/daemon/src/__tests__/crash-recovery.test.ts @@ -64,6 +64,7 @@ function makeConfig(workflows: Record = {}): NerveConfig senses: {}, reflexes: [], workflows, + maxRounds: 10, }; } @@ -90,7 +91,14 @@ function makeLogStore( return activeRuns; }), getTriggerPayload: vi.fn((): unknown => ({ value: 42 })), - getThreadEvents: vi.fn((): Array<{ type: string; [key: string]: unknown }> => [{ type: "thread_start", triggerPayload: {} }]), + getThreadEvents: vi.fn( + (): Array<{ type: string; [key: string]: unknown }> => [ + { type: "thread_start", triggerPayload: {} }, + ], + ), + getThreadMessages: vi.fn( + (): Array<{ role: string; content: string; meta: unknown; timestamp: number }> => [], + ), getThreadRoundCount: vi.fn(() => 0), getThreadRounds: vi.fn(() => []), archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })), @@ -119,8 +127,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", { n: 1 }); - mgr.startWorkflow("my-wf", { n: 2 }); + mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10 }); + mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10 }); expect(mgr.activeCount("my-wf")).toBe(2); // Simulate unexpected exit (not shutdown) @@ -146,8 +154,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", {}); - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); expect(mgr.activeCount("my-wf")).toBe(2); const child = mockChildren[0]; @@ -171,7 +179,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); expect(mockChildren).toHaveLength(1); const child = mockChildren[0]; @@ -194,9 +202,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { { runId: "run-started-1", workflow: "my-wf", status: "started" as const, ts: 1000 }, ]; const logStore = makeLogStore(activeRuns); - logStore.getThreadEvents.mockReturnValue([ - { type: "thread_start", triggerPayload: {} }, - { type: "scan_complete", items: ["a"] }, + logStore.getThreadMessages.mockReturnValue([ + { role: "scanner", content: "done", meta: { items: ["a"] }, timestamp: 1000 }, ]); logStore.getTriggerPayload.mockReturnValue({ trigger: "initial" }); @@ -205,7 +212,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); const firstChild = mockChildren[0]; firstChild.exitCode = 1; firstChild.connected = false; @@ -230,7 +237,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { runId: "run-started-1", triggerPayload: { trigger: "initial" }, }); - expect(Array.isArray((resumeCalls[0][0] as Record).events)).toBe(true); + expect(Array.isArray((resumeCalls[0][0] as Record).messages)).toBe(true); const stopPromise = mgr.stop(); await vi.runAllTimersAsync(); @@ -250,7 +257,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { const mgr = createWorkflowManager("/nerve-root", config, logStore); // Start one thread to fill the concurrency slot (so queued run stays queued on respawn) - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); const firstChild = mockChildren[0]; firstChild.exitCode = 1; firstChild.connected = false; @@ -267,34 +274,33 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { }); }); - describe("command events are persisted (for crash recovery replay)", () => { - it("persists thread_command_event when worker sends thread-command-event IPC", async () => { + describe("workflow messages are persisted (for crash recovery replay)", () => { + it("persists thread_workflow_message when worker sends thread-workflow-message IPC", async () => { const logStore = makeLogStore(); const config = makeConfig({ "my-wf": { concurrency: 1, overflow: "drop" }, }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", { x: 1 }); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); const child = mockChildren[0]; const startCall = (child.send as ReturnType).mock.calls[0]; const runId = (startCall[0] as Record).runId as string; - // Simulate worker sending a command event back child.emit("message", { - type: "thread-command-event", + type: "thread-workflow-message", runId, - event: { type: "scan_complete", items: ["a", "b"] }, + message: { role: "scanner", content: "done", meta: { items: ["a", "b"] }, timestamp: 1000 }, }); const appendCalls = logStore.append.mock.calls.filter( - (args: any[]) => (args[0] as { type: string }).type === "thread_command_event", + (args: any[]) => (args[0] as { type: string }).type === "thread_workflow_message", ); expect(appendCalls).toHaveLength(1); expect(appendCalls[0][0]).toMatchObject({ source: "workflow", - type: "thread_command_event", + type: "thread_workflow_message", refId: runId, }); @@ -312,7 +318,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - const payload = { task: "build-docker", repo: "myrepo" }; + const payload = { prompt: "build-docker for myrepo", maxRounds: 10 }; mgr.startWorkflow("my-wf", payload); const startedCall = logStore.upsertWorkflowRun.mock.calls.find( @@ -344,7 +350,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { const mgr = createWorkflowManager("/nerve-root", config, logStore); // Start one thread to fill the concurrency slot - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); const firstChild = mockChildren[0]; // Crash once → respawn → crash again → second respawn @@ -372,7 +378,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { { runId: "run-started-dup", workflow: "my-wf", status: "started" as const, ts: 1000 }, ]; const logStore = makeLogStore(activeRuns); - logStore.getThreadEvents.mockReturnValue([{ type: "thread_start", triggerPayload: {} }]); + logStore.getThreadMessages.mockReturnValue([]); logStore.getTriggerPayload.mockReturnValue({ s: 1 }); const config = makeConfig({ @@ -380,7 +386,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); const firstChild = mockChildren[0]; firstChild.exitCode = 1; firstChild.connected = false; @@ -410,7 +416,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("crash-wf", {}); + mgr.startWorkflow("crash-wf", { prompt: "test", maxRounds: 10 }); // Crash the worker 6 times in rapid succession (within CRASH_WINDOW_MS = 60s) for (let i = 0; i < 6; i++) { diff --git a/packages/daemon/src/__tests__/daemon-ipc.test.ts b/packages/daemon/src/__tests__/daemon-ipc.test.ts index 5b37b74..a400acc 100644 --- a/packages/daemon/src/__tests__/daemon-ipc.test.ts +++ b/packages/daemon/src/__tests__/daemon-ipc.test.ts @@ -63,7 +63,10 @@ function sendRaw(path: string, message: object): Promise { } beforeEach(() => { - sockPath = join(tmpdir(), `nerve-ipc-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`); + sockPath = join( + tmpdir(), + `nerve-ipc-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`, + ); }); afterEach(async () => { diff --git a/packages/daemon/src/__tests__/hot-reload.test.ts b/packages/daemon/src/__tests__/hot-reload.test.ts index 35e5071..692192e 100644 --- a/packages/daemon/src/__tests__/hot-reload.test.ts +++ b/packages/daemon/src/__tests__/hot-reload.test.ts @@ -62,7 +62,7 @@ const { createWorkflowManager } = await import("../workflow-manager.js"); const { createKernel } = await import("../kernel.js"); function makeWfConfig(workflows: Record = {}): NerveConfig { - return { senses: {}, reflexes: [], workflows }; + return { senses: {}, reflexes: [], workflows, maxRounds: 10 }; } function makeLogStore() { @@ -77,6 +77,7 @@ function makeLogStore() { getActiveWorkflowRuns: vi.fn(() => []), getTriggerPayload: vi.fn(() => null), getThreadEvents: vi.fn(() => []), + getThreadMessages: vi.fn(() => []), getThreadRoundCount: vi.fn(() => 0), getThreadRounds: vi.fn(() => []), archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })), @@ -101,7 +102,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => { const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); expect(mockChildren).toHaveLength(1); // Remove workflow from config before drain completes @@ -120,8 +121,8 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => { const config = makeWfConfig({ "my-wf": { concurrency: 2, overflow: "drop" } }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", { n: 1 }); - mgr.startWorkflow("my-wf", { n: 2 }); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); expect(mgr.activeCount("my-wf")).toBe(2); const drainPromise = mgr.drainAndRespawn("my-wf", 5000); @@ -152,7 +153,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => { const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); expect(mockChildren).toHaveLength(1); const drainPromise = mgr.drainAndRespawn("my-wf", 5000); @@ -168,7 +169,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => { const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); expect(mockChildren).toHaveLength(1); const drainPromise = mgr.drainAndRespawn("my-wf", 5000); @@ -185,7 +186,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => { const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", {}); + mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); const drainPromise = mgr.drainAndRespawn("my-wf", 5000); await vi.runAllTimersAsync(); @@ -210,14 +211,14 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => { const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-wf", { first: true }); + mgr.startWorkflow("my-wf", { prompt: "first", maxRounds: 10 }); const drainPromise = mgr.drainAndRespawn("my-wf", 5000); await vi.runAllTimersAsync(); await drainPromise; // Start a new thread on the fresh worker - mgr.startWorkflow("my-wf", { second: true }); + mgr.startWorkflow("my-wf", { prompt: "second", maxRounds: 10 }); const newChild = mockChildren[1]; const startCalls = (newChild.send as ReturnType).mock.calls.filter( @@ -249,8 +250,9 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { const logStore = makeLogStore(); const config: NerveConfig = { senses: {}, - reflexes: [{ kind: "workflow", workflow: "my-wf", on: null }], + reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any], workflows: { "my-wf": { concurrency: 1, overflow: "drop" } }, + maxRounds: 10, }; const kernel = createKernel(config, "/tmp/nerve-hot-reload-test", { @@ -259,7 +261,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { }); // Trigger a workflow thread so a worker is spawned - kernel.workflowManager.startWorkflow("my-wf", {}); + kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); // Manually call drainAndRespawn (simulating what kernel does on workflow file change) const drainPromise = kernel.workflowManager.drainAndRespawn("my-wf", 1000); @@ -269,7 +271,9 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { // Kernel's handleWorkflowFileChange should log a workflow_reload event // We test this via the kernel itself const appendCalls = logStore.append.mock.calls; - const startCall = appendCalls.find((args: any[]) => (args[0] as { type: string }).type === "start"); + const startCall = appendCalls.find( + (args: any[]) => (args[0] as { type: string }).type === "start", + ); expect(startCall).toBeDefined(); const stopPromise = kernel.stop(); @@ -281,8 +285,9 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { const logStore = makeLogStore(); const initialConfig: NerveConfig = { senses: {}, - reflexes: [{ kind: "workflow", workflow: "old-wf", on: null }], + reflexes: [{ kind: "workflow", workflow: "old-wf", on: null } as any], workflows: { "old-wf": { concurrency: 1, overflow: "drop" } }, + maxRounds: 10, }; const kernel = createKernel(initialConfig, "/tmp/nerve-hot-reload-test", { @@ -291,7 +296,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { }); // Spawn a worker for old-wf - kernel.workflowManager.startWorkflow("old-wf", {}); + kernel.workflowManager.startWorkflow("old-wf", { prompt: "test", maxRounds: 10 }); expect(mockChildren).toHaveLength(1); // Reload config without old-wf @@ -299,6 +304,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { senses: {}, reflexes: [], workflows: null, + maxRounds: 10, }; kernel.reloadConfig(newConfig); @@ -318,8 +324,9 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { const logStore = makeLogStore(); const initialConfig: NerveConfig = { senses: {}, - reflexes: [{ kind: "workflow", workflow: "my-wf", on: null }], + reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any], workflows: { "my-wf": { concurrency: 1, overflow: "drop" } }, + maxRounds: 10, }; const kernel = createKernel(initialConfig, "/tmp/nerve-hot-reload-test", { @@ -327,14 +334,15 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { logStore, }); - kernel.workflowManager.startWorkflow("my-wf", {}); + kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); const workersBefore = mockChildren.length; // Reload with updated concurrency — should NOT spawn a new workflow worker const newConfig: NerveConfig = { senses: {}, - reflexes: [{ kind: "workflow", workflow: "my-wf", on: null }], + reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any], workflows: { "my-wf": { concurrency: 5, overflow: "queue", maxQueue: 50 } }, + maxRounds: 10, }; kernel.reloadConfig(newConfig); @@ -346,8 +354,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => { expect(kernel.workflowManager.activeCount("my-wf")).toBe(1); // Can now start up to 5 concurrent threads (previously only 1) - kernel.workflowManager.startWorkflow("my-wf", { n: 2 }); - kernel.workflowManager.startWorkflow("my-wf", { n: 3 }); + kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); + kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 }); expect(kernel.workflowManager.activeCount("my-wf")).toBe(3); const stopPromise = kernel.stop(); diff --git a/packages/daemon/src/__tests__/kernel-integration.test.ts b/packages/daemon/src/__tests__/kernel-integration.test.ts index ea7fa8a..d86daef 100644 --- a/packages/daemon/src/__tests__/kernel-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-integration.test.ts @@ -27,6 +27,7 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, reflexes: [], workflows: null, + maxRounds: 10, ...overrides, }; } diff --git a/packages/daemon/src/__tests__/kernel-phase6.test.ts b/packages/daemon/src/__tests__/kernel-phase6.test.ts index 5b4f4de..c575ec8 100644 --- a/packages/daemon/src/__tests__/kernel-phase6.test.ts +++ b/packages/daemon/src/__tests__/kernel-phase6.test.ts @@ -74,6 +74,7 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, reflexes: [], workflows: null, + maxRounds: 10, ...overrides, }; } @@ -180,6 +181,7 @@ describe("kernel — reloadConfig", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }); expect(kernel.groups.has("network")).toBe(true); @@ -196,6 +198,7 @@ describe("kernel — reloadConfig", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }; const kernel = createKernel(config, "/tmp/nerve-test"); @@ -210,6 +213,7 @@ describe("kernel — reloadConfig", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }); expect(kernel.groups.has("network")).toBe(false); @@ -232,6 +236,7 @@ describe("kernel — reloadConfig", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }); expect(kernel.getHealth().activeSenses).toBe(2); diff --git a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts index b87be6a..297d22c 100644 --- a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts +++ b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts @@ -74,6 +74,7 @@ function makeMockLogStore() { getAllWorkflowRuns: vi.fn(() => []), getTriggerPayload: vi.fn(() => null), getThreadEvents: vi.fn(() => []), + getThreadMessages: vi.fn(() => []), getThreadRoundCount: vi.fn(() => 0), getThreadRounds: vi.fn(() => []), archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })), @@ -92,6 +93,7 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, reflexes: [], workflows: null, + maxRounds: 10, ...overrides, }; } @@ -193,9 +195,7 @@ describe("kernel.triggerSense()", () => { // Should not throw even when the worker is disconnected expect(() => kernel.triggerSense("cpu-usage")).not.toThrow(); - expect(worker.send).not.toHaveBeenCalledWith( - expect.objectContaining({ type: "compute" }), - ); + expect(worker.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "compute" })); await kernel.stop(); }, 10_000); diff --git a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts index 4a8b19a..0ab735e 100644 --- a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts @@ -81,6 +81,7 @@ function makeLogStore() { getAllWorkflowRuns: vi.fn(() => []), getTriggerPayload: vi.fn(() => null), getThreadEvents: vi.fn(() => []), + getThreadMessages: vi.fn(() => []), getThreadRoundCount: vi.fn(() => 0), getThreadRounds: vi.fn(() => []), archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })), @@ -95,6 +96,7 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, reflexes: [], workflows: null, + maxRounds: 10, ...overrides, }; } @@ -121,7 +123,7 @@ describe("kernel + workflowManager integration", () => { senses: { "cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null }, }, - reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["cpu-usage"] }], + reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["cpu-usage"] } as any], workflows: { "my-workflow": { concurrency: 2, overflow: "drop" } }, }); @@ -159,7 +161,7 @@ describe("kernel + workflowManager integration", () => { senses: { "cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null }, }, - reflexes: [{ kind: "workflow", workflow: "alert-workflow", on: ["cpu-usage"] }], + reflexes: [{ kind: "workflow", workflow: "alert-workflow", on: ["cpu-usage"] } as any], workflows: { "alert-workflow": { concurrency: 1, overflow: "drop" } }, }); @@ -200,7 +202,7 @@ describe("kernel + workflowManager integration", () => { "cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null }, "disk-io": { group: "system", throttle: null, timeout: null, gracePeriod: null }, }, - reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["disk-io"] }], + reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["disk-io"] } as any], workflows: { "my-workflow": { concurrency: 1, overflow: "drop" } }, }); @@ -236,7 +238,7 @@ describe("kernel + workflowManager integration", () => { senses: { "cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null }, }, - reflexes: [{ kind: "workflow", workflow: "log-test-workflow", on: ["cpu-usage"] }], + reflexes: [{ kind: "workflow", workflow: "log-test-workflow", on: ["cpu-usage"] } as any], workflows: { "log-test-workflow": { concurrency: 2, overflow: "drop" } }, }); @@ -267,6 +269,7 @@ describe("kernel + workflowManager integration", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }); const kernel = createKernel(initialConfig, "/tmp/nerve-test", { @@ -279,8 +282,9 @@ describe("kernel + workflowManager integration", () => { senses: { "cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null }, }, - reflexes: [{ kind: "workflow", workflow: "new-workflow", on: ["cpu-usage"] }], + reflexes: [{ kind: "workflow", workflow: "new-workflow", on: ["cpu-usage"] } as any], workflows: { "new-workflow": { concurrency: 1, overflow: "drop" } }, + maxRounds: 10, }; kernel.reloadConfig(newConfig); @@ -310,7 +314,7 @@ describe("kernel + workflowManager integration", () => { senses: { "cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null }, }, - reflexes: [{ kind: "workflow", workflow: "old-workflow", on: ["cpu-usage"] }], + reflexes: [{ kind: "workflow", workflow: "old-workflow", on: ["cpu-usage"] } as any], workflows: { "old-workflow": { concurrency: 1, overflow: "drop" } }, }); @@ -326,6 +330,7 @@ describe("kernel + workflowManager integration", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }; kernel.reloadConfig(newConfig); @@ -361,7 +366,7 @@ describe("kernel + workflowManager integration", () => { senses: { "cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null }, }, - reflexes: [{ kind: "workflow", workflow: "shutdown-test", on: ["cpu-usage"] }], + reflexes: [{ kind: "workflow", workflow: "shutdown-test", on: ["cpu-usage"] } as any], workflows: { "shutdown-test": { concurrency: 1, overflow: "drop" } }, }); @@ -403,7 +408,7 @@ describe("kernel + workflowManager integration", () => { senses: { "cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null }, }, - reflexes: [{ kind: "workflow", workflow: "health-wf", on: ["cpu-usage"] }], + reflexes: [{ kind: "workflow", workflow: "health-wf", on: ["cpu-usage"] } as any], workflows: { "health-wf": { concurrency: 2, overflow: "drop" } }, }); diff --git a/packages/daemon/src/__tests__/kernel.test.ts b/packages/daemon/src/__tests__/kernel.test.ts index 101c9b6..e14e401 100644 --- a/packages/daemon/src/__tests__/kernel.test.ts +++ b/packages/daemon/src/__tests__/kernel.test.ts @@ -60,6 +60,7 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, reflexes: [], workflows: null, + maxRounds: 10, ...overrides, }; } @@ -200,6 +201,7 @@ describe("kernel — groupForSense mapping", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }; const kernel = createKernel(config, "/tmp/nerve-test"); diff --git a/packages/daemon/src/__tests__/log-store-crash-recovery.test.ts b/packages/daemon/src/__tests__/log-store-crash-recovery.test.ts index 7145b98..979a82e 100644 --- a/packages/daemon/src/__tests__/log-store-crash-recovery.test.ts +++ b/packages/daemon/src/__tests__/log-store-crash-recovery.test.ts @@ -230,8 +230,8 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => { const all = store.getThreadRounds("run-tr", { before: 0, limit: 50 }); expect(all).toHaveLength(2); expect(all.map((r) => r.round)).toEqual([2, 1]); - expect(all[0].event.type).toBe("step_b"); - expect(all[1].event.type).toBe("step_a"); + expect(all[0].message.role).toBe("beta"); + expect(all[1].message.role).toBe("alpha"); }); it("getThreadRounds respects exclusive before bound", () => { diff --git a/packages/daemon/src/__tests__/log-store-integration.test.ts b/packages/daemon/src/__tests__/log-store-integration.test.ts index 57f10e9..5b36249 100644 --- a/packages/daemon/src/__tests__/log-store-integration.test.ts +++ b/packages/daemon/src/__tests__/log-store-integration.test.ts @@ -30,6 +30,7 @@ describe("LogStore + ReflexScheduler integration", () => { }, reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }], workflows: null, + maxRounds: 10, }; const bus = createSignalBus(); const triggered: string[] = []; @@ -57,6 +58,7 @@ describe("LogStore + ReflexScheduler integration", () => { }, reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }], workflows: null, + maxRounds: 10, }; const bus = createSignalBus(); const ref: { scheduler: ReturnType | null } = { scheduler: null }; @@ -87,6 +89,7 @@ describe("LogStore + ReflexScheduler integration", () => { }, reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }], workflows: null, + maxRounds: 10, }; const bus = createSignalBus(); const triggered: string[] = []; diff --git a/packages/daemon/src/__tests__/phase6-integration.test.ts b/packages/daemon/src/__tests__/phase6-integration.test.ts index 20ae945..58021f2 100644 --- a/packages/daemon/src/__tests__/phase6-integration.test.ts +++ b/packages/daemon/src/__tests__/phase6-integration.test.ts @@ -24,6 +24,7 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, reflexes: [], workflows: null, + maxRounds: 10, ...overrides, }; } @@ -136,6 +137,7 @@ describe("phase6 — reloadConfig", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }; kernel.reloadConfig(newConfig); @@ -155,6 +157,7 @@ describe("phase6 — reloadConfig", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }; kernel = createKernel(config, "/tmp/nerve-phase6-test", { workerScript: MOCK_WORKER, @@ -169,6 +172,7 @@ describe("phase6 — reloadConfig", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }; kernel.reloadConfig(newConfig); @@ -199,6 +203,7 @@ describe("phase6 — error isolation", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }; kernel = createKernel(config, "/tmp/nerve-phase6-test", { @@ -302,6 +307,7 @@ describe("phase6 — getHealth", () => { }, reflexes: [], workflows: null, + maxRounds: 10, }; kernel.reloadConfig(newConfig); diff --git a/packages/daemon/src/__tests__/reflex-scheduler-throttle-pending.test.ts b/packages/daemon/src/__tests__/reflex-scheduler-throttle-pending.test.ts index 2edf5dd..bea9b3e 100644 --- a/packages/daemon/src/__tests__/reflex-scheduler-throttle-pending.test.ts +++ b/packages/daemon/src/__tests__/reflex-scheduler-throttle-pending.test.ts @@ -11,6 +11,7 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, reflexes: [], workflows: null, + maxRounds: 10, ...overrides, }; } diff --git a/packages/daemon/src/__tests__/reflex-scheduler.test.ts b/packages/daemon/src/__tests__/reflex-scheduler.test.ts index 1e1d657..764ba2c 100644 --- a/packages/daemon/src/__tests__/reflex-scheduler.test.ts +++ b/packages/daemon/src/__tests__/reflex-scheduler.test.ts @@ -17,6 +17,7 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }, reflexes: [], workflows: null, + maxRounds: 10, ...overrides, }; } @@ -290,10 +291,11 @@ describe("ReflexScheduler — workflow reflexes ignored", () => { it("does not set up any scheduling for workflow kind reflexes", () => { const triggered: string[] = []; const config: NerveConfig = { + maxRounds: 10, senses: { "cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null }, }, - reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["cpu-usage"] }], + reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["cpu-usage"] } as any], workflows: { "my-workflow": { concurrency: 1, overflow: "drop" }, }, diff --git a/packages/daemon/src/__tests__/workflow-manager.test.ts b/packages/daemon/src/__tests__/workflow-manager.test.ts index 7227f57..b736fb0 100644 --- a/packages/daemon/src/__tests__/workflow-manager.test.ts +++ b/packages/daemon/src/__tests__/workflow-manager.test.ts @@ -74,6 +74,7 @@ function makeLogStore() { getActiveWorkflowRuns: vi.fn(() => []), getTriggerPayload: vi.fn(() => null), getThreadEvents: vi.fn(() => []), + getThreadMessages: vi.fn(() => []), getThreadRoundCount: vi.fn(() => 0), getThreadRounds: vi.fn(() => []), archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })), @@ -84,6 +85,7 @@ function makeLogStore() { function makeConfig(overrides: Partial = {}): NerveConfig { return { + maxRounds: 10, senses: {}, reflexes: [], workflows: overrides as NerveConfig["workflows"], @@ -113,7 +115,7 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-workflow", { event: "test" }); + mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10 }); expect(mockChildren).toHaveLength(1); expect(mockChildren[0].send).toHaveBeenCalledWith( @@ -129,8 +131,8 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-workflow", { n: 1 }); - mgr.startWorkflow("my-workflow", { n: 2 }); + mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10 }); + mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10 }); // Only one forked child — worker is reused expect(mockChildren).toHaveLength(1); @@ -145,7 +147,7 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("my-workflow", { x: 1 }); + mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10 }); expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith( expect.objectContaining({ source: "workflow", type: "started" }), @@ -162,9 +164,9 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("drop-wf", { first: true }); + mgr.startWorkflow("drop-wf", { prompt: "first", maxRounds: 10 }); // now at limit — second call should be dropped - mgr.startWorkflow("drop-wf", { second: true }); + mgr.startWorkflow("drop-wf", { prompt: "second", maxRounds: 10 }); expect(mgr.activeCount("drop-wf")).toBe(1); expect(mgr.queueLength("drop-wf")).toBe(0); @@ -179,8 +181,8 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("drop-wf", {}); - mgr.startWorkflow("drop-wf", {}); + mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10 }); + mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10 }); const droppedCall = logStore.upsertWorkflowRun.mock.calls.find( ([entry]) => entry.type === "dropped", @@ -197,8 +199,8 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("queue-wf", { first: true }); - mgr.startWorkflow("queue-wf", { second: true }); + mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 }); + mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 }); expect(mgr.activeCount("queue-wf")).toBe(1); expect(mgr.queueLength("queue-wf")).toBe(1); @@ -211,8 +213,8 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("queue-wf", {}); - mgr.startWorkflow("queue-wf", {}); + mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10 }); + mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10 }); const queuedCall = logStore.upsertWorkflowRun.mock.calls.find( ([entry]) => entry.type === "queued", @@ -231,12 +233,12 @@ describe("WorkflowManager", () => { const mgr = createWorkflowManager("/nerve-root", config, logStore); // Fill the concurrency slot - mgr.startWorkflow("queue-wf", { n: 0 }); + mgr.startWorkflow("queue-wf", { prompt: "test 0", maxRounds: 10 }); // Fill the queue to maxQueue - mgr.startWorkflow("queue-wf", { n: 1 }); - mgr.startWorkflow("queue-wf", { n: 2 }); - // This one should push out { n: 1 } - mgr.startWorkflow("queue-wf", { n: 3 }); + mgr.startWorkflow("queue-wf", { prompt: "test 1", maxRounds: 10 }); + mgr.startWorkflow("queue-wf", { prompt: "test 2", maxRounds: 10 }); + // This one should push out the oldest + mgr.startWorkflow("queue-wf", { prompt: "test 3", maxRounds: 10 }); // Queue should still be at maxQueue (2) expect(mgr.queueLength("queue-wf")).toBe(2); @@ -257,8 +259,8 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("queue-wf", { first: true }); - mgr.startWorkflow("queue-wf", { second: true }); + mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 }); + mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 }); expect(mgr.activeCount("queue-wf")).toBe(1); expect(mgr.queueLength("queue-wf")).toBe(1); @@ -292,8 +294,8 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("queue-wf", { first: true }); - mgr.startWorkflow("queue-wf", { second: true }); + mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 }); + mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 }); const child = mockChildren[0]; const firstRunId = (child.send as ReturnType).mock.calls[0][0].runId as string; @@ -319,8 +321,8 @@ describe("WorkflowManager", () => { }); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("wf-a", {}); - mgr.startWorkflow("wf-b", {}); + mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10 }); + mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10 }); // Two distinct workers should have been forked expect(mockChildren).toHaveLength(2); @@ -346,7 +348,7 @@ describe("WorkflowManager", () => { await vi.runAllTimersAsync(); await stopPromise; - mgr.startWorkflow("wf-a", {}); + mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10 }); // No worker should have been spawned expect(mockChildren).toHaveLength(0); @@ -359,7 +361,7 @@ describe("WorkflowManager", () => { const config = makeConfig({}); const mgr = createWorkflowManager("/nerve-root", config, logStore); - mgr.startWorkflow("no-such-workflow", {}); + mgr.startWorkflow("no-such-workflow", { prompt: "test", maxRounds: 10 }); expect(mockChildren).toHaveLength(0); expect(logStore.upsertWorkflowRun).not.toHaveBeenCalled(); diff --git a/packages/daemon/src/daemon-ipc.ts b/packages/daemon/src/daemon-ipc.ts index bca647c..bb2e58f 100644 --- a/packages/daemon/src/daemon-ipc.ts +++ b/packages/daemon/src/daemon-ipc.ts @@ -23,7 +23,8 @@ export type { SenseInfo }; export type TriggerWorkflowRequest = { type: "trigger-workflow"; workflow: string; - payload: unknown; + prompt: string; + maxRounds: number; }; /** JSON message sent by the CLI to trigger a sense compute on-demand. */ @@ -55,7 +56,9 @@ function parseRequest(line: string): DaemonRequest | null { const req = obj as Record; if (req.type === "trigger-workflow") { if (typeof req.workflow !== "string" || req.workflow.length === 0) return null; - return { type: "trigger-workflow", workflow: req.workflow, payload: req.payload ?? {} }; + if (typeof req.prompt !== "string") return null; + if (typeof req.maxRounds !== "number") return null; + return { type: "trigger-workflow", workflow: req.workflow, prompt: req.prompt, maxRounds: req.maxRounds as number }; } if (req.type === "trigger-sense") { if (typeof req.sense !== "string" || req.sense.length === 0) return null; @@ -102,7 +105,7 @@ export function createDaemonIpcServer( try { if (req.type === "trigger-workflow") { - workflowManager.startWorkflow(req.workflow, req.payload); + workflowManager.startWorkflow(req.workflow, { prompt: req.prompt, maxRounds: req.maxRounds }); const resp: DaemonResponse = { ok: true }; socket.write(`${JSON.stringify(resp)}\n`); } else if (req.type === "trigger-sense") { diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index be51c7b..ea5bd36 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -12,6 +12,7 @@ export type { ResumeThreadMessage, ThreadEventMessage, WorkflowErrorMessage, + ThreadWorkflowMessageMessage, } from "./ipc.js"; export type { SignalBus, SignalHandler, Unsubscribe } from "./signal-bus.js"; diff --git a/packages/daemon/src/ipc.ts b/packages/daemon/src/ipc.ts index 46077c4..b03626f 100644 --- a/packages/daemon/src/ipc.ts +++ b/packages/daemon/src/ipc.ts @@ -31,18 +31,19 @@ export type StartThreadMessage = { type: "start-thread"; runId: string; workflow: string; - /** The trigger payload from the Reflex that initiated this thread. */ - triggerPayload: unknown; + prompt: string; + /** Safety-valve: max moderator rounds for this thread (engine launch parameter). */ + maxRounds: number; }; /** Parent → Workflow Worker: resume an existing thread after crash recovery */ export type ResumeThreadMessage = { type: "resume-thread"; runId: string; - /** Serialised CommandEvent history to rebuild ThreadState. */ - events: Array<{ type: string; [key: string]: unknown }>; - /** Serialised trigger payload (the same value as in the original start-thread). */ - triggerPayload: unknown; + /** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */ + messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>; + /** Safety-valve: max moderator rounds for this thread. */ + maxRounds: number; }; /** Union of all messages the parent sends to a worker */ @@ -103,12 +104,12 @@ export type WorkflowErrorMessage = { error: string; }; -/** Workflow Worker → Parent: a thread CommandEvent produced by a role (for crash recovery). */ -export type ThreadCommandEventMessage = { - type: "thread-command-event"; +/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */ +export type ThreadWorkflowMessageMessage = { + type: "thread-workflow-message"; runId: string; - /** The CommandEvent returned by role.execute() — will be persisted for crash recovery. */ - event: { type: string; [key: string]: unknown }; + /** The WorkflowMessage produced by the role — persisted for crash recovery. */ + message: { role: string; content: string; meta: unknown; timestamp: number }; }; /** Union of all messages a worker sends to the parent */ @@ -119,7 +120,7 @@ export type WorkerToParentMessage = | HealthResponseMessage | ThreadEventMessage | WorkflowErrorMessage - | ThreadCommandEventMessage; + | ThreadWorkflowMessageMessage; const PARENT_MSG_TYPES = new Set([ "compute", @@ -132,14 +133,16 @@ const PARENT_MSG_TYPES = new Set([ function validateStartThreadMsg(obj: Record): string | null { if (typeof obj.runId !== "string") return "'start-thread' message missing string 'runId'"; if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'"; - if (!("triggerPayload" in obj)) return "'start-thread' message missing 'triggerPayload'"; + if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'"; + if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'"; return null; } function validateResumeThreadMsg(obj: Record): string | null { if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'"; - if (!Array.isArray(obj.events)) return "'resume-thread' message missing 'events' array"; - if (!("triggerPayload" in obj)) return "'resume-thread' message missing 'triggerPayload'"; + if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array"; + if (typeof obj.maxRounds !== "number") + return "'resume-thread' message missing number 'maxRounds'"; return null; } @@ -245,24 +248,34 @@ const WORKER_MSG_TYPES = new Set([ "health-response", "thread-event", "workflow-error", - "thread-command-event", + "thread-workflow-message", ]); -function parseThreadCommandEventMsg( +function parseThreadWorkflowMessageMsg( obj: Record, raw: unknown, ): Result { if (typeof obj.runId !== "string") { - return err(new Error("Worker 'thread-command-event' message missing string 'runId' field")); + return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field")); } - if (obj.event === null || typeof obj.event !== "object") { - return err(new Error("Worker 'thread-command-event' message missing object 'event' field")); + if (obj.message === null || typeof obj.message !== "object") { + return err(new Error("Worker 'thread-workflow-message' missing object 'message' field")); } - const event = obj.event as Record; - if (typeof event.type !== "string") { - return err(new Error("Worker 'thread-command-event' event missing string 'type' field")); + const msg = obj.message as Record; + if (typeof msg.role !== "string") { + return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field")); } - return ok(raw as ThreadCommandEventMessage); + if (typeof msg.content !== "string") { + return err( + new Error("Worker 'thread-workflow-message' message missing string 'content' field"), + ); + } + if (typeof msg.timestamp !== "number") { + return err( + new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"), + ); + } + return ok(raw as ThreadWorkflowMessageMessage); } /** Validate and parse an unknown IPC message received from a worker process. */ @@ -282,6 +295,6 @@ export function parseWorkerMessage(raw: unknown): Result if (obj.type === "health-response") return parseHealthResponseMsg(obj, raw); if (obj.type === "thread-event") return parseThreadEventMsg(obj, raw); if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj, raw); - if (obj.type === "thread-command-event") return parseThreadCommandEventMsg(obj, raw); + if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj, raw); return ok({ type: "ready" }); } diff --git a/packages/daemon/src/kernel.ts b/packages/daemon/src/kernel.ts index 26acabd..ddc6aee 100644 --- a/packages/daemon/src/kernel.ts +++ b/packages/daemon/src/kernel.ts @@ -19,7 +19,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { NerveConfig, SenseInfo, Signal } from "@uncaged/nerve-core"; -import { parseNerveConfig } from "@uncaged/nerve-core"; +import { parseNerveConfig, routeSenseComputeOutput } from "@uncaged/nerve-core"; import { createDaemonIpcServer } from "./daemon-ipc.js"; import type { DaemonIpcServer } from "./daemon-ipc.js"; @@ -230,20 +230,33 @@ export function createKernel( } if (msg.type === "signal") { - const signal: Signal = { - id: nextSignalId(), - senseId: msg.sense, - payload: msg.payload, - ts: Date.now(), - }; - logStore.append({ - source: "sense", - type: "signal", - refId: msg.sense, - payload: JSON.stringify(msg.payload), - ts: signal.ts, - }); - bus.emit(signal); + const route = routeSenseComputeOutput(msg.payload); + if (route.kind === "launch") { + const { workflowName, maxRounds, prompt } = route.launch; + workflowManager.startWorkflow(workflowName, { prompt, maxRounds }); + logStore.append({ + source: "sense", + type: "workflow-launch", + refId: msg.sense, + payload: JSON.stringify(route.launch), + ts: Date.now(), + }); + } else { + const signal: Signal = { + id: nextSignalId(), + senseId: msg.sense, + payload: route.payload, + ts: Date.now(), + }; + logStore.append({ + source: "sense", + type: "signal", + refId: msg.sense, + payload: JSON.stringify(route.payload), + ts: signal.ts, + }); + bus.emit(signal); + } scheduler.onComputeComplete(msg.sense); } @@ -319,9 +332,6 @@ export function createKernel( scheduler = createReflexScheduler(config, bus, triggerFn, { logStore, - workflowTriggerFn: (workflowName, payload) => { - workflowManager.startWorkflow(workflowName, payload); - }, }); if (groups.size === 0) { @@ -408,9 +418,6 @@ export function createKernel( scheduler.stop(); scheduler = createReflexScheduler(config, bus, triggerFn, { logStore, - workflowTriggerFn: (workflowName, payload) => { - workflowManager.startWorkflow(workflowName, payload); - }, }); // Update workflow concurrency/overflow config incrementally — no restart needed workflowManager.updateConfig(newConfig); diff --git a/packages/daemon/src/log-store.ts b/packages/daemon/src/log-store.ts index e197085..000e599 100644 --- a/packages/daemon/src/log-store.ts +++ b/packages/daemon/src/log-store.ts @@ -83,12 +83,12 @@ export type WorkflowRun = { ts: number; }; -/** One role-produced command-event row with 1-based round index (ROW_NUMBER over role events only). */ +/** One role-produced workflow-message row with 1-based round index (ROW_NUMBER over role messages only). */ export type ThreadRoundRow = { round: number; logId: number; ts: number; - event: { type: string; [key: string]: unknown }; + message: { role: string; content: string; meta: unknown; timestamp: number }; }; /** Parameters for {@link LogStore.getThreadRounds}. */ @@ -136,9 +136,16 @@ export type LogStore = { getTriggerPayload: (runId: string) => unknown; /** * Get all workflow CommandEvents for a specific run, ordered by id ASC. - * Used for crash recovery to rebuild ThreadState. + * @deprecated Use getThreadMessages for the new WorkflowMessage format. */ getThreadEvents: (runId: string) => Array<{ type: string; [key: string]: unknown }>; + /** + * Get all WorkflowMessages for a specific run, ordered by id ASC. + * Used for crash recovery to rebuild the message chain. + */ + getThreadMessages: ( + runId: string, + ) => Array<{ role: string; content: string; meta: unknown; timestamp: number }>; /** * Count role command events for a run (excludes `thread_start` and invalid payloads). * Round indices for {@link getThreadRounds} are 1..count in chronological order. @@ -309,9 +316,13 @@ export function createLogStore(dbPath: string): LogStore { "SELECT payload FROM logs WHERE source = 'workflow' AND type = 'thread_command_event' AND ref_id = ? ORDER BY id ASC", ); + const getThreadMessagesStmt = sqlite.prepare( + "SELECT payload FROM logs WHERE source = 'workflow' AND type = 'thread_workflow_message' AND ref_id = ? ORDER BY id ASC", + ); + const getThreadRoundCountStmt = sqlite.prepare( `SELECT COUNT(*) AS c FROM logs - WHERE source = 'workflow' AND type = 'thread_command_event' AND ref_id = ? + WHERE source = 'workflow' AND type IN ('thread_command_event', 'thread_workflow_message') AND ref_id = ? AND payload IS NOT NULL AND json_valid(payload) = 1 AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_start'`, ); @@ -321,7 +332,7 @@ export function createLogStore(dbPath: string): LogStore { SELECT id, ts, payload, ROW_NUMBER() OVER (ORDER BY id ASC) AS rn FROM logs - WHERE source = 'workflow' AND type = 'thread_command_event' AND ref_id = @runId + WHERE source = 'workflow' AND type IN ('thread_command_event', 'thread_workflow_message') AND ref_id = @runId AND payload IS NOT NULL AND json_valid(payload) = 1 AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_start' ) @@ -527,6 +538,38 @@ export function createLogStore(dbPath: string): LogStore { return result; } + function tryParseWorkflowMessage( + payload: string, + ): { role: string; content: string; meta: unknown; timestamp: number } | null { + try { + const parsed = JSON.parse(payload) as unknown; + if (parsed === null || typeof parsed !== "object") return null; + const obj = parsed as Record; + if (typeof obj.role !== "string" || typeof obj.content !== "string") return null; + return { + role: obj.role, + content: obj.content, + meta: obj.meta, + timestamp: typeof obj.timestamp === "number" ? obj.timestamp : 0, + }; + } catch { + return null; + } + } + + function getThreadMessages( + runId: string, + ): Array<{ role: string; content: string; meta: unknown; timestamp: number }> { + const rows = getThreadMessagesStmt.all(runId) as Array<{ payload: string | null }>; + const result: Array<{ role: string; content: string; meta: unknown; timestamp: number }> = []; + for (const row of rows) { + if (row.payload === null) continue; + const msg = tryParseWorkflowMessage(row.payload); + if (msg !== null) result.push(msg); + } + return result; + } + function getThreadRoundCount(runId: string): number { const row = getThreadRoundCountStmt.get(runId) as { c: number } | undefined; const c = row?.c; @@ -534,36 +577,51 @@ export function createLogStore(dbPath: string): LogStore { return Number(c); } + function parseRoundPayload( + payload: string, + fallbackTs: number, + ): { role: string; content: string; meta: unknown; timestamp: number } | null { + try { + const parsed = JSON.parse(payload) as unknown; + if (parsed === null || typeof parsed !== "object") return null; + const obj = parsed as Record; + if (typeof obj.role === "string" && typeof obj.content === "string") { + return { + role: obj.role, + content: obj.content, + meta: obj.meta, + timestamp: typeof obj.timestamp === "number" ? obj.timestamp : 0, + }; + } + if (typeof obj.type === "string") { + return { + role: typeof obj.role === "string" ? obj.role : obj.type, + content: typeof obj.content === "string" ? obj.content : JSON.stringify(obj), + meta: obj, + timestamp: fallbackTs, + }; + } + return null; + } catch { + return null; + } + } + function getThreadRounds(runId: string, params: GetThreadRoundsParams): ThreadRoundRow[] { - const before = params.before; - const lim = params.limit; - if (lim < 1) return []; + if (params.limit < 1) return []; const rows = getThreadRoundsStmt.all({ runId, - before, - lim, + before: params.before, + lim: params.limit, }) as Array<{ id: number; ts: number; payload: string | null; rn: number }>; const out: ThreadRoundRow[] = []; for (const row of rows) { if (row.payload === null) continue; - try { - const parsed = JSON.parse(row.payload) as unknown; - if ( - parsed !== null && - typeof parsed === "object" && - typeof (parsed as Record).type === "string" - ) { - out.push({ - round: row.rn, - logId: row.id, - ts: row.ts, - event: parsed as { type: string; [key: string]: unknown }, - }); - } - } catch { - // skip malformed payloads + const message = parseRoundPayload(row.payload, row.ts); + if (message !== null) { + out.push({ round: row.rn, logId: row.id, ts: row.ts, message }); } } return out; @@ -633,6 +691,7 @@ export function createLogStore(dbPath: string): LogStore { getAllWorkflowRuns, getTriggerPayload, getThreadEvents, + getThreadMessages, getThreadRoundCount, getThreadRounds, archiveLogs, diff --git a/packages/daemon/src/reflex-scheduler.ts b/packages/daemon/src/reflex-scheduler.ts index f6d9253..d1c416d 100644 --- a/packages/daemon/src/reflex-scheduler.ts +++ b/packages/daemon/src/reflex-scheduler.ts @@ -16,9 +16,6 @@ import type { SignalBus, Unsubscribe } from "./signal-bus.js"; /** Sends a compute message to the worker responsible for the given sense. */ export type TriggerFn = (senseName: string) => void; -/** Triggers a workflow run in response to a signal. */ -export type WorkflowTriggerFn = (workflowName: string, payload: unknown) => void; - /** Per-sense mutable state tracked by the scheduler. */ type SenseState = { lastComputeAt: number; @@ -40,7 +37,6 @@ function makeSenseState(): SenseState { export type ReflexSchedulerOptions = { logStore?: LogStore; - workflowTriggerFn?: WorkflowTriggerFn; }; /** @@ -157,21 +153,6 @@ export function createReflexScheduler( } for (const reflex of config.reflexes) { - if (reflex.kind === "workflow") { - if (opts?.workflowTriggerFn !== undefined && reflex.on !== null && reflex.on.length > 0) { - const workflowTriggerFn = opts.workflowTriggerFn; - const workflowName = reflex.workflow; - const watchedSenses = new Set(reflex.on); - const unsub = bus.subscribe((signal) => { - if (watchedSenses.has(signal.senseId)) { - workflowTriggerFn(workflowName, signal.payload); - } - }); - unsubscribers.push(unsub); - } - continue; - } - if (reflex.kind !== "sense") continue; const senseReflex = reflex; const senseName = senseReflex.sense; diff --git a/packages/daemon/src/worker-fork-support.ts b/packages/daemon/src/worker-fork-support.ts index 774a5cc..9c5d334 100644 --- a/packages/daemon/src/worker-fork-support.ts +++ b/packages/daemon/src/worker-fork-support.ts @@ -26,10 +26,7 @@ export function teeCapturedStderr(child: ChildProcess, tail: { value: string }): }); } -export function formatChildExitSummary( - code: number | null, - signal: NodeJS.Signals | null, -): string { +export function formatChildExitSummary(code: number | null, signal: NodeJS.Signals | null): string { const codeStr = code === null || code === undefined ? "null" : String(code); if (signal) { return `code=${codeStr} signal=${signal}`; diff --git a/packages/daemon/src/workflow-manager.ts b/packages/daemon/src/workflow-manager.ts index 64064a7..d7388df 100644 --- a/packages/daemon/src/workflow-manager.ts +++ b/packages/daemon/src/workflow-manager.ts @@ -11,7 +11,8 @@ import type { ChildProcess } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core"; +import type { NerveConfig, WorkflowConfig, WorkflowMessage } from "@uncaged/nerve-core"; +import { START } from "@uncaged/nerve-core"; import type { ResumeThreadMessage, @@ -28,9 +29,14 @@ import { teeCapturedStderr, } from "./worker-fork-support.js"; +export type WorkflowLaunchParams = { + prompt: string; + maxRounds: number; +}; + export type WorkflowManager = { - /** Trigger a new workflow thread (called by Reflex scheduler). */ - startWorkflow: (workflowName: string, payload: unknown) => void; + /** Trigger a new workflow thread (Sense-driven launch or CLI / IPC). */ + startWorkflow: (workflowName: string, launch: WorkflowLaunchParams) => void; /** Number of currently active (running) threads for a workflow. */ activeCount: (workflowName: string) => number; /** Number of pending queued threads waiting to run for a workflow. */ @@ -51,7 +57,8 @@ export type WorkflowManager = { type PendingThread = { runId: string; - payload: unknown; + prompt: string; + maxRounds: number; }; type WorkflowState = { @@ -81,6 +88,42 @@ const WORKER_SHUTDOWN_TIMEOUT_MS = 10_000; const DEFAULT_MAX_QUEUE = 100; +function readLaunchFromTriggerPayload( + raw: unknown, + engineDefaultMaxRounds: number, +): { prompt: string; maxRounds: number } { + if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { + const o = raw as Record; + if (typeof o.prompt === "string" && typeof o.maxRounds === "number") { + return { prompt: o.prompt, maxRounds: o.maxRounds }; + } + } + return { prompt: "", maxRounds: engineDefaultMaxRounds }; +} + +function ensureThreadMessagesWithStart( + messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>, + fallbackPrompt: string, + fallbackMaxRounds: number, +): WorkflowMessage[] { + const mapped: WorkflowMessage[] = messages.map((m) => ({ + role: m.role, + content: m.content, + meta: m.meta, + timestamp: m.timestamp, + })); + if (mapped.length > 0 && mapped[0].role === START) { + return mapped; + } + const start: WorkflowMessage = { + role: START, + content: fallbackPrompt, + meta: { maxRounds: fallbackMaxRounds }, + timestamp: Date.now(), + }; + return [start, ...mapped]; +} + function resolveWorkerScript(): string { const __filename = fileURLToPath(import.meta.url); const __dir = dirname(__filename); @@ -213,7 +256,12 @@ export function createWorkflowManager( } } - function dispatchThread(workflowName: string, runId: string, payload: unknown): void { + function dispatchThread( + workflowName: string, + runId: string, + prompt: string, + maxRounds: number, + ): void { const state = getOrCreateState(workflowName); state.active.add(runId); @@ -222,11 +270,11 @@ export function createWorkflowManager( type: "start-thread", runId, workflow: workflowName, - triggerPayload: payload, + prompt, + maxRounds, }; sendStartThread(worker.process, msg); - // Store triggerPayload in the log so it can be recovered after a crash - logWorkflowEvent(workflowName, runId, "started", { triggerPayload: payload }); + logWorkflowEvent(workflowName, runId, "started", { prompt, maxRounds }); } function dequeueNext(workflowName: string): void { @@ -239,7 +287,7 @@ export function createWorkflowManager( if (state.active.size < concurrency) { const next = state.queue.shift(); if (next !== undefined) { - dispatchThread(workflowName, next.runId, next.payload); + dispatchThread(workflowName, next.runId, next.prompt, next.maxRounds); } } } @@ -260,8 +308,8 @@ export function createWorkflowManager( function recoverQueuedRun(workflowName: string, runId: string, state: WorkflowState): void { if (state.queue.some((q) => q.runId === runId)) return; - const triggerPayload = logStore.getTriggerPayload(runId); - state.queue.push({ runId, payload: triggerPayload }); + const launch = readLaunchFromTriggerPayload(logStore.getTriggerPayload(runId), config.maxRounds); + state.queue.push({ runId, prompt: launch.prompt, maxRounds: launch.maxRounds }); process.stderr.write( `[workflow-manager] crash-recovery: re-queued thread "${runId}" for "${workflowName}"\n`, ); @@ -274,18 +322,19 @@ export function createWorkflowManager( worker: WorkerEntry, ): void { if (state.active.has(runId)) return; - const events = logStore.getThreadEvents(runId); - const triggerPayload = logStore.getTriggerPayload(runId); + const rawMessages = logStore.getThreadMessages(runId); + const launch = readLaunchFromTriggerPayload(logStore.getTriggerPayload(runId), config.maxRounds); + const messages = ensureThreadMessagesWithStart(rawMessages, launch.prompt, launch.maxRounds); state.active.add(runId); const msg: ResumeThreadMessage = { type: "resume-thread", runId, - events, - triggerPayload, + messages, + maxRounds: launch.maxRounds, }; sendResumeThread(worker.process, msg); process.stderr.write( - `[workflow-manager] crash-recovery: resuming thread "${runId}" for "${workflowName}" (${events.length} events)\n`, + `[workflow-manager] crash-recovery: resuming thread "${runId}" for "${workflowName}" (${messages.length} messages)\n`, ); } @@ -363,12 +412,12 @@ export function createWorkflowManager( return; } - if (msg.type === "thread-command-event") { + if (msg.type === "thread-workflow-message") { logStore.append({ source: "workflow", - type: "thread_command_event", + type: "thread_workflow_message", refId: msg.runId, - payload: JSON.stringify(msg.event), + payload: JSON.stringify(msg.message), ts: Date.now(), }); return; @@ -464,7 +513,7 @@ export function createWorkflowManager( return entry; } - function startWorkflow(workflowName: string, payload: unknown): void { + function startWorkflow(workflowName: string, launch: WorkflowLaunchParams): void { if (stopped) return; const wfConfig = workflowConfig(workflowName); @@ -477,9 +526,10 @@ export function createWorkflowManager( const state = getOrCreateState(workflowName); const runId = crypto.randomUUID(); + const { prompt, maxRounds } = launch; if (state.active.size < wfConfig.concurrency) { - dispatchThread(workflowName, runId, payload); + dispatchThread(workflowName, runId, prompt, maxRounds); return; } @@ -504,7 +554,7 @@ export function createWorkflowManager( } } - state.queue.push({ runId, payload }); + state.queue.push({ runId, prompt, maxRounds }); logWorkflowEvent(workflowName, runId, "queued"); process.stderr.write( `[workflow-manager] queued thread for "${workflowName}" runId "${runId}" (queue length: ${state.queue.length})\n`, diff --git a/packages/daemon/src/workflow-worker.ts b/packages/daemon/src/workflow-worker.ts index 69af343..902966b 100644 --- a/packages/daemon/src/workflow-worker.ts +++ b/packages/daemon/src/workflow-worker.ts @@ -12,14 +12,14 @@ import { existsSync } from "node:fs"; import { join, resolve } from "node:path"; -import type { - CommandEvent, - ThreadState, - WorkflowContext, - WorkflowDefinition, -} from "@uncaged/nerve-core"; +import type { RoleMeta, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core"; +import { END, START } from "@uncaged/nerve-core"; -import type { ThreadCommandEventMessage, ThreadEventType, WorkerToParentMessage } from "./ipc.js"; +import type { + ThreadEventType, + ThreadWorkflowMessageMessage, + WorkerToParentMessage, +} from "./ipc.js"; import { parseParentMessage } from "./ipc.js"; import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js"; @@ -45,136 +45,112 @@ function sendWorkflowError(runId: string, error: string): void { send({ type: "workflow-error", runId, error }); } -function sendCommandEvent(runId: string, event: CommandEvent): void { - const msg: ThreadCommandEventMessage = { - type: "thread-command-event", +function sendWorkflowMessage(runId: string, message: WorkflowMessage): void { + const msg: ThreadWorkflowMessageMessage = { + type: "thread-workflow-message", runId, - event: event as { type: string; [key: string]: unknown }, + message: { + role: message.role, + content: message.content, + meta: message.meta, + timestamp: message.timestamp, + }, }; send(msg); } // --------------------------------------------------------------------------- -// Thread loop (RFC-002 §5.4) +// Thread loop (signal-driven automaton, issue #80) // --------------------------------------------------------------------------- -/** - * Replay persisted events through moderate() to reconstruct ThreadState, - * then execute the next role and return the resulting CommandEvent. - * Returns null if the thread is already complete (moderate returned null). - */ -async function replayAndResume( - def: WorkflowDefinition, - runId: string, - ctx: WorkflowContext, - state: ThreadState, - resumeEvents: CommandEvent[], -): Promise { - let lastNext: ReturnType = null; - for (const ev of resumeEvents) { - state.events.push(ev); - lastNext = def.moderate(state, ev); - if (lastNext === null) { - sendThreadEvent(runId, "completed", null); - return null; - } - } - - const next = lastNext; - if (next === null) { - sendThreadEvent(runId, "completed", null); - return null; - } - - const role = def.roles[next.role]; - if (!role) { - sendWorkflowError(runId, `Unknown role: ${next.role}`); - return null; - } - - try { - const event = await role.execute(next.prompt, ctx); - sendCommandEvent(runId, event); - return event; - } catch (e: unknown) { - const errMsg = e instanceof Error ? e.message : String(e); - sendThreadEvent(runId, "failed", { error: errMsg }); - return null; - } -} - async function runThread( - def: WorkflowDefinition, - workflowName: string, + def: WorkflowDefinition, runId: string, - triggerPayload: unknown, - /** Pre-existing event history for crash-recovery resume. Empty for a fresh thread. */ - resumeEvents: CommandEvent[] = [], + maxRounds: number, + resumeMessages: WorkflowMessage[] = [], + freshPrompt: string | null = null, ): Promise { - const state: ThreadState = { runId, events: [] }; - const ctx: WorkflowContext = { - runId, - workflowName, - log: (msg) => sendThreadEvent(runId, "step_complete", { message: msg }), - }; + let chain: WorkflowMessage[]; - const initialEvent: CommandEvent = { - type: "thread_start", - triggerPayload: triggerPayload ?? {}, - }; + if (resumeMessages.length > 0) { + chain = [...resumeMessages]; + } else { + const prompt = freshPrompt ?? ""; + const startMsg: WorkflowMessage = { + role: START, + content: prompt, + meta: { maxRounds }, + timestamp: Date.now(), + }; + chain = [startMsg]; + sendWorkflowMessage(runId, startMsg); + } - // On resume: replay persisted events, run the next un-executed role, then continue. - if (resumeEvents.length > 0) { - const nextEvent = await replayAndResume(def, runId, ctx, state, resumeEvents); - if (nextEvent === null) return; - await continueThread(def, runId, ctx, state, nextEvent); + let roleRound = chain.filter((m) => m.role !== START).length; + const lastMsg = chain[chain.length - 1]; + if (lastMsg === undefined) { + sendWorkflowError(runId, "empty workflow message chain"); return; } - // Fresh thread — send the initial command event and enter the loop. - sendCommandEvent(runId, initialEvent); - await continueThread(def, runId, ctx, state, initialEvent); -} + const lastSignal = + lastMsg.role === START + ? { + role: START, + content: lastMsg.content, + meta: lastMsg.meta as { maxRounds: number }, + timestamp: lastMsg.timestamp, + } + : { role: lastMsg.role, meta: lastMsg.meta as Record }; -async function continueThread( - def: WorkflowDefinition, - runId: string, - ctx: WorkflowContext, - state: ThreadState, - firstEvent: CommandEvent, -): Promise { - let event = firstEvent; + let nextRole = def.moderator( + lastSignal as Parameters[0], + roleRound, + maxRounds, + ); - const MAX_STEPS = 1000; - let step = 0; - while (step < MAX_STEPS) { - step++; - state.events.push(event); - const next = def.moderate(state, event); + if (nextRole === END) { + sendThreadEvent(runId, "completed", null); + return; + } - if (next === null) { - sendThreadEvent(runId, "completed", null); - return; - } - - const role = def.roles[next.role]; + while (roleRound < maxRounds) { + const role = def.roles[nextRole]; if (!role) { - sendWorkflowError(runId, `Unknown role: ${next.role}`); + sendWorkflowError(runId, `Unknown role: ${nextRole}`); return; } + let result: { content: string; meta: Record }; try { - event = await role.execute(next.prompt, ctx); + result = await role(chain); } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : String(e); sendThreadEvent(runId, "failed", { error: errMsg }); return; } - sendCommandEvent(runId, event); - } - if (step >= MAX_STEPS) { - sendWorkflowError(runId, `Thread exceeded maximum steps (${MAX_STEPS})`); + + const message: WorkflowMessage = { + role: nextRole, + content: result.content, + meta: result.meta, + timestamp: Date.now(), + }; + chain.push(message); + sendWorkflowMessage(runId, message); + + roleRound += 1; + + const signal = { role: nextRole, meta: result.meta }; + nextRole = def.moderator(signal as Parameters[0], roleRound, maxRounds); + + if (nextRole === END) { + sendThreadEvent(runId, "completed", null); + return; + } } + + sendWorkflowError(runId, `Thread exceeded maximum rounds (${maxRounds})`); } // --------------------------------------------------------------------------- @@ -184,7 +160,7 @@ async function continueThread( async function loadWorkflowDefinition( nerveRoot: string, workflowName: string, -): Promise { +): Promise> { const candidates = [ resolve(join(nerveRoot, "workflows", workflowName, "index.ts")), resolve(join(nerveRoot, "workflows", workflowName, "index.js")), @@ -204,15 +180,16 @@ async function loadWorkflowDefinition( if ( def === null || typeof def !== "object" || - typeof (def as WorkflowDefinition).moderate !== "function" || - typeof (def as WorkflowDefinition).roles !== "object" + typeof (def as WorkflowDefinition).moderator !== "function" || + typeof (def as WorkflowDefinition).roles !== "object" || + typeof (def as WorkflowDefinition).name !== "string" ) { throw new Error( - `Workflow "${workflowName}" must export a WorkflowDefinition with "roles" and "moderate".`, + `Workflow "${workflowName}" must export a WorkflowDefinition with "name", "roles", and "moderator".`, ); } - return def as WorkflowDefinition; + return def as WorkflowDefinition; } // --------------------------------------------------------------------------- @@ -221,8 +198,7 @@ async function loadWorkflowDefinition( function handleMessage( raw: unknown, - def: WorkflowDefinition, - workflowName: string, + def: WorkflowDefinition, inFlight: Map>, shuttingDown: { value: boolean }, ): void { @@ -245,11 +221,11 @@ function handleMessage( if (msg.type === "start-thread") { if (shuttingDown.value) return; - const { runId, triggerPayload } = msg; + const { runId, prompt, maxRounds } = msg; const previous = inFlight.get(runId) ?? Promise.resolve(); const next = previous - .then(() => runThread(def, workflowName, runId, triggerPayload)) + .then(() => runThread(def, runId, maxRounds, [], prompt)) .catch((e: unknown) => { const errMsg = e instanceof Error ? e.message : String(e); sendWorkflowError(runId, errMsg); @@ -264,11 +240,11 @@ function handleMessage( if (msg.type === "resume-thread") { if (shuttingDown.value) return; - const { runId, events, triggerPayload } = msg; + const { runId, messages, maxRounds } = msg; const previous = inFlight.get(runId) ?? Promise.resolve(); const next = previous - .then(() => runThread(def, workflowName, runId, triggerPayload, events as CommandEvent[])) + .then(() => runThread(def, runId, maxRounds, messages as WorkflowMessage[], null)) .catch((e: unknown) => { const errMsg = e instanceof Error ? e.message : String(e); sendWorkflowError(runId, errMsg); @@ -287,7 +263,7 @@ function handleMessage( // --------------------------------------------------------------------------- async function bootstrap(nerveRoot: string, workflowName: string): Promise { - let def: WorkflowDefinition; + let def: WorkflowDefinition; try { def = await loadWorkflowDefinition(nerveRoot, workflowName); } catch (e: unknown) { @@ -302,7 +278,7 @@ async function bootstrap(nerveRoot: string, workflowName: string): Promise sendReady(); process.on("message", (raw: unknown) => { - handleMessage(raw, def, workflowName, inFlight, shuttingDown); + handleMessage(raw, def, inFlight, shuttingDown); }); }