diff --git a/.knowledge/sense.md b/.knowledge/sense.md index 2c1d646..23332ef 100644 --- a/.knowledge/sense.md +++ b/.knowledge/sense.md @@ -17,17 +17,16 @@ export async function compute(): Promise> { ... } // pure, no - No database access within compute — runtime provides isolated execution context - Must be pure function (no side effects, no external API calls) -**Return Value Contract:** -- `ComputeResult` = `null | { signal: T; workflow: WorkflowTrigger | null }` - - `null` → silent, no storage, no signal - - `{ signal: data, workflow: null }` → persist + emit signal - - `{ signal, workflow: WorkflowTrigger }` → persist + emit signal + trigger workflow - - Any other value → treated as `{ signal: value, workflow: null }` +**Return Value Contract (current engine):** +- `compute(state)` returns `Promise<{ state: S; trigger: SenseTrigger | null }>` where `SenseTrigger = { command: string }`. + - `trigger: null` → persist state only; no shell command + - `trigger: { command }` → persist state; worker runs the command with `shell: true` after a successful compute +- Workflows are **not** started from `trigger`; use CLI / daemon IPC (`nerve workflow trigger`, etc.). **Error Handling & Serialization:** -- Exceptions caught by worker, logged as errors (no signal emitted) -- Signal payload must be JSON-serializable (passed via IPC) -- Invalid workflow triggers silently dropped (signal still emitted) +- Exceptions caught by worker, logged as errors (state unchanged) +- State must be JSON-serializable (persisted to `data/senses/.json`) +- Invalid `trigger` shapes fail IPC validation when the worker sends `compute-result` **Timeout & Scheduling Semantics:** - Timeout priority: explicit config → AbortSignal → DEFAULT_TIMEOUT_MS (30s) diff --git a/.knowledge/signal-routing.md b/.knowledge/signal-routing.md index b5d07eb..d2254e3 100644 --- a/.knowledge/signal-routing.md +++ b/.knowledge/signal-routing.md @@ -1,30 +1,31 @@ -# Sense compute → workflow (RFC #308) +# Sense compute → shell + scheduler (issue #318) -Stateful senses no longer emit signals or pass outputs through `routeSenseComputeOutput`. The worker runs `compute(state)` and returns `{ state, workflow }`. +Stateful senses run `compute(state)` and return `{ state, trigger }`. The worker persists state JSON and sends `compute-result` to the kernel. Optional side effects are **shell commands only**, executed in the sense worker. Workflows are not started from sense return values. ## Flow ``` -Sense worker: compute(state) → { state, workflow } +Sense worker: compute(state) → { state, trigger } ↓ persist state JSON (data/senses/.json) ↓ - IPC compute-result → kernel + trigger !== null → spawn shell command (cwd = nerve root) ↓ - workflow !== null → parseWorkflowTrigger (validation) → workflowManager.startWorkflow - scheduler.onSenseCompleted(senseName) → dependents with `on: [senseName]` + IPC compute-result → kernel (audit: shell-launch log) + ↓ + scheduler.onSenseCompleted(senseName) → dependents with `on: [senseName]` ``` -## Workflow trigger shape +Workflow runs: **`workflowManager.startWorkflow`** from CLI / daemon IPC only (`nerve workflow trigger`, HTTP when enabled). -When `workflow` is non-null it must be a plain object validated by `parseWorkflowTrigger()` in `packages/core/src/sense.ts`: +## Sense trigger shape -- `name`: non-empty string -- `maxRounds`: integer ≥ 1 -- `prompt`: string -- `dryRun`: boolean +When `trigger` is non-null it must be a plain object validated by `parseSenseTrigger()` in `packages/core/src/sense.ts`: -Invalid triggers are rejected by the daemon when handling the worker message (workflow is not started). +- Exactly one property: `command` (non-empty string after trim) +- No `kind` field; no workflow fields + +Invalid triggers are rejected when parsing the worker message. ## Scheduling diff --git a/CLAUDE.md b/CLAUDE.md index 82a4fe6..9a1719b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ For mutually exclusive fields, use discriminated unions: ```typescript import type { SenseTrigger } from "@uncaged/nerve-core"; -// ✅ Good — sense modules return explicit next state + optional trigger (workflow or shell) +// ✅ Good — sense modules return explicit next state + optional shell trigger only type SenseComputeReturn = { state: S; trigger: SenseTrigger | null; diff --git a/README.md b/README.md index 1403733..efa0887 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,21 @@ Nerve is a lightweight daemon that continuously observes external state through ## Core Concepts ``` -External World → Sense(state) → { newState, workflow? } → Workflow → Log +External World → Sense(state) → { state, trigger? } → (shell in worker) / Log + │ + Workflow → Log (CLI / daemon IPC only) ↑ scheduling: interval / on (per sense in nerve.yaml) ``` | Concept | Metaphor | Role | |---------|----------|------| -| **Sense** | 👁️ Perception | Stateful `compute(state)` returning `{ state, workflow }`. State lives in `data/senses/.json`. | +| **Sense** | 👁️ Perception | Stateful `compute(state)` returning `{ state, trigger }`. State lives in `data/senses/.json`. | | **Schedule** | ⏱️ When | Each sense entry sets optional `interval` (periodic) and `on: [other senses]` (run after those senses complete a compute). | -| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started when `workflow` is non-null in the compute result, or via CLI/daemon IPC. | +| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started via CLI / daemon IPC (`nerve workflow trigger`, transport). Not started from sense `compute()` results. | | **Log** | 📝 Record | Immutable audit trail. **Cannot** schedule senses or workflows (prevents feedback loops). | -**Sense → Workflow:** when `workflow` is a structured object `{ name, maxRounds, prompt, dryRun }`, the kernel validates it (`@uncaged/nerve-core` `parseWorkflowTrigger`) and starts that workflow. Use `workflow: null` when no run should start. +**Sense → shell:** when `trigger` is non-null it must be `{ command: string }`. The sense worker runs it with `shell: true` (cwd = nerve root). Use `trigger: null` when no command should run. To start a workflow, invoke it from that shell command (for example calling the CLI) or trigger workflows separately via IPC. Two extension points for **what to observe (+ when)** vs **multi-step action** — scheduling is declarative config on each sense, not a separate YAML section. @@ -27,7 +29,7 @@ Two extension points for **what to observe (+ when)** vs **multi-step action** | Package | Description | |---------|-------------| -| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, workflow trigger validation, daemon IPC protocol | +| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, sense trigger validation (`parseSenseTrigger`), daemon IPC protocol | | [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows | | [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, sense workers, sense scheduler, workflow manager, file watcher, IPC | | [`@uncaged/nerve-cli`](./packages/cli) | CLI (`nerve`) — init, validate, daemon, dev, logs, sense, store, workflow | diff --git a/docs/dead-code-analysis.md b/docs/dead-code-analysis.md index d1f169f..2d7dd0a 100644 --- a/docs/dead-code-analysis.md +++ b/docs/dead-code-analysis.md @@ -117,7 +117,7 @@ | 项目 | 位置 | 说明 | 置信度 | 建议 | |------|------|------|--------|------| -| ~~已更名 API 仍出现在 README~~ | `packages/core/README.md` | (已修正)文档与 stateful sense、`parseWorkflowTrigger` 对齐;`routeSenseComputeOutput` 已移除 | — | 关闭 | +| ~~已更名 API 仍出现在 README~~ | `packages/core/README.md` | (已修正)文档与 stateful sense、`parseSenseTrigger`(shell-only)对齐 | — | 关闭 | | Hermes 选项合并注释 | `packages/workflow-utils/src/shared/hermes-agent.ts` | 注释称 absorbed from `hermes-options.ts`,该文件已不存在 | **中** | **清理注释**,避免误导。 | | `KNOWN_AGENT_ADAPTER_IDS` 含 `codex` | `packages/core/src/agent.ts` | 仓内无 `codex` 适配器包;与常量未被引用叠加 | **中** | **对齐产品**:实现适配器或从列表移除。 | diff --git a/packages/cli/skills/claude/CLAUDE.md b/packages/cli/skills/claude/CLAUDE.md index 5083893..04ddcb0 100644 --- a/packages/cli/skills/claude/CLAUDE.md +++ b/packages/cli/skills/claude/CLAUDE.md @@ -212,10 +212,10 @@ extract: ### compute 函数签名 -Sense 的 `compute` 接收当前状态,返回新状态和可选的 workflow trigger。状态以 JSON 文件持久化在 `data/senses/.json`。 +Sense 的 `compute` 接收当前状态,返回新状态和可选的 shell trigger(`{ command: string }`)。状态以 JSON 文件持久化在 `data/senses/.json`。Workflow 只能通过 CLI / daemon IPC 启动,不能从 sense 返回值直接启动。 ```typescript -import type { SenseComputeFn, WorkflowTrigger } from "@uncaged/nerve-core"; +import type { SenseComputeFn } from "@uncaged/nerve-core"; type MyState = { lastRun: number | null; @@ -226,7 +226,7 @@ export const initialState: MyState = { lastRun: null, count: 0 }; export async function compute(state: MyState): Promise<{ state: MyState; - trigger: WorkflowTrigger | null; + trigger: { command: string } | null; }> { return { state: { lastRun: Date.now(), count: state.count + 1 }, @@ -247,15 +247,9 @@ export async function compute(state: MyState): Promise<{ ### 返回值 ```typescript -// trigger: null → 不触发 workflow -// trigger: WorkflowTrigger → 触发 workflow - -type WorkflowTrigger = { - name: string; // workflow 名称(对应 nerve.yaml 中的 key) - maxRounds: number; // moderator 最大轮次 - prompt: string; // 初始 prompt - dryRun: boolean; // 干跑模式 -}; +// trigger: null → 不执行 shell 命令 +// trigger: { command } → sense worker 在成功的 compute 后以 shell 执行该命令(cwd = nerve 根目录) +// 启动 workflow:在 shell 中调用 `nerve workflow trigger ...`,或使用 daemon IPC / HTTP API ``` ### Sense 模块导出 @@ -271,7 +265,7 @@ export const initialState: MyState = { ... }; // 2. compute 函数 export async function compute(state: MyState): Promise<{ state: MyState; - trigger: WorkflowTrigger | null; + trigger: { command: string } | null; }> { // ... } diff --git a/packages/cli/skills/cursor/.cursorrules b/packages/cli/skills/cursor/.cursorrules index 8e2e95b..3d019c2 100644 --- a/packages/cli/skills/cursor/.cursorrules +++ b/packages/cli/skills/cursor/.cursorrules @@ -252,22 +252,7 @@ export async function compute(): Promise> { ### 返回值 -```typescript -// 返回 null = 静默,不发 signal -// 返回非 null = 发出 signal(并写入业务表),可选触发 workflow -type ComputeResult = - | null - | { signal: T; workflow: WorkflowTrigger | null }; - -type WorkflowTrigger = { - name: string; // workflow 名称(对应 nerve.yaml 中的 key) - maxRounds: number; // moderator 最大轮次 - prompt: string; // 初始 prompt - dryRun: boolean; // 干跑模式 -}; -``` - -若返回值是普通对象且不含 `signal` 字段,内核会按 shorthand 视为 `{ signal: payload, workflow: null }`(见 core 的 `routeSenseComputeOutput`)。 +当前引擎:`compute(state)` 返回 `{ state, trigger }`,`trigger` 为 `null` 或 `{ command: string }`(shell 命令)。Workflow 仅通过 CLI / daemon IPC 启动;类型见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseTrigger`。 ### Sense 模块导出 diff --git a/packages/cli/skills/hermes/SKILL.md b/packages/cli/skills/hermes/SKILL.md index d2854cd..92d6610 100644 --- a/packages/cli/skills/hermes/SKILL.md +++ b/packages/cli/skills/hermes/SKILL.md @@ -245,22 +245,7 @@ export async function compute(): Promise> { ### 返回值 -```typescript -// 返回 null = 静默,不发 signal -// 返回非 null = 发出 signal(并写入业务表),可选触发 workflow -type ComputeResult = - | null - | { signal: T; trigger: WorkflowTrigger | null }; - -type WorkflowTrigger = { - name: string; // workflow 名称(对应 nerve.yaml 中的 key) - maxRounds: number; // moderator 最大轮次 - prompt: string; // 初始 prompt - dryRun: boolean; // 干跑模式 -}; -``` - -若返回值是普通对象且不含 `signal` 字段,内核会按 shorthand 视为 `{ signal: payload, trigger: null }`(见 core 的 `routeSenseComputeOutput`)。 +当前引擎:`compute(state)` 返回 `{ state, trigger }`,其中 `trigger` 为 `null` 或 `{ command: string }`(sense worker 内 `shell: true` 执行)。Workflow 仅通过 CLI / daemon IPC 启动,类型见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseTrigger`。 ### Sense 模块导出 diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index aebfb7e..67355b7 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -125,29 +125,6 @@ export async function compute(state) { } `; -/** First trigger launches local noop workflow; later triggers only advance idleTicks. */ -const counterIndexJsWithNoopWorkflow = `export const initialState = { launched: false, idleTicks: 0 }; - -export async function compute(state) { - if (!state.launched) { - return { - state: { launched: true, idleTicks: state.idleTicks }, - trigger: { - kind: "workflow", - name: "noop", - maxRounds: 3, - prompt: "e2e-archive", - dryRun: false, - }, - }; - } - return { - state: { launched: state.launched, idleTicks: state.idleTicks + 1 }, - trigger: null, - }; -} -`; - /** Minimal workflow: moderator ends immediately (no role rounds). */ const noopWorkflowIndexJs = `const END = "__end__"; export default { @@ -205,11 +182,7 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate, "utf8", ); - writeFileSync( - join(nerveRoot, "dist", "senses", "counter", "index.js"), - withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs, - "utf8", - ); + writeFileSync(join(nerveRoot, "dist", "senses", "counter", "index.js"), counterIndexJs, "utf8"); writeFileSync( join(nerveRoot, "dist", "workflows", "echo", "index.js"), echoWorkflowIndexJs, @@ -235,8 +208,8 @@ export type TestDaemonHandle = { export type StartTestDaemonOpts = { /** - * When true, counter sense's first compute launches a local `noop` workflow (real - * workflow-worker child). Requires built `workflow-worker.js` next to `sense-worker.js`. + * When true, bundles a local `noop` workflow under `dist/workflows/noop` for tests that + * start runs via `nerve workflow trigger` (real workflow-worker child). */ withNoopWorkflow: boolean; } | null; diff --git a/packages/cli/src/__tests__/e2e-store-archive.test.ts b/packages/cli/src/__tests__/e2e-store-archive.test.ts index f93edce..1b1be67 100644 --- a/packages/cli/src/__tests__/e2e-store-archive.test.ts +++ b/packages/cli/src/__tests__/e2e-store-archive.test.ts @@ -46,8 +46,14 @@ describe("e2e store archive", () => { daemon = await startTestDaemon({ withNoopWorkflow: true }); linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot); - const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]); - expect(triggerResult.exitCode).toBe(0); + const wfTrigger = await runCli(daemon, [ + "workflow", + "trigger", + "noop", + "--prompt", + "e2e-archive", + ]); + expect(wfTrigger.exitCode).toBe(0); const logsDb = join(daemon.nerveRoot, "data", "logs.db"); await pollUntil(() => { @@ -101,8 +107,14 @@ describe("e2e store archive", () => { daemon = await startTestDaemon({ withNoopWorkflow: true }); linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot); - const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]); - expect(triggerResult.exitCode).toBe(0); + const wfTrigger = await runCli(daemon, [ + "workflow", + "trigger", + "noop", + "--prompt", + "e2e-archive", + ]); + expect(wfTrigger.exitCode).toBe(0); const logsDb = join(daemon.nerveRoot, "data", "logs.db"); await pollUntil(() => { diff --git a/packages/core/README.md b/packages/core/README.md index 3c7e1f6..cf19e37 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,9 +4,9 @@ Shared types and configuration parser for the [nerve](../../README.md) observati ## What's Inside -- **Type definitions** — `SenseConfig`, `SenseInfo`, `SenseComputeFn`, `SenseModule`, `WorkflowConfig`, `NerveConfig`, `WorkflowTrigger`, and related types +- **Type definitions** — `SenseConfig`, `SenseInfo`, `SenseComputeFn`, `SenseModule`, `SenseTrigger`, `WorkflowConfig`, `NerveConfig`, and related types - **Config parser** — `parseNerveConfig(yaml)` validates and parses `nerve.yaml` into `NerveConfig` (top-level `reflexes` is rejected; use `interval` / `on` on each sense) -- **Workflow triggers** — `parseWorkflowTrigger` validates structured workflow launch objects from Sense compute results or IPC +- **Sense triggers** — `parseSenseTrigger` validates `{ command: string }` from sense compute results or worker IPC (`trigger` field on `compute-result`) - **Daemon IPC protocol** — request/response types (`DaemonIpcRequest`, `DaemonIpcResponse`, …) and `parseDaemonIpcRequest` for newline-delimited JSON on the CLI ↔ daemon socket - **Workflow automaton types** — `START` / `END` sentinel constants, `WorkflowMessage`, `StartStep`, `RoleStep`, `ModeratorContext` (`start` + `steps`; empty `steps` on first moderator call), `Moderator` (single `context` argument), `WorkflowDefinition`, `Role`, `RoleResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS` - **Result type** — `Result` with `ok()` / `err()` helpers for explicit error handling (no thrown exceptions for parse paths) @@ -23,23 +23,18 @@ if (result.ok) { } ``` -### Workflow trigger validation +### Sense trigger validation ```typescript -import { parseWorkflowTrigger } from "@uncaged/nerve-core"; +import { parseSenseTrigger } from "@uncaged/nerve-core"; -const directive = parseWorkflowTrigger({ - name: "my-workflow", - maxRounds: 8, - prompt: "Hello from sense", - dryRun: false, -}); -if (directive.ok) { - console.log(directive.value.name, directive.value.maxRounds, directive.value.prompt); +const parsed = parseSenseTrigger({ command: "echo hello" }); +if (parsed.ok) { + console.log(parsed.value.command); } ``` -Sense modules return `{ state, workflow }` from `compute(state)`; when `workflow` is non-null it must satisfy the shape validated by `parseWorkflowTrigger` (the daemon validates before starting a run). +Sense modules return `{ state, trigger }` from `compute(state)`; when `trigger` is non-null it must be exactly `{ command: string }` (non-empty after trim). The daemon validates worker IPC with `parseSenseTrigger`. Workflows are started only via CLI / daemon IPC, not from this field. ## Duration Format diff --git a/packages/core/src/__tests__/sense-trigger.test.ts b/packages/core/src/__tests__/sense-trigger.test.ts new file mode 100644 index 0000000..d45df05 --- /dev/null +++ b/packages/core/src/__tests__/sense-trigger.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { parseSenseTrigger } from "../sense.js"; + +describe("parseSenseTrigger", () => { + it("accepts a valid command trigger", () => { + const r = parseSenseTrigger({ command: "echo hi" }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.value).toEqual({ command: "echo hi" }); + }); + + it("trims command", () => { + const r = parseSenseTrigger({ command: " echo hi " }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.value).toEqual({ command: "echo hi" }); + }); + + it("rejects empty command", () => { + const r = parseSenseTrigger({ command: "" }); + expect(r.ok).toBe(false); + }); + + it("rejects whitespace-only command", () => { + const r = parseSenseTrigger({ command: " " }); + expect(r.ok).toBe(false); + }); + + it("rejects non-string command", () => { + const r = parseSenseTrigger({ command: 1 as unknown as string }); + expect(r.ok).toBe(false); + }); + + it("rejects non-object", () => { + expect(parseSenseTrigger(null).ok).toBe(false); + expect(parseSenseTrigger("x").ok).toBe(false); + expect(parseSenseTrigger([]).ok).toBe(false); + }); + + it("rejects extra properties", () => { + const r = parseSenseTrigger({ command: "x", kind: "shell" }); + expect(r.ok).toBe(false); + }); + + it("rejects empty object", () => { + const r = parseSenseTrigger({}); + expect(r.ok).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/sense-workflow-directive.test.ts b/packages/core/src/__tests__/sense-workflow-directive.test.ts deleted file mode 100644 index 3a21a8a..0000000 --- a/packages/core/src/__tests__/sense-workflow-directive.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseSenseTrigger } from "../sense.js"; - -describe("parseSenseTrigger", () => { - it("accepts a valid workflow trigger", () => { - const r = parseSenseTrigger({ - kind: "workflow", - name: "my-wf", - maxRounds: 3, - prompt: "go", - dryRun: true, - }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.value).toEqual({ - kind: "workflow", - name: "my-wf", - maxRounds: 3, - prompt: "go", - dryRun: true, - }); - }); - - it("trims workflow name", () => { - const r = parseSenseTrigger({ - kind: "workflow", - name: " spaced ", - maxRounds: 1, - prompt: "", - dryRun: false, - }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.value.kind).toBe("workflow"); - if (r.value.kind !== "workflow") return; - expect(r.value.name).toBe("spaced"); - }); - - it("accepts a valid shell trigger", () => { - const r = parseSenseTrigger({ - kind: "shell", - command: " echo hi ", - }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.value).toEqual({ kind: "shell", command: "echo hi" }); - }); - - it("rejects workflow without kind", () => { - const r = parseSenseTrigger({ - name: "my-wf", - maxRounds: 1, - prompt: "x", - dryRun: false, - }); - expect(r.ok).toBe(false); - }); - - it("rejects empty workflow name", () => { - const r = parseSenseTrigger({ - kind: "workflow", - name: "", - maxRounds: 1, - prompt: "x", - dryRun: false, - }); - expect(r.ok).toBe(false); - }); - - it("rejects non-integer maxRounds", () => { - const r = parseSenseTrigger({ - kind: "workflow", - name: "w", - maxRounds: 1.5, - prompt: "", - dryRun: false, - }); - expect(r.ok).toBe(false); - }); - - it("rejects maxRounds < 1", () => { - const r = parseSenseTrigger({ - kind: "workflow", - name: "w", - maxRounds: 0, - prompt: "", - dryRun: false, - }); - expect(r.ok).toBe(false); - }); - - it("rejects non-boolean dryRun", () => { - const r = parseSenseTrigger({ - kind: "workflow", - name: "w", - maxRounds: 1, - prompt: "", - dryRun: "no" as unknown as boolean, - }); - expect(r.ok).toBe(false); - }); - - it("rejects empty shell command", () => { - const r = parseSenseTrigger({ kind: "shell", command: "" }); - expect(r.ok).toBe(false); - }); - - it("rejects unknown kind", () => { - const r = parseSenseTrigger({ kind: "other", x: 1 }); - expect(r.ok).toBe(false); - }); -}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c1cee71..fb835d7 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -52,24 +52,15 @@ export type ExtractConfig = { model: string; }; -/** Parameters for starting a workflow from a Sense compute result (or CLI trigger). */ -export type WorkflowTrigger = { - kind: "workflow"; - name: string; - maxRounds: number; - prompt: string; - dryRun: boolean; -}; - -/** Run a shell command from a Sense compute result (daemon executes in the sense worker). */ -export type ShellTrigger = { - kind: "shell"; +/** + * Optional shell side effect after a successful sense `compute()`. + * Executed in the sense worker (`spawn` with `shell: true`, cwd = nerve root). + * Workflows are started only via CLI / daemon IPC, not from sense compute results. + */ +export type SenseTrigger = { command: string; }; -/** Optional side effect requested by `compute()` — workflow launch or shell command. */ -export type SenseTrigger = WorkflowTrigger | ShellTrigger; - export type NerveConfig = { /** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */ maxRounds: number; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ad85d99..18f7135 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,8 +7,6 @@ export type { AgentConfig, ExtractConfig, NerveConfig, - WorkflowTrigger, - ShellTrigger, SenseTrigger, } from "./config.js"; export type { SenseInfo } from "./sense.js"; diff --git a/packages/core/src/sense.ts b/packages/core/src/sense.ts index ddb6076..491b359 100644 --- a/packages/core/src/sense.ts +++ b/packages/core/src/sense.ts @@ -1,4 +1,4 @@ -import type { SenseConfig, SenseTrigger, ShellTrigger, WorkflowTrigger } from "./config.js"; +import type { SenseConfig, SenseTrigger } from "./config.js"; import { type Result, err, isPlainRecord, ok } from "./util.js"; /** Runtime metadata for a sense (e.g. daemon list-senses IPC). */ @@ -69,51 +69,19 @@ export function senseTriggerLabels( return [labelSenseTrigger({ interval: sc.interval, on: sc.on })]; } -function parseWorkflowTriggerBranch(value: Record): Result { - const nameRaw = value.name; - if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) { - return err(new Error('workflow trigger: "name" must be a non-empty string')); - } - const maxRounds = value.maxRounds; - if (typeof maxRounds !== "number" || !Number.isInteger(maxRounds) || maxRounds < 1) { - return err(new Error('workflow trigger: "maxRounds" must be an integer >= 1')); - } - const prompt = value.prompt; - if (typeof prompt !== "string") { - return err(new Error('workflow trigger: "prompt" must be a string')); - } - const dryRun = value.dryRun; - if (typeof dryRun !== "boolean") { - return err(new Error('workflow trigger: "dryRun" must be a boolean')); - } - return ok({ - kind: "workflow", - name: nameRaw.trim(), - maxRounds, - prompt, - dryRun, - }); -} - -function parseShellTriggerBranch(value: Record): Result { - const command = value.command; - if (typeof command !== "string" || command.trim().length === 0) { - return err(new Error('shell trigger: "command" must be a non-empty string')); - } - return ok({ kind: "shell", command: command.trim() }); -} - -/** Validates a structured sense trigger from Sense compute or IPC (`trigger` field). */ +/** Validates `{ command: string }` from Sense compute or IPC (`trigger` field). */ export function parseSenseTrigger(value: unknown): Result { if (!isPlainRecord(value)) { return err(new Error("sense trigger must be a plain object")); } - const kind = value.kind; - if (kind === "workflow") { - return parseWorkflowTriggerBranch(value); + for (const key of Object.keys(value)) { + if (key !== "command") { + return err(new Error(`sense trigger: unexpected property "${key}"`)); + } } - if (kind === "shell") { - return parseShellTriggerBranch(value); + const command = value.command; + if (typeof command !== "string" || command.trim().length === 0) { + return err(new Error('sense trigger: "command" must be a non-empty string')); } - return err(new Error('sense trigger: "kind" must be "workflow" or "shell"')); + return ok({ command: command.trim() }); } diff --git a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts index 6fd1a9d..1db60cf 100644 --- a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts @@ -1,8 +1,9 @@ /** * Integration tests for Kernel + WorkflowManager integration. * - * Verifies that sense compute-result IPC triggers workflow runs when `workflow` - * is non-null; that workflow events are logged; that reloadConfig handles workflow changes; + * Verifies that workflow runs are started via `workflowManager.startWorkflow` (CLI / IPC path); + * that sense compute-result with a shell trigger does not start workflows; + * that workflow events are logged; that reloadConfig handles workflow changes; * and that graceful shutdown stops workflow workers. * * Uses mocked child_process.fork to avoid real subprocesses. @@ -153,20 +154,10 @@ describe("kernel + workflowManager integration", () => { rmSync(nerveRoot, { recursive: true, force: true }); }); - describe("sense compute triggers workflow via return value", () => { - it("calls workflowManager.startWorkflow when a sense compute returns a workflow launch", async () => { + describe("workflowManager.startWorkflow", () => { + it("spawns a workflow worker and sends start-thread", async () => { const logStore = makeLogStore(); const config = makeConfig({ - senses: { - "cpu-usage": { - group: "system", - throttle: null, - timeout: null, - gracePeriod: null, - interval: null, - on: [], - }, - }, workflows: { "my-workflow": { concurrency: 2, overflow: "drop" } }, }); @@ -177,25 +168,14 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - const workerPool = mockChildren[0]; - if (workerPool) { - workerPool.emit("message", { - type: "compute-result", - sense: "cpu-usage", - state: { reason: "test" }, - trigger: { - kind: "workflow", - name: "my-workflow", - maxRounds: 10, - prompt: "run this workflow", - dryRun: false, - }, - }); - } + kernel.workflowManager.startWorkflow("my-workflow", { + prompt: "run this workflow", + maxRounds: 10, + dryRun: false, + }); await vi.runAllTimersAsync(); - // A workflow worker should be spawned and a start-thread message sent const workflowWorker = mockChildren.find((c) => (c.send as ReturnType).mock.calls.some( (args: unknown[]) => @@ -211,19 +191,9 @@ describe("kernel + workflowManager integration", () => { await stopPromise; }); - it("passes prompt and maxRounds from the workflow field to the workflow", async () => { + it("passes prompt and maxRounds on start-thread", async () => { const logStore = makeLogStore(); const config = makeConfig({ - senses: { - "cpu-usage": { - group: "system", - throttle: null, - timeout: null, - gracePeriod: null, - interval: null, - on: [], - }, - }, workflows: { "alert-workflow": { concurrency: 1, overflow: "drop" } }, }); @@ -234,25 +204,14 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - const workerPool = mockChildren[0]; - if (workerPool) { - workerPool.emit("message", { - type: "compute-result", - sense: "cpu-usage", - state: { level: "critical" }, - trigger: { - kind: "workflow", - name: "alert-workflow", - maxRounds: 5, - prompt: "handle critical alert", - dryRun: false, - }, - }); - } + kernel.workflowManager.startWorkflow("alert-workflow", { + prompt: "handle critical alert", + maxRounds: 5, + dryRun: false, + }); await vi.runAllTimersAsync(); - // Find the start-thread call and verify triggerPayload const startThreadCall = mockChildren .flatMap((c) => (c.send as ReturnType).mock.calls as [unknown][]) .find( @@ -275,8 +234,10 @@ describe("kernel + workflowManager integration", () => { await vi.runAllTimersAsync(); await stopPromise; }); + }); - it("logs compute-complete before workflow-launch when workflow is present", async () => { + describe("sense compute-result triggers", () => { + it("logs compute-complete before shell-launch when shell trigger is present", async () => { const logStore = makeLogStore(); const config = makeConfig({ workflows: { "order-wf": { concurrency: 1, overflow: "drop" } }, @@ -295,13 +256,7 @@ describe("kernel + workflowManager integration", () => { type: "compute-result", sense: "cpu-usage", state: { seq: 1 }, - trigger: { - kind: "workflow", - name: "order-wf", - maxRounds: 2, - prompt: "p", - dryRun: true, - }, + trigger: { command: "echo order-test" }, }); } @@ -312,16 +267,16 @@ describe("kernel + workflowManager integration", () => { .filter((e) => e.source === "sense" && e.refId === "cpu-usage"); const typeOrder = senseEntries.map((e) => e.type); const completeAt = typeOrder.indexOf("compute-complete"); - const launchAt = typeOrder.indexOf("workflow-launch"); + const shellAt = typeOrder.indexOf("shell-launch"); expect(completeAt).toBeGreaterThanOrEqual(0); - expect(launchAt).toBeGreaterThan(completeAt); + expect(shellAt).toBeGreaterThan(completeAt); const stopPromise = kernel.stop(); await vi.runAllTimersAsync(); await stopPromise; }); - it("does not trigger workflow when compute-result has workflow null", async () => { + it("does not trigger workflow when compute-result has trigger null", async () => { const logStore = makeLogStore(); const config = makeConfig({ senses: { @@ -362,7 +317,6 @@ describe("kernel + workflowManager integration", () => { }); } - // No workflow should have been started const workflowWorkerSpawned = mockChildren.some((c) => (c.send as ReturnType).mock.calls.some( (args: unknown[]) => @@ -395,7 +349,6 @@ describe("kernel + workflowManager integration", () => { sense: "cpu-usage", state: {}, trigger: { - kind: "shell", command: "echo nerve-shell-test", }, }); @@ -425,19 +378,9 @@ describe("kernel + workflowManager integration", () => { }); describe("workflow events are logged", () => { - it("logs a 'started' event when workflow thread is triggered via sense compute", async () => { + it("logs a 'started' event when workflow thread is started via workflowManager", async () => { const logStore = makeLogStore(); const config = makeConfig({ - senses: { - "cpu-usage": { - group: "system", - throttle: null, - timeout: null, - gracePeriod: null, - interval: null, - on: [], - }, - }, workflows: { "log-test-workflow": { concurrency: 2, overflow: "drop" } }, }); @@ -448,21 +391,11 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - const workerPool = mockChildren[0]; - if (workerPool) { - workerPool.emit("message", { - type: "compute-result", - sense: "cpu-usage", - state: { note: "log" }, - trigger: { - kind: "workflow", - name: "log-test-workflow", - maxRounds: 10, - prompt: "test prompt", - dryRun: false, - }, - }); - } + kernel.workflowManager.startWorkflow("log-test-workflow", { + prompt: "test prompt", + maxRounds: 10, + dryRun: false, + }); await vi.runAllTimersAsync(); @@ -481,16 +414,6 @@ describe("kernel + workflowManager integration", () => { it("new workflows are available after reloadConfig", async () => { const logStore = makeLogStore(); const initialConfig = makeConfig({ - senses: { - "cpu-usage": { - group: "system", - throttle: null, - timeout: null, - gracePeriod: null, - interval: null, - on: [], - }, - }, workflows: {}, maxRounds: 10, }); @@ -502,7 +425,6 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - // Reload with a workflow added const newConfig: NerveConfig = { senses: { "cpu-usage": { @@ -521,21 +443,11 @@ describe("kernel + workflowManager integration", () => { }; kernel.reloadConfig(newConfig); - const workerPool = mockChildren[0]; - if (workerPool) { - workerPool.emit("message", { - type: "compute-result", - sense: "cpu-usage", - state: { phase: "reload" }, - trigger: { - kind: "workflow", - name: "new-workflow", - maxRounds: 10, - prompt: "reload test", - dryRun: false, - }, - }); - } + kernel.workflowManager.startWorkflow("new-workflow", { + prompt: "reload test", + maxRounds: 10, + dryRun: false, + }); await vi.runAllTimersAsync(); @@ -559,16 +471,6 @@ describe("kernel + workflowManager integration", () => { it("old workflows are removed after reloadConfig", async () => { const logStore = makeLogStore(); const initialConfig = makeConfig({ - senses: { - "cpu-usage": { - group: "system", - throttle: null, - timeout: null, - gracePeriod: null, - interval: null, - on: [], - }, - }, workflows: { "old-workflow": { concurrency: 1, overflow: "drop" } }, }); @@ -579,7 +481,6 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - // Reload with the workflow removed const newConfig: NerveConfig = { senses: { "cpu-usage": { @@ -598,26 +499,15 @@ describe("kernel + workflowManager integration", () => { }; kernel.reloadConfig(newConfig); - // Clear send history for (const c of mockChildren) { (c.send as ReturnType).mockClear(); } - const workerPool = mockChildren[0]; - if (workerPool) { - workerPool.emit("message", { - type: "compute-result", - sense: "cpu-usage", - state: { stale: true }, - trigger: { - kind: "workflow", - name: "old-workflow", - maxRounds: 10, - prompt: "should not work", - dryRun: false, - }, - }); - } + kernel.workflowManager.startWorkflow("old-workflow", { + prompt: "should not work", + maxRounds: 10, + dryRun: false, + }); await vi.runAllTimersAsync(); @@ -642,16 +532,6 @@ describe("kernel + workflowManager integration", () => { it("stop() resolves after workflow workers exit", async () => { const logStore = makeLogStore(); const config = makeConfig({ - senses: { - "cpu-usage": { - group: "system", - throttle: null, - timeout: null, - gracePeriod: null, - interval: null, - on: [], - }, - }, workflows: { "shutdown-test": { concurrency: 1, overflow: "drop" } }, }); @@ -662,21 +542,11 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - const workerPool = mockChildren[0]; - if (workerPool) { - workerPool.emit("message", { - type: "compute-result", - sense: "cpu-usage", - state: { shutdownCase: true }, - trigger: { - kind: "workflow", - name: "shutdown-test", - maxRounds: 10, - prompt: "test", - dryRun: false, - }, - }); - } + kernel.workflowManager.startWorkflow("shutdown-test", { + prompt: "test", + maxRounds: 10, + dryRun: false, + }); await vi.runAllTimersAsync(); @@ -707,16 +577,6 @@ describe("kernel + workflowManager integration", () => { it("getHealth includes activeWorkflows count", async () => { const logStore = makeLogStore(); const config = makeConfig({ - senses: { - "cpu-usage": { - group: "system", - throttle: null, - timeout: null, - gracePeriod: null, - interval: null, - on: [], - }, - }, workflows: { "health-wf": { concurrency: 2, overflow: "drop" } }, }); diff --git a/packages/daemon/src/ipc.ts b/packages/daemon/src/ipc.ts index 48a6237..e23f692 100644 --- a/packages/daemon/src/ipc.ts +++ b/packages/daemon/src/ipc.ts @@ -3,7 +3,7 @@ * Protocol per RFC §5.2: hub-and-spoke, all messages through engine. */ -import type { Result, SenseTrigger, WorkflowTrigger } from "@uncaged/nerve-core"; +import type { Result, SenseTrigger } from "@uncaged/nerve-core"; import { err, isPlainRecord, ok, parseSenseTrigger } from "@uncaged/nerve-core"; /** Parent → Worker: trigger one compute cycle for a sense */ @@ -65,7 +65,7 @@ export type ParentToWorkerMessage = | ResumeThreadMessage | KillThreadMessage; -/** Worker → Parent: sense compute finished (state persisted in worker; workflow optional). */ +/** Worker → Parent: sense compute finished (state persisted in worker; optional shell trigger). */ export type ComputeResultMessage = { type: "compute-result"; sense: string; @@ -73,13 +73,6 @@ export type ComputeResultMessage = { trigger: SenseTrigger | null; }; -/** Worker → Parent: sense compute result includes a workflow to start */ -export type SenseWorkflowTriggerMessage = { - type: "sense-workflow-trigger"; - sense: string; - workflow: WorkflowTrigger; -}; - /** Worker → Parent: compute threw or returned an unexpected error */ export type ErrorMessage = { type: "error"; @@ -147,8 +140,7 @@ export type WorkerToParentMessage = | HealthResponseMessage | ThreadEventMessage | WorkflowErrorMessage - | ThreadWorkflowMessageMessage - | SenseWorkflowTriggerMessage; + | ThreadWorkflowMessageMessage; const PARENT_MSG_TYPES = new Set([ "compute", @@ -258,15 +250,15 @@ function parseComputeResultMsg(obj: Record): Result): Result { - if (typeof obj.sense !== "string") { - return err(new Error("Worker 'sense-workflow-trigger' message missing string 'sense' field")); - } - if (!isPlainRecord(obj.workflow)) { - return err( - new Error("Worker 'sense-workflow-trigger' message missing object 'workflow' field"), - ); - } - const parsed = parseSenseTrigger(obj.workflow); - if (!parsed.ok) return err(parsed.error); - if (parsed.value.kind !== "workflow") { - return err(new Error("Worker 'sense-workflow-trigger' expects kind \"workflow\"")); - } - return ok({ - type: "sense-workflow-trigger", - sense: obj.sense, - workflow: parsed.value, - }); -} - /** Validate and parse an unknown IPC message received from a worker process. */ export function parseWorkerMessage(raw: unknown): Result { if (!isPlainRecord(raw)) { @@ -442,6 +412,5 @@ export function parseWorkerMessage(raw: unknown): Result if (obj.type === "thread-event") return parseThreadEventMsg(obj); if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj); if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj); - if (obj.type === "sense-workflow-trigger") return parseSenseWorkflowTriggerMsg(obj); return ok({ type: "ready" }); } diff --git a/packages/daemon/src/kernel.ts b/packages/daemon/src/kernel.ts index 3f2ea57..d411a84 100644 --- a/packages/daemon/src/kernel.ts +++ b/packages/daemon/src/kernel.ts @@ -155,28 +155,13 @@ export function createKernel( }); if (trigger !== null) { - if (trigger.kind === "workflow") { - workflowManager.startWorkflow(trigger.name, { - prompt: trigger.prompt, - maxRounds: trigger.maxRounds, - dryRun: trigger.dryRun, - }); - logStore.append({ - source: "sense", - type: "workflow-launch", - refId: senseName, - payload: JSON.stringify(trigger), - timestamp: Date.now(), - }); - } else { - logStore.append({ - source: "sense", - type: "shell-launch", - refId: senseName, - payload: JSON.stringify(trigger), - timestamp: Date.now(), - }); - } + logStore.append({ + source: "sense", + type: "shell-launch", + refId: senseName, + payload: JSON.stringify(trigger), + timestamp: Date.now(), + }); } scheduler.onComputeComplete(senseName); scheduler.onSenseCompleted(senseName); diff --git a/packages/daemon/src/sense-worker.ts b/packages/daemon/src/sense-worker.ts index 4a4787b..4243bed 100644 --- a/packages/daemon/src/sense-worker.ts +++ b/packages/daemon/src/sense-worker.ts @@ -49,7 +49,7 @@ function sendComputeResult( } function executeShellTriggerIfNeeded(nerveRoot: string, trigger: SenseTrigger | null): void { - if (trigger === null || trigger.kind !== "shell") return; + if (trigger === null) return; const child = spawn(trigger.command, { shell: true, cwd: nerveRoot, diff --git a/packages/daemon/src/workflow-manager.ts b/packages/daemon/src/workflow-manager.ts index 14e4eaa..9048097 100644 --- a/packages/daemon/src/workflow-manager.ts +++ b/packages/daemon/src/workflow-manager.ts @@ -35,7 +35,7 @@ export type WorkflowLaunchParams = { }; export type WorkflowManager = { - /** Trigger a new workflow thread (Sense-driven launch or CLI / IPC). */ + /** Trigger a new workflow thread (CLI / daemon IPC). */ startWorkflow: (workflowName: string, launch: WorkflowLaunchParams) => void; /** * Kill a running or queued workflow thread by runId. diff --git a/packages/skills/nerve-dev/SKILL.md b/packages/skills/nerve-dev/SKILL.md index 7f4d246..86586c9 100644 --- a/packages/skills/nerve-dev/SKILL.md +++ b/packages/skills/nerve-dev/SKILL.md @@ -150,46 +150,30 @@ export async function compute( } ``` -**Signal + Workflow 联动**:Signal 和 Workflow 是蕴含关系 — 有 signal 才可能触发 workflow,两者不互斥: +**Shell trigger vs Workflow**:Sense `compute` 只能请求 `{ command: string }`,由 worker 执行 shell。要跑 workflow,请在命令里调用 CLI(例如 `nerve workflow trigger ...`)或由外部通过 daemon IPC 触发。 ```typescript -export async function compute(db) { +// 示例:异常时在 shell 里触发 workflow(需 PATH 中能调用 nerve) +export async function compute(state) { const anomaly = detectAnomaly(); - if (!anomaly) return null; + if (!anomaly) return { state, trigger: null }; return { - signal: { level: "critical", cpu: anomaly.cpu }, - workflow: { - name: "alert", - maxRounds: 5, - prompt: "CPU 持续高负载,需要分析", - dryRun: false, + state: { ...state, lastAlert: Date.now() }, + trigger: { + command: + 'nerve workflow trigger alert --prompt "CPU 持续高负载" --max-rounds 5', }, }; - // → 先发 Signal,再启动 alert workflow } ``` -**`WorkflowTrigger` 类型**(定义在 `@uncaged/nerve-core`): +**`SenseTrigger` 类型**(`@uncaged/nerve-core`):`{ command: string }`。由 `parseSenseTrigger` 校验(仅允许 `command` 键)。 -```typescript -type WorkflowTrigger = { - name: string; // workflow 名称 - maxRounds: number; // 最大轮数(>= 1) - prompt: string; // 传递给 workflow 的 prompt - dryRun: boolean; // 是否 dry-run -}; -``` - -**compute 返回值路由规则**(由 `routeSenseComputeOutput()` 决定): - -| 返回值 | 行为 | +| `trigger` | 行为 | |--------|------| -| `null` | 静默,不发 Signal | -| `{ signal: T, workflow: null }` | 发出 **Signal**,不触发 Workflow | -| `{ signal: T, workflow: WorkflowTrigger }` | 先发 **Signal**,再启动 **Workflow** | -| `{ signal: T, workflow: 非法对象 }` | 降级为 signal-only(workflow 被忽略) | -| 裸值(无 `signal` 键) | 兼容模式:整个值作为 signal payload,不触发 workflow | +| `null` | 只持久化 state | +| `{ command }` | 持久化 state + worker 执行 shell 命令 | ### Drizzle Schema 与迁移