refactor(core): remove WorkflowTrigger from SenseTrigger — shell only
Senses trigger shell commands only. Workflows are invoked via CLI.
SenseTrigger is now { command: string } — no discriminated union.
Closes #318
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+8
-9
@@ -17,17 +17,16 @@ export async function compute(): Promise<ComputeResult<T>> { ... } // 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<T>` = `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/<name>.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)
|
||||
|
||||
@@ -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/<name>.json)
|
||||
↓
|
||||
IPC compute-result → kernel
|
||||
trigger !== null → spawn shell command (cwd = nerve root)
|
||||
↓
|
||||
IPC compute-result → kernel (audit: shell-launch log)
|
||||
↓
|
||||
workflow !== null → parseWorkflowTrigger (validation) → workflowManager.startWorkflow
|
||||
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
|
||||
|
||||
|
||||
@@ -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<S> = {
|
||||
state: S;
|
||||
trigger: SenseTrigger | null;
|
||||
|
||||
@@ -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/<name>.json`. |
|
||||
| **Sense** | 👁️ Perception | Stateful `compute(state)` returning `{ state, trigger }`. State lives in `data/senses/<name>.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 |
|
||||
|
||||
@@ -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` 适配器包;与常量未被引用叠加 | **中** | **对齐产品**:实现适配器或从列表移除。 |
|
||||
|
||||
|
||||
@@ -212,10 +212,10 @@ extract:
|
||||
|
||||
### compute 函数签名
|
||||
|
||||
Sense 的 `compute` 接收当前状态,返回新状态和可选的 workflow trigger。状态以 JSON 文件持久化在 `data/senses/<name>.json`。
|
||||
Sense 的 `compute` 接收当前状态,返回新状态和可选的 shell trigger(`{ command: string }`)。状态以 JSON 文件持久化在 `data/senses/<name>.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;
|
||||
}> {
|
||||
// ...
|
||||
}
|
||||
|
||||
@@ -252,22 +252,7 @@ export async function compute(): Promise<ComputeResult<MySignalShape>> {
|
||||
|
||||
### 返回值
|
||||
|
||||
```typescript
|
||||
// 返回 null = 静默,不发 signal
|
||||
// 返回非 null = 发出 signal(并写入业务表),可选触发 workflow
|
||||
type ComputeResult<T> =
|
||||
| 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 模块导出
|
||||
|
||||
|
||||
@@ -245,22 +245,7 @@ export async function compute(): Promise<ComputeResult<MySignalShape>> {
|
||||
|
||||
### 返回值
|
||||
|
||||
```typescript
|
||||
// 返回 null = 静默,不发 signal
|
||||
// 返回非 null = 发出 signal(并写入业务表),可选触发 workflow
|
||||
type ComputeResult<T> =
|
||||
| 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 模块导出
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+8
-13
@@ -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<T>` 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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -7,8 +7,6 @@ export type {
|
||||
AgentConfig,
|
||||
ExtractConfig,
|
||||
NerveConfig,
|
||||
WorkflowTrigger,
|
||||
ShellTrigger,
|
||||
SenseTrigger,
|
||||
} from "./config.js";
|
||||
export type { SenseInfo } from "./sense.js";
|
||||
|
||||
+10
-42
@@ -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<string, unknown>): Result<WorkflowTrigger> {
|
||||
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<string, unknown>): Result<ShellTrigger> {
|
||||
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<SenseTrigger> {
|
||||
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);
|
||||
}
|
||||
return err(new Error('sense trigger: "kind" must be "workflow" or "shell"'));
|
||||
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 ok({ command: command.trim() });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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<typeof vi.fn>).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,
|
||||
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<typeof vi.fn>).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<typeof vi.fn>).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,
|
||||
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,
|
||||
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<typeof vi.fn>).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,
|
||||
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,
|
||||
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" } },
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown>): Result<WorkerToPar
|
||||
if (!("trigger" in obj)) {
|
||||
return err(new Error("Worker 'compute-result' message missing 'trigger' field"));
|
||||
}
|
||||
const wfRaw = obj.trigger;
|
||||
if (wfRaw !== null && !isPlainRecord(wfRaw)) {
|
||||
const triggerRaw = obj.trigger;
|
||||
if (triggerRaw !== null && !isPlainRecord(triggerRaw)) {
|
||||
return err(new Error("Worker 'compute-result' trigger must be an object or null"));
|
||||
}
|
||||
let trigger: SenseTrigger | null;
|
||||
if (wfRaw === null) {
|
||||
if (triggerRaw === null) {
|
||||
trigger = null;
|
||||
} else {
|
||||
const parsed = parseSenseTrigger(wfRaw);
|
||||
const parsed = parseSenseTrigger(triggerRaw);
|
||||
if (!parsed.ok) return err(parsed.error);
|
||||
trigger = parsed.value;
|
||||
}
|
||||
@@ -365,7 +357,6 @@ const WORKER_MSG_TYPES = new Set([
|
||||
"thread-event",
|
||||
"workflow-error",
|
||||
"thread-workflow-message",
|
||||
"sense-workflow-trigger",
|
||||
]);
|
||||
|
||||
function parseThreadWorkflowMessageMsg(
|
||||
@@ -403,27 +394,6 @@ function parseThreadWorkflowMessageMsg(
|
||||
});
|
||||
}
|
||||
|
||||
function parseSenseWorkflowTriggerMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||
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<WorkerToParentMessage> {
|
||||
if (!isPlainRecord(raw)) {
|
||||
@@ -442,6 +412,5 @@ export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage>
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -155,20 +155,6 @@ 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",
|
||||
@@ -177,7 +163,6 @@ export function createKernel(
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
scheduler.onComputeComplete(senseName);
|
||||
scheduler.onSenseCompleted(senseName);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <name> ...`)或由外部通过 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 与迁移
|
||||
|
||||
|
||||
Reference in New Issue
Block a user