Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8e42c838b |
@@ -20,7 +20,7 @@ Always use static top-level `import` statements.
|
||||
## Exceptions (must include a comment explaining why)
|
||||
|
||||
1. **`sense-runtime.ts`** — loads user-authored sense modules whose paths are only known at runtime
|
||||
2. **`packages/workflow/src/worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
|
||||
2. **`workflow-worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
|
||||
@@ -5,27 +5,19 @@ Observation engine for autonomous agents — sense the world, react to changes,
|
||||
## Core Pipeline
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Workflow → Log
|
||||
↑
|
||||
compute() returns
|
||||
{ signal, workflow }
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
```
|
||||
|
||||
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger further Senses (prevents feedback loops).
|
||||
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger Senses (prevents feedback loops).
|
||||
|
||||
## Two Extension Points
|
||||
|
||||
| Extension | Question | Nature |
|
||||
|-----------|----------|--------|
|
||||
| **Sense** | What to observe & when to react | `compute()` pure function + YAML config (interval / on) |
|
||||
| **Sense** | What to observe & when to react | `compute(state)` stateful function + YAML config (interval / on) |
|
||||
| **Workflow** | What to do | Roles + Moderator |
|
||||
|
||||
Senses own both the "what" (compute logic) and the "when" (config-driven scheduling). A Sense can trigger a Workflow directly by returning `{ signal, workflow: { name, prompt } }`.
|
||||
|
||||
## Two Event Types
|
||||
|
||||
- **Signal** — from Sense compute (non-null return). Pure fact, no intent. Drives the front half (perception).
|
||||
- **Command Event** — inside Workflow Threads. Has causal chain, must be responded to. Drives the back half (action).
|
||||
Senses own both the "what" (compute logic) and the "when" (config-driven scheduling). A Sense can trigger a Workflow by returning `{ state, workflow: { name, prompt, maxRounds, dryRun } }`.
|
||||
|
||||
## Process Isolation
|
||||
|
||||
@@ -38,10 +30,6 @@ Senses own both the "what" (compute logic) and the "when" (config-driven schedul
|
||||
## Storage Systems
|
||||
|
||||
- **Log Store** — SQLite with WAL mode for audit trails and workflow state
|
||||
- **Sense Databases** — Isolated SQLite per sense group for private data
|
||||
- **Sense State** — JSON files per sense (`data/senses/<name>.json`), atomically written
|
||||
- **Knowledge Store** — Vector search index for project context
|
||||
- **Blob Store** — Content-addressable storage for large artifacts
|
||||
|
||||
## Signal Flow
|
||||
|
||||
Sense compute outputs are routed through signal routing logic that determines whether to emit a signal or trigger a workflow—never both simultaneously.
|
||||
|
||||
+8
-10
@@ -18,21 +18,18 @@ nerve daemon # restart daemon (stop + start)
|
||||
|
||||
### Init Behavior
|
||||
|
||||
**Default `nerve init`**: Creates workspace at `~/.uncaged-nerve/`. If this directory already exists and is non-empty, **exits with error** requiring `--force` flag. No merge/overwrite logic — prevents accidental workspace destruction.
|
||||
**Default `nerve init`**: Creates workspace at `~/.uncaged-nerve/`. If this directory already exists and is non-empty, **exits with error** requiring `--force` flag.
|
||||
|
||||
**Force mode `nerve init --force`**: Reinitializes workspace even if `~/.uncaged-nerve/` exists. **Preserves `data/` directory** (containing sense SQLite databases and logs) but overwrites all config files (`nerve.yaml`, `package.json`, etc.) and example senses.
|
||||
**Force mode `nerve init --force`**: Reinitializes workspace even if `~/.uncaged-nerve/` exists. **Preserves `data/` directory** (containing sense state files and logs) but overwrites all config files.
|
||||
|
||||
**Git clone `nerve init --from <url>`**: Clones existing repository to `~/.uncaged-nerve/`. Requires empty target directory — fails if workspace already exists and is non-empty.
|
||||
**Git clone `nerve init --from <url>`**: Clones existing repository to `~/.uncaged-nerve/`. Requires empty target directory.
|
||||
|
||||
## Sense Management
|
||||
|
||||
```bash
|
||||
nerve create sense <name> # scaffold a new sense (compute.ts + schema.ts)
|
||||
nerve create sense <name> # scaffold a new sense (compute + initialState)
|
||||
nerve sense list # list configured senses
|
||||
nerve sense trigger <name> # manually trigger a sense compute
|
||||
nerve sense schema <name> # show sense Drizzle schema
|
||||
nerve sense query <name> # inspect sense SQLite database
|
||||
nerve sense query <name> --sql "SELECT * FROM samples LIMIT 5"
|
||||
```
|
||||
|
||||
## Workflow Management
|
||||
@@ -77,12 +74,13 @@ my-agent/
|
||||
knowledge.yaml # knowledge index config (optional)
|
||||
senses/
|
||||
cpu-usage/
|
||||
compute.ts # sense implementation
|
||||
schema.ts # Drizzle schema
|
||||
migrations/ # auto-generated
|
||||
src/index.ts # compute(state) + initialState export
|
||||
workflows/
|
||||
cleanup/
|
||||
src/index.ts # workflow definition
|
||||
data/
|
||||
senses/ # sense state JSON files (auto-generated)
|
||||
logs.db # log store (auto-generated)
|
||||
knowledge.db # generated by nerve knowledge sync
|
||||
.knowledge/ # curated knowledge cards
|
||||
```
|
||||
|
||||
@@ -58,7 +58,7 @@ No compiler enforcement - relies on manual discipline and TypeScript's flow cont
|
||||
**Primary exports** use descriptive, unambiguous names:
|
||||
- Functions: `createXxx()`, `parseXxx()`, `xxxAgent()` (e.g., `createCursorAdapter`, `cursorAgent`)
|
||||
- Types: Domain-specific prefixes (e.g., `CursorAgentOptions`, `SenseComputeFn`, `ThreadContext`)
|
||||
- Constants: `UPPER_SNAKE_CASE` with context (e.g., `DEFAULT_SENSE_SIGNAL_RETENTION`, `CURSOR_ADAPTER_DEFAULT_MS`)
|
||||
- Constants: `UPPER_SNAKE_CASE` with context (e.g., `DEFAULT_ENGINE_MAX_ROUNDS`, `CURSOR_ADAPTER_DEFAULT_MS`)
|
||||
|
||||
**Avoiding ambiguity**:
|
||||
- Package-scoped naming: `@uncaged/nerve-adapter-cursor` exports `cursorAgent`, `createCursorAdapter`
|
||||
|
||||
+29
-22
@@ -1,32 +1,41 @@
|
||||
# Sense
|
||||
|
||||
A `compute()` function that samples or derives external data. The only first-class citizen in nerve.
|
||||
A stateful `compute(state)` function that samples or derives external data. Returns new state and an optional workflow trigger.
|
||||
|
||||
## Contract
|
||||
|
||||
Each sense module (`src/index.ts`) must export:
|
||||
|
||||
```ts
|
||||
export { snapshots as table } from "./schema.ts"; // drizzle table for runtime to insert into
|
||||
type MyState = { lastRun: number | null };
|
||||
|
||||
export async function compute(): Promise<ComputeResult<T>> { ... } // pure, no args
|
||||
export const initialState: MyState = { lastRun: null };
|
||||
|
||||
export async function compute(state: MyState): Promise<{
|
||||
state: MyState;
|
||||
workflow: WorkflowTrigger | null;
|
||||
}> {
|
||||
// ... observe external world, derive new state
|
||||
return { state: { lastRun: Date.now() }, workflow: null };
|
||||
}
|
||||
```
|
||||
|
||||
**Function Signature & Input Schema:**
|
||||
- `compute()` is **parameterless** — no direct inputs, environment variables available
|
||||
- No database access within compute — runtime provides isolated execution context
|
||||
- Must be pure function (no side effects, no external API calls)
|
||||
**Function Signature:**
|
||||
- `compute(state: S)` — receives previous state (or `initialState` on first run)
|
||||
- Returns `{ state: S; workflow: WorkflowTrigger | null }`
|
||||
- `workflow: null` → no workflow triggered
|
||||
- `workflow: { name, maxRounds, prompt, dryRun }` → triggers a workflow
|
||||
|
||||
**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.).
|
||||
**State Persistence:**
|
||||
- State stored as JSON at `data/senses/<name>.json`
|
||||
- Read on worker startup; if missing or corrupt, `initialState` is used
|
||||
- Written atomically (temp file + rename) after each successful compute
|
||||
- Memory state updated only after disk write succeeds
|
||||
|
||||
**Error Handling & Serialization:**
|
||||
**Error Handling:**
|
||||
- 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`
|
||||
- State payload must be JSON-serializable
|
||||
- Invalid workflow triggers rejected by daemon (workflow not started, compute still succeeds)
|
||||
|
||||
**Timeout & Scheduling Semantics:**
|
||||
- Timeout priority: explicit config → AbortSignal → DEFAULT_TIMEOUT_MS (30s)
|
||||
@@ -45,16 +54,14 @@ senses:
|
||||
timeout: 30s # max compute duration
|
||||
grace_period: 5s # wait before first compute
|
||||
interval: 30s # periodic trigger (optional)
|
||||
on: [disk-pressure] # trigger on signals from other senses (optional)
|
||||
on: [disk-pressure] # trigger when another sense completes (optional)
|
||||
```
|
||||
|
||||
## Manual Trigger Context
|
||||
|
||||
**`nerve sense trigger <name>`** sends IPC message to running daemon. The compute context is initialized as follows:
|
||||
**`nerve sense trigger <name>`** sends IPC message to running daemon. The compute runs in the sense's worker process with:
|
||||
|
||||
- **SQLite Database**: Opened in **read-write mode** at `data/senses/<name>.db`
|
||||
- **Migrations**: All `*.sql` files in `senses/<name>/migrations/` applied in lexicographic order
|
||||
- **Environment**: Inherits daemon process environment (no special secrets injection)
|
||||
- **Arguments**: No runtime arguments or mock inputs supported — `compute()` is always pure function with no parameters
|
||||
- **State**: Read from `data/senses/<name>.json` (or `initialState` if missing)
|
||||
- **Environment**: Inherits daemon process environment
|
||||
- **Isolation**: Runs in forked child process (worker) with full filesystem access within user permissions
|
||||
- **Persistence**: Runtime automatically calls `db.insert(table).values(result.signal)` if compute returns non-null signal
|
||||
- **Persistence**: State written to JSON file after successful compute
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
# Sense compute → shell + scheduler (issue #318)
|
||||
# Sense compute → workflow (RFC #308)
|
||||
|
||||
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.
|
||||
Stateful senses no longer emit signals or pass outputs through `routeSenseComputeOutput`. The worker runs `compute(state)` and returns `{ state, workflow }`.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Sense worker: compute(state) → { state, trigger }
|
||||
Sense worker: compute(state) → { state, workflow }
|
||||
↓
|
||||
persist state JSON (data/senses/<name>.json)
|
||||
↓
|
||||
trigger !== null → spawn shell command (cwd = nerve root)
|
||||
IPC compute-result → kernel
|
||||
↓
|
||||
IPC compute-result → kernel (audit: shell-launch log)
|
||||
↓
|
||||
scheduler.onSenseCompleted(senseName) → dependents with `on: [senseName]`
|
||||
workflow !== null → parseWorkflowTrigger (validation) → workflowManager.startWorkflow
|
||||
scheduler.onSenseCompleted(senseName) → dependents with `on: [senseName]`
|
||||
```
|
||||
|
||||
Workflow runs: **`workflowManager.startWorkflow`** from CLI / daemon IPC only (`nerve workflow trigger`, HTTP when enabled).
|
||||
## Workflow trigger shape
|
||||
|
||||
## Sense trigger shape
|
||||
When `workflow` is non-null it must be a plain object validated by `parseWorkflowTrigger()` in `packages/core/src/sense.ts`:
|
||||
|
||||
When `trigger` is non-null it must be a plain object validated by `parseSenseTrigger()` in `packages/core/src/sense.ts`:
|
||||
- `name`: non-empty string
|
||||
- `maxRounds`: integer ≥ 1
|
||||
- `prompt`: string
|
||||
- `dryRun`: boolean
|
||||
|
||||
- Exactly one property: `command` (non-empty string after trim)
|
||||
- No `kind` field; no workflow fields
|
||||
|
||||
Invalid triggers are rejected when parsing the worker message.
|
||||
Invalid triggers are rejected by the daemon when handling the worker message (workflow is not started).
|
||||
|
||||
## Scheduling
|
||||
|
||||
|
||||
+12
-13
@@ -18,13 +18,13 @@ Append-only audit trail implemented in SQLite with WAL mode.
|
||||
- Configurable log archival to JSONL files
|
||||
- Full-text search across log entries
|
||||
|
||||
### 2. Sense Databases
|
||||
Each sense group gets its own SQLite database for private state.
|
||||
### 2. Sense State Files
|
||||
Each sense persists its state as a JSON file.
|
||||
|
||||
**Characteristics:**
|
||||
- Isolated per sense group (e.g., `system-senses.db`)
|
||||
- Managed by individual sense compute functions
|
||||
- Drizzle ORM integration for schema management
|
||||
- One JSON file per sense at `data/senses/<name>.json`
|
||||
- Atomically written (temp file + rename) after each successful compute
|
||||
- Read on worker startup; `initialState` used if missing or corrupt
|
||||
- No cross-sense data sharing
|
||||
|
||||
### 3. Knowledge Store (`knowledge.db`)
|
||||
@@ -74,11 +74,10 @@ function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
||||
- `upsertWorkflowRun()` — atomically writes log entry + workflow state
|
||||
- `archiveLogs()` — transactional export + delete + watermark update
|
||||
|
||||
#### Sense Database Isolation
|
||||
- Each sense group has its own SQLite file (e.g., `system-senses.db`)
|
||||
- No cross-sense transactions or coordination required
|
||||
- Independent schema migrations per sense
|
||||
- Private `_signals` table for signal history retention
|
||||
#### Sense State Isolation
|
||||
- Each sense has its own JSON state file
|
||||
- No cross-sense coordination required
|
||||
- State files are independent and self-contained
|
||||
|
||||
### Process-Level Isolation
|
||||
|
||||
@@ -107,14 +106,14 @@ workflows:
|
||||
**No Cross-Database Consistency**:
|
||||
- No distributed transactions across multiple SQLite files
|
||||
- Log Store and Sense Databases can temporarily diverge during failures
|
||||
- Signal emission and workflow triggering are separate, non-atomic operations
|
||||
- State persistence and workflow triggering are separate, non-atomic operations
|
||||
|
||||
**Failure Recovery Mechanisms**:
|
||||
- **Sense worker crash**: State rebuilt from sense SQLite database on respawn
|
||||
- **Sense worker crash**: State rebuilt from JSON state file on respawn (or `initialState` if corrupt)
|
||||
- **Workflow worker crash**: Thread state recovered from log store message history
|
||||
- **Kernel crash**: All workers respawned, state recovered from persistent stores
|
||||
- **Log Store corruption**: WAL recovery on database open
|
||||
- **Sense DB corruption**: Migrations re-run, `_signals` table rebuilt if needed
|
||||
- **Sense state corruption**: Falls back to `initialState` with stderr warning
|
||||
|
||||
**Rollback Scenarios**:
|
||||
- **Log write failure**: Transaction rolled back, no state changes persisted
|
||||
|
||||
@@ -24,7 +24,7 @@ Worker **entrypoints** (`sense-worker.ts`, `workflow-worker.ts`) import lightwei
|
||||
- **One worker per sense group** (configured in `nerve.yaml`)
|
||||
- Groups share a child process but have isolated execution contexts
|
||||
- Crash in one sense doesn't affect other groups
|
||||
- Each group has its own SQLite database
|
||||
- Each group has its own JSON state files
|
||||
|
||||
### 2. Workflow Workers
|
||||
- **One worker per workflow type** (spawned on-demand)
|
||||
@@ -59,7 +59,7 @@ workflow trigger → check existing worker → reuse or spawn
|
||||
### Kernel ↔ Sense Worker
|
||||
- IPC via child process stdio
|
||||
- JSON-formatted messages
|
||||
- Worker reports signals back to kernel
|
||||
- Worker reports compute results back to kernel
|
||||
- Bidirectional: kernel can request immediate computes
|
||||
|
||||
### Kernel ↔ Workflow Worker
|
||||
@@ -128,7 +128,7 @@ process.on("SIGTERM", () => {});
|
||||
**Sense Workers**:
|
||||
- IPC `shutdown` message → `process.exit(0)` (immediate)
|
||||
- No graceful termination period for senses
|
||||
- State rebuilt from SQLite on respawn (no handoff needed)
|
||||
- State rebuilt from JSON state files on respawn (no handoff needed)
|
||||
|
||||
**Workflow Workers**:
|
||||
- IPC `shutdown` → wait for in-flight threads to complete
|
||||
@@ -138,14 +138,14 @@ process.on("SIGTERM", () => {});
|
||||
|
||||
**State Handoff Mechanism**:
|
||||
- No explicit state transfer between old/new workers
|
||||
- Sense workers: SQLite database contains full state
|
||||
- Sense workers: JSON state files contain full state
|
||||
- Workflow workers: Log store contains thread message history
|
||||
- Kernel coordinates recovery via `recoverThreadsForWorker()`
|
||||
|
||||
## Failure Handling
|
||||
|
||||
### Worker Crashes
|
||||
- **Sense workers**: Automatic respawn after 1s delay, state rebuilt from DB
|
||||
- **Sense workers**: Automatic respawn after 1s delay, state rebuilt from JSON
|
||||
- **Workflow workers**: Crash recovery from log store thread messages
|
||||
- **Kernel protection**: Main process continues, marks affected runs as crashed
|
||||
- **Crash limits**: Max 5 crashes per workflow in 60s window (prevents infinite respawn)
|
||||
|
||||
@@ -104,12 +104,10 @@ type SenseConfig = {
|
||||
For mutually exclusive fields, use discriminated unions:
|
||||
|
||||
```typescript
|
||||
import type { SenseTrigger } from "@uncaged/nerve-core";
|
||||
|
||||
// ✅ Good — sense modules return explicit next state + optional shell trigger only
|
||||
// ✅ Good — sense modules return explicit next state + optional workflow trigger
|
||||
type SenseComputeReturn<S> = {
|
||||
state: S;
|
||||
trigger: SenseTrigger | null;
|
||||
workflow: WorkflowTrigger | null;
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -7,21 +7,19 @@ Nerve is a lightweight daemon that continuously observes external state through
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense(state) → { state, trigger? } → (shell in worker) / Log
|
||||
│
|
||||
Workflow → Log (CLI / daemon IPC only)
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
↑
|
||||
scheduling: interval / on (per sense in nerve.yaml)
|
||||
```
|
||||
|
||||
| Concept | Metaphor | Role |
|
||||
|---------|----------|------|
|
||||
| **Sense** | 👁️ Perception | Stateful `compute(state)` returning `{ state, trigger }`. State lives in `data/senses/<name>.json`. |
|
||||
| **Sense** | 👁️ Perception | Stateful `compute(state)` returning `{ state, workflow }`. 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 via CLI / daemon IPC (`nerve workflow trigger`, transport). Not started from sense `compute()` results. |
|
||||
| **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. |
|
||||
| **Log** | 📝 Record | Immutable audit trail. **Cannot** schedule senses or workflows (prevents feedback loops). |
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
Two extension points for **what to observe (+ when)** vs **multi-step action** — scheduling is declarative config on each sense, not a separate YAML section.
|
||||
|
||||
@@ -29,7 +27,7 @@ Two extension points for **what to observe (+ when)** vs **multi-step action**
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, sense trigger validation (`parseSenseTrigger`), daemon IPC protocol |
|
||||
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, workflow trigger validation, 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、`parseSenseTrigger`(shell-only)对齐 | — | 关闭 |
|
||||
| ~~已更名 API 仍出现在 README~~ | `packages/core/README.md` | (已修正)文档与 stateful sense、`parseWorkflowTrigger` 对齐;`routeSenseComputeOutput` 已移除 | — | 关闭 |
|
||||
| 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` 适配器包;与常量未被引用叠加 | **中** | **对齐产品**:实现适配器或从列表移除。 |
|
||||
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
# Extract Workflow Engine into `@uncaged/workflow` — Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Extract the workflow engine (types, runtime, IPC, manager) from nerve-core and nerve-daemon into a standalone `@uncaged/workflow` package.
|
||||
|
||||
**Architecture:** Create `packages/workflow/` as a new pnpm workspace package. Move workflow types from core and workflow runtime from daemon into it. The daemon becomes a consumer of `@uncaged/workflow`. No backward-compat re-exports — breaking change, update all consumers in one shot.
|
||||
|
||||
**Tech Stack:** TypeScript, pnpm workspace, rslib (bundler, same as other packages), Biome
|
||||
|
||||
**Ref:** Fixes #320
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Scaffold `packages/workflow/`
|
||||
|
||||
### Task 1: Create package skeleton
|
||||
|
||||
**Objective:** Create the `@uncaged/workflow` package with package.json, tsconfig, rslib config.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/package.json`
|
||||
- Create: `packages/workflow/tsconfig.json`
|
||||
- Create: `packages/workflow/rslib.config.ts`
|
||||
- Create: `packages/workflow/src/index.ts` (empty barrel)
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Copy `packages/workflow-utils/package.json` as template, change name to `@uncaged/workflow`, remove all dependencies except dev deps (typescript, rslib, etc.). No runtime deps initially.
|
||||
|
||||
2. Copy `packages/workflow-utils/tsconfig.json` and `rslib.config.ts` as-is (same monorepo conventions).
|
||||
|
||||
3. Create empty `packages/workflow/src/index.ts`:
|
||||
```typescript
|
||||
// @uncaged/workflow — standalone workflow orchestration engine
|
||||
```
|
||||
|
||||
4. Verify:
|
||||
```bash
|
||||
cd packages/workflow && pnpm install && pnpm run build
|
||||
```
|
||||
|
||||
5. Commit:
|
||||
```bash
|
||||
git add packages/workflow/
|
||||
git commit -m "chore(workflow): scaffold @uncaged/workflow package
|
||||
|
||||
Refs #320"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Move Types from Core
|
||||
|
||||
### Task 2: Move `workflow.ts` types to `@uncaged/workflow`
|
||||
|
||||
**Objective:** Move all workflow types and constants from `packages/core/src/workflow.ts` → `packages/workflow/src/types.ts`.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/src/types.ts`
|
||||
- Modify: `packages/workflow/src/index.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Copy `packages/core/src/workflow.ts` → `packages/workflow/src/types.ts` verbatim (all 83 lines — `START`, `END`, `DEFAULT_ENGINE_MAX_ROUNDS`, all types).
|
||||
|
||||
2. Export everything from `packages/workflow/src/index.ts`:
|
||||
```typescript
|
||||
export {
|
||||
START,
|
||||
END,
|
||||
DEFAULT_ENGINE_MAX_ROUNDS,
|
||||
} from "./types.js";
|
||||
export type {
|
||||
WorkflowMessage,
|
||||
RoleResult,
|
||||
Role,
|
||||
RoleMeta,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowContext,
|
||||
AgentFn,
|
||||
RoleStep,
|
||||
ModeratorContext,
|
||||
Moderator,
|
||||
WorkflowDefinition,
|
||||
} from "./types.js";
|
||||
```
|
||||
|
||||
3. Build to verify types compile:
|
||||
```bash
|
||||
cd packages/workflow && pnpm run build
|
||||
```
|
||||
|
||||
4. Commit:
|
||||
```bash
|
||||
git commit -am "refactor(workflow): move workflow types from core to @uncaged/workflow
|
||||
|
||||
Refs #320"
|
||||
```
|
||||
|
||||
### Task 3: Move `WorkflowConfig` to `@uncaged/workflow`
|
||||
|
||||
**Objective:** Move the `WorkflowConfig` type (and its constituent types `DropOverflowConfig`, `QueueOverflowConfig`) from core/config.ts to the workflow package.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/src/config.ts`
|
||||
- Modify: `packages/workflow/src/index.ts`
|
||||
- Modify: `packages/core/src/config.ts` — remove `WorkflowConfig`, `DropOverflowConfig`, `QueueOverflowConfig`; import from `@uncaged/workflow` instead
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Create `packages/workflow/src/config.ts` with the three types:
|
||||
```typescript
|
||||
export type DropOverflowConfig = {
|
||||
concurrency: number;
|
||||
overflow: "drop";
|
||||
};
|
||||
|
||||
export type QueueOverflowConfig = {
|
||||
concurrency: number;
|
||||
overflow: "queue";
|
||||
maxQueue: number;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
|
||||
```
|
||||
|
||||
2. Re-export from `packages/workflow/src/index.ts`.
|
||||
|
||||
3. In `packages/core/package.json`, add dependency: `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
4. In `packages/core/src/config.ts`:
|
||||
- Remove the three type definitions
|
||||
- Add `import type { WorkflowConfig, DropOverflowConfig, QueueOverflowConfig } from "@uncaged/workflow";`
|
||||
- Keep the re-export from `packages/core/src/index.ts` pointing to config.ts (which now re-exports from workflow)
|
||||
|
||||
5. Build entire workspace:
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
6. Commit:
|
||||
```bash
|
||||
git commit -am "refactor(workflow): move WorkflowConfig types to @uncaged/workflow
|
||||
|
||||
Refs #320"
|
||||
```
|
||||
|
||||
### Task 4: Remove workflow types from core, update core exports
|
||||
|
||||
**Objective:** Delete `packages/core/src/workflow.ts` entirely. Core re-exports workflow types from `@uncaged/workflow`.
|
||||
|
||||
**Files:**
|
||||
- Delete: `packages/core/src/workflow.ts`
|
||||
- Modify: `packages/core/src/index.ts` — change workflow exports to re-export from `@uncaged/workflow`
|
||||
- Modify: `packages/core/src/config.ts` — remove import of `DEFAULT_ENGINE_MAX_ROUNDS` from deleted file
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. In `packages/core/src/config.ts`, replace:
|
||||
```typescript
|
||||
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
|
||||
```
|
||||
with:
|
||||
```typescript
|
||||
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
|
||||
```
|
||||
|
||||
2. In `packages/core/src/index.ts`, replace all workflow.js imports with re-exports from `@uncaged/workflow`:
|
||||
```typescript
|
||||
// Workflow types — re-exported from @uncaged/workflow
|
||||
export {
|
||||
START, END, DEFAULT_ENGINE_MAX_ROUNDS,
|
||||
} from "@uncaged/workflow";
|
||||
export type {
|
||||
WorkflowMessage, RoleResult, Role, RoleMeta, StartStep,
|
||||
ThreadContext, WorkflowContext, AgentFn, RoleStep,
|
||||
ModeratorContext, Moderator, WorkflowDefinition,
|
||||
} from "@uncaged/workflow";
|
||||
```
|
||||
|
||||
3. Delete `packages/core/src/workflow.ts`.
|
||||
|
||||
4. Full build + test:
|
||||
```bash
|
||||
pnpm run build && pnpm test
|
||||
```
|
||||
|
||||
5. Commit:
|
||||
```bash
|
||||
git commit -am "refactor(core): remove workflow.ts, re-export from @uncaged/workflow
|
||||
|
||||
Refs #320"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Move Workflow IPC Messages
|
||||
|
||||
### Task 5: Extract workflow IPC types to `@uncaged/workflow`
|
||||
|
||||
**Objective:** Move workflow-related IPC message types from `packages/daemon/src/ipc.ts` to `packages/workflow/src/ipc.ts`. Sense IPC stays in daemon.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/src/ipc.ts`
|
||||
- Modify: `packages/workflow/src/index.ts`
|
||||
- Modify: `packages/daemon/src/ipc.ts` — remove workflow IPC types, import from `@uncaged/workflow`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Extract to `packages/workflow/src/ipc.ts`:
|
||||
- `StartThreadMessage`, `ResumeThreadMessage`, `KillThreadMessage`
|
||||
- `ThreadLifecycleEvent`, `ThreadEventMessage`, `WorkflowErrorMessage`, `ThreadWorkflowMessage`
|
||||
- Workflow-related validation logic from `parseParentToWorkerMessage`
|
||||
- Union type for workflow parent→worker messages
|
||||
|
||||
2. Keep in daemon `ipc.ts`:
|
||||
- `ComputeMessage`, `ShutdownMessage`, `HealthRequestMessage`
|
||||
- Sense-related worker→parent messages
|
||||
- The combined `ParentToWorkerMessage` union (imports workflow types from `@uncaged/workflow`)
|
||||
|
||||
3. Add `@uncaged/workflow` as dependency to `packages/daemon/package.json`.
|
||||
|
||||
4. Build + test:
|
||||
```bash
|
||||
pnpm run build && pnpm test
|
||||
```
|
||||
|
||||
5. Commit:
|
||||
```bash
|
||||
git commit -am "refactor(workflow): move workflow IPC types to @uncaged/workflow
|
||||
|
||||
Refs #320"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Move Workflow Runtime
|
||||
|
||||
### Task 6: Move `workflow-worker.ts` to `@uncaged/workflow`
|
||||
|
||||
**Objective:** Move workflow execution runtime (the worker that runs inside a child process) from daemon to the workflow package.
|
||||
|
||||
**Files:**
|
||||
- Move: `packages/daemon/src/workflow-worker.ts` → `packages/workflow/src/worker.ts`
|
||||
- Modify: `packages/workflow/src/index.ts`
|
||||
- Modify: `packages/daemon/` — update worker spawn path
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Copy `workflow-worker.ts` to `packages/workflow/src/worker.ts`.
|
||||
|
||||
2. Update imports: replace `@uncaged/nerve-core` with local imports from `./types.js`, `./ipc.js`.
|
||||
|
||||
3. Export the worker entry point or the worker file path from `@uncaged/workflow` so daemon can spawn it.
|
||||
|
||||
4. In daemon, update worker spawn to reference `@uncaged/workflow`'s worker.
|
||||
|
||||
5. Delete `packages/daemon/src/workflow-worker.ts`.
|
||||
|
||||
6. Build + test:
|
||||
```bash
|
||||
pnpm run build && pnpm test
|
||||
```
|
||||
|
||||
7. Commit:
|
||||
```bash
|
||||
git commit -am "refactor(workflow): move workflow-worker runtime to @uncaged/workflow
|
||||
|
||||
Refs #320"
|
||||
```
|
||||
|
||||
### Task 7: Move `workflow-manager.ts` and `workflow-manager-support.ts` to `@uncaged/workflow`
|
||||
|
||||
**Objective:** Move workflow process management from daemon to workflow package.
|
||||
|
||||
**Files:**
|
||||
- Move: `packages/daemon/src/workflow-manager.ts` → `packages/workflow/src/manager.ts`
|
||||
- Move: `packages/daemon/src/workflow-manager-support.ts` → `packages/workflow/src/manager-support.ts`
|
||||
- Modify: `packages/daemon/src/kernel.ts` — import from `@uncaged/workflow`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Copy both files to `packages/workflow/src/`.
|
||||
|
||||
2. Update imports to use local paths within the workflow package.
|
||||
|
||||
3. Export manager creation function from `packages/workflow/src/index.ts`.
|
||||
|
||||
4. Update `packages/daemon/src/kernel.ts` to import workflow manager from `@uncaged/workflow`.
|
||||
|
||||
5. Delete the original files from daemon.
|
||||
|
||||
6. Build + test:
|
||||
```bash
|
||||
pnpm run build && pnpm test
|
||||
```
|
||||
|
||||
7. Commit:
|
||||
```bash
|
||||
git commit -am "refactor(workflow): move workflow-manager to @uncaged/workflow
|
||||
|
||||
Refs #320"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Update All Consumers
|
||||
|
||||
### Task 8: Update `workflow-utils` to depend on `@uncaged/workflow`
|
||||
|
||||
**Objective:** Change `@uncaged/nerve-workflow-utils` to import workflow types from `@uncaged/workflow` instead of `@uncaged/nerve-core`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow-utils/package.json` — replace `@uncaged/nerve-core` dep with `@uncaged/workflow`
|
||||
- Modify: all `packages/workflow-utils/src/*.ts` — update import paths
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. In `package.json`, replace `"@uncaged/nerve-core": "workspace:*"` with `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
2. Find-and-replace all `from "@uncaged/nerve-core"` → `from "@uncaged/workflow"` in `packages/workflow-utils/src/`.
|
||||
|
||||
3. Build + test:
|
||||
```bash
|
||||
pnpm run build && pnpm test
|
||||
```
|
||||
|
||||
4. Commit:
|
||||
```bash
|
||||
git commit -am "refactor(workflow-utils): import from @uncaged/workflow
|
||||
|
||||
Refs #320"
|
||||
```
|
||||
|
||||
### Task 9: Update `workflow-meta` to depend on `@uncaged/workflow`
|
||||
|
||||
**Objective:** Same as Task 8 but for `packages/workflow-meta/`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow-meta/package.json`
|
||||
- Modify: all `packages/workflow-meta/src/**/*.ts`
|
||||
|
||||
**Steps:** Same pattern as Task 8.
|
||||
|
||||
### Task 10: Update adapter packages
|
||||
|
||||
**Objective:** Update `adapter-cursor` and `adapter-hermes` to import workflow types from `@uncaged/workflow`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/adapter-cursor/src/index.ts`
|
||||
- Modify: `packages/adapter-cursor/package.json`
|
||||
- Modify: `packages/adapter-hermes/src/index.ts`
|
||||
- Modify: `packages/adapter-hermes/package.json`
|
||||
|
||||
**Steps:** Same pattern — add `@uncaged/workflow` dep, update imports.
|
||||
|
||||
### Task 11: Update CLI package
|
||||
|
||||
**Objective:** Update CLI to import workflow types from `@uncaged/workflow`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/cli/package.json`
|
||||
- Modify: `packages/cli/src/commands/workflow.ts`
|
||||
- Modify: `packages/cli/src/commands/thread.ts`
|
||||
- Modify: `packages/cli/src/commands/create.ts`
|
||||
- Modify: `packages/cli/src/workflow-agent-validation.ts`
|
||||
- Modify: other files that import workflow types from nerve-core
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Add `"@uncaged/workflow": "workspace:*"` to `packages/cli/package.json`.
|
||||
|
||||
2. In each file, change workflow-related imports from `@uncaged/nerve-core` to `@uncaged/workflow`.
|
||||
|
||||
3. Build + test:
|
||||
```bash
|
||||
pnpm run build && pnpm test
|
||||
```
|
||||
|
||||
4. Commit:
|
||||
```bash
|
||||
git commit -am "refactor(cli): import workflow types from @uncaged/workflow
|
||||
|
||||
Refs #320"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Clean Up Core Re-exports
|
||||
|
||||
### Task 12: Remove workflow re-exports from `@uncaged/nerve-core`
|
||||
|
||||
**Objective:** Core no longer re-exports any workflow types. All consumers import directly from `@uncaged/workflow`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/core/src/index.ts` — remove all workflow re-exports
|
||||
- Modify: `packages/core/package.json` — remove `@uncaged/workflow` dependency (core no longer needs it)
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Remove all workflow-related `export` lines from `packages/core/src/index.ts`.
|
||||
|
||||
2. Remove `@uncaged/workflow` from core's dependencies.
|
||||
|
||||
3. Full build + full test:
|
||||
```bash
|
||||
pnpm run build && pnpm test
|
||||
```
|
||||
|
||||
4. Run biome check:
|
||||
```bash
|
||||
pnpm run check
|
||||
```
|
||||
|
||||
5. Commit:
|
||||
```bash
|
||||
git commit -am "refactor(core): remove workflow re-exports, clean break
|
||||
|
||||
Fixes #320"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Verify & PR
|
||||
|
||||
### Task 13: Final verification
|
||||
|
||||
**Objective:** Full workspace build, all tests pass, biome clean.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Clean build:
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
2. Full tests:
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
3. Biome:
|
||||
```bash
|
||||
pnpm run check
|
||||
```
|
||||
|
||||
4. Verify monorepo structure matches issue spec:
|
||||
```
|
||||
packages/
|
||||
core/ # @uncaged/nerve-core — sense types, config (no workflow)
|
||||
workflow/ # @uncaged/workflow — standalone orchestration engine
|
||||
workflow-utils/ # helper roles, extract layer
|
||||
workflow-meta/ # meta-workflows
|
||||
daemon/ # @uncaged/nerve-daemon — sense engine + workflow integration
|
||||
cli/ # @uncaged/nerve-cli
|
||||
```
|
||||
|
||||
5. Create PR:
|
||||
```bash
|
||||
tea pr create --title "refactor: extract workflow engine into @uncaged/workflow" \
|
||||
--description "## What
|
||||
Extract workflow engine into standalone @uncaged/workflow package.
|
||||
|
||||
## Why
|
||||
Workflow engine is now independent of sense observation — can be used standalone.
|
||||
|
||||
## Changes
|
||||
- packages/workflow/ — new package with types, IPC, worker, manager
|
||||
- packages/core/ — removed workflow types, no longer re-exports them
|
||||
- packages/daemon/ — imports workflow runtime from @uncaged/workflow
|
||||
- packages/cli/ — imports workflow types from @uncaged/workflow
|
||||
- packages/workflow-utils/ — depends on @uncaged/workflow
|
||||
- packages/workflow-meta/ — depends on @uncaged/workflow
|
||||
- packages/adapter-*/ — depends on @uncaged/workflow
|
||||
|
||||
## Ref
|
||||
Fixes #320"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls & Notes
|
||||
|
||||
1. **Worker spawn path**: `workflow-worker.ts` is spawned as a child process via `node`. After moving to `@uncaged/workflow`, the daemon needs to resolve the worker entry point from the package (e.g. `require.resolve("@uncaged/workflow/worker")`). This likely requires a separate export in `package.json` exports map.
|
||||
|
||||
2. **Dynamic import exception**: `workflow-worker.ts` uses dynamic `import()` for user workflow modules — this exception carries over to the new package. Add comment per CLAUDE.md conventions.
|
||||
|
||||
3. **IPC split**: The `ipc.ts` in daemon has both sense and workflow messages in one file with shared validation. The split needs careful handling of the `ParentToWorkerMessage` union type and `parseParentToWorkerMessage` function.
|
||||
|
||||
4. **No backward compat**: Per user preference, no deprecated re-exports — straight breaking change. Phase 6 removes re-exports from core entirely.
|
||||
|
||||
5. **`workflow-utils` may still need `nerve-core`**: If workflow-utils imports non-workflow types (like `Schema`, `ExtractFn`, `ExtractError`) from core, it will need both deps. Check carefully.
|
||||
|
||||
6. **Test files**: Many test files in daemon import workflow types. They need updating in Phase 5 alongside the source files.
|
||||
+1
-8
@@ -4,16 +4,9 @@
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/nerve-store": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"build": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build && pnpm -r --filter '!@uncaged/nerve-core' --filter '!@uncaged/nerve-store' --filter '!@uncaged/workflow' run build",
|
||||
"build": "pnpm -r run build",
|
||||
"test": "pnpm -r test",
|
||||
"check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*"
|
||||
"@uncaged/nerve-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rslib/core": "^0.21.3",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AgentConfig } from "@uncaged/nerve-core";
|
||||
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
|
||||
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
|
||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
export type CursorAgentMode = "plan" | "ask" | "default";
|
||||
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*"
|
||||
"@uncaged/nerve-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rslib/core": "^0.21.3",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AgentConfig } from "@uncaged/nerve-core";
|
||||
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
|
||||
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
|
||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
/**
|
||||
* Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only
|
||||
|
||||
@@ -17,13 +17,12 @@
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build",
|
||||
"pretest": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build && pnpm --filter @uncaged/nerve-daemon run build",
|
||||
"pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-daemon run build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/nerve-store": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"citty": "^0.1.6",
|
||||
"picomatch": "^4.0.2",
|
||||
"yaml": "^2.8.3"
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
# Nerve — AI Agent 观测引擎
|
||||
|
||||
Nerve 是一个轻量级观测引擎守护进程。它持续观测外部状态,通过声明式规则响应变化,编排多步骤工作流。
|
||||
|
||||
## 核心架构
|
||||
|
||||
```
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
```
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| **Sense** | 有状态的 `compute(state)` 函数。返回新状态和可选的 workflow trigger。状态以 JSON 文件持久化。调度由 nerve.yaml 配置。 |
|
||||
| **Workflow** | 有状态的多步骤执行。包含 Role(有副作用的执行者)和 Moderator(纯路由器)。每个实例是一个 Thread,有唯一 runId。 |
|
||||
| **Log** | 不可变审计日志。记录执行、状态转换、错误。不能触发 Sense(防止反馈循环)。 |
|
||||
| **Engine** | 内核,持有 Process Manager、Workflow Manager、Sense Scheduler。不直接加载用户代码。 |
|
||||
| **Daemon** | 引擎运行时,作为后台进程运行。 |
|
||||
|
||||
**关键规则:**
|
||||
- 因果链单向:External → Sense(state) → Workflow(触发时) + Log
|
||||
- 进程隔离:每个 Sense group 一个 worker(长期),每个 Workflow 类型一个 worker(按需)
|
||||
- 两个扩展点:Sense(观测什么 + 何时)、Workflow(做什么)
|
||||
|
||||
## 工作区结构
|
||||
|
||||
由 `nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件。
|
||||
|
||||
```
|
||||
~/.uncaged-nerve/
|
||||
├── AGENT.md # 人类 / Agent 可读的工作区约定(init 生成)
|
||||
├── nerve.yaml # 核心配置
|
||||
├── package.json # 单一根包(sense/workflow 下不再有独立 package)
|
||||
├── scripts/build.mjs # 根目录 esbuild;通过 npm/pnpm 的 build 脚本调用
|
||||
├── senses/
|
||||
│ └── <name>/
|
||||
│ └── src/index.ts # exports compute(state) + initialState
|
||||
├── workflows/
|
||||
│ └── <name>/
|
||||
│ ├── index.ts # default export:WorkflowDefinition
|
||||
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
|
||||
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
|
||||
│ └── roles/
|
||||
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
|
||||
└── data/
|
||||
├── senses/ # sense 状态 JSON 文件(自动生成)
|
||||
└── logs.db # 日志存储(自动生成)
|
||||
```
|
||||
|
||||
### 命名约定
|
||||
|
||||
- **Workflow**:动词开头的 kebab-case(例如 `review-pull-request`、`deploy-staging`)。避免单独名词式命名(如 `notifications`)。
|
||||
- **Sense**:描述性名词 kebab-case(例如 `cpu-usage`)。
|
||||
|
||||
---
|
||||
|
||||
## CLI 完整参考
|
||||
|
||||
全局选项:`--host <host:port>`(连接远程 daemon)、`--api-token <secret>`(Bearer 认证)
|
||||
|
||||
### 初始化与脚手架
|
||||
|
||||
```bash
|
||||
nerve init # 初始化工作区
|
||||
nerve init --from <git-url> # 从 git 仓库克隆工作区
|
||||
nerve init workspace # 只初始化工作区结构
|
||||
|
||||
nerve create sense <name> # 创建 sense 脚手架
|
||||
nerve create sense <name> --force # 覆盖已有
|
||||
nerve create workflow <name> # 创建 workflow 脚手架
|
||||
nerve create workflow <name> --force
|
||||
|
||||
nerve validate # 验证 nerve.yaml 配置
|
||||
```
|
||||
|
||||
### Daemon 管理
|
||||
|
||||
```bash
|
||||
nerve daemon start # 启动后台 daemon
|
||||
nerve daemon start --port 3000 # 指定 HTTP API 端口
|
||||
nerve daemon stop # 停止 daemon
|
||||
nerve daemon restart # 重启
|
||||
nerve daemon status # 查看状态
|
||||
nerve daemon logs # 查看日志
|
||||
nerve daemon logs --follow # 实时日志
|
||||
nerve daemon logs --n 50 # 最近 50 行
|
||||
|
||||
nerve dev # 前台开发模式(不 fork daemon)
|
||||
nerve dev --port 3000 # 指定端口
|
||||
```
|
||||
|
||||
### Sense 操作
|
||||
|
||||
```bash
|
||||
nerve sense list # 列出所有注册的 sense
|
||||
nerve sense trigger <name> # 手动触发 sense 计算
|
||||
```
|
||||
|
||||
### Workflow 操作
|
||||
|
||||
```bash
|
||||
nerve workflow list # 列出 nerve.yaml 中定义的 workflow
|
||||
nerve workflow status # 查看运行中的 workflow 状态
|
||||
nerve workflow trigger <name> # 触发 workflow
|
||||
nerve workflow trigger <name> --prompt "检查生产环境"
|
||||
nerve workflow trigger <name> --maxRounds 50
|
||||
nerve workflow trigger <name> --dryRun # 干跑模式
|
||||
```
|
||||
|
||||
### Thread(Workflow 执行记录)
|
||||
|
||||
```bash
|
||||
nerve thread list # 列出最近的 workflow 执行
|
||||
nerve thread list --all # 包含已完成/失败的
|
||||
nerve thread list --workflow <name> # 按 workflow 过滤
|
||||
nerve thread list --limit 50 # 最多 50 条
|
||||
|
||||
nerve thread show <runId> # 查看 role 对话轮次
|
||||
nerve thread show <runId> --budget 16000 # 增大输出预算(默认 8000 字符)
|
||||
|
||||
nerve thread inspect <runId> # 查看详情和事件
|
||||
|
||||
nerve thread kill <runId> # 终止运行中/排队中的 thread
|
||||
```
|
||||
|
||||
### Store(日志归档)
|
||||
|
||||
```bash
|
||||
nerve store archive # 导出旧日志到 JSONL 归档
|
||||
nerve store archive --vacuum # 归档后 VACUUM 数据库
|
||||
```
|
||||
|
||||
### Knowledge(知识库)
|
||||
|
||||
```bash
|
||||
nerve knowledge sync # 从 knowledge.yaml 重建索引
|
||||
nerve knowledge query "搜索内容" # 搜索知识库
|
||||
nerve knowledge query "内容" --limit 5
|
||||
nerve knowledge query "内容" -g # 搜索所有注册仓库
|
||||
```
|
||||
|
||||
### Remote(远程 daemon)
|
||||
|
||||
```bash
|
||||
nerve remote add <name> <host:port> --token <secret>
|
||||
nerve remote list
|
||||
nerve remote show <name>
|
||||
nerve remote set-url <name> <host>
|
||||
nerve remote set-token <name> <token>
|
||||
nerve remote remove <name>
|
||||
nerve remote default <name> # 设为默认远程
|
||||
```
|
||||
|
||||
### Agent(向 AI Agent 注入 nerve skill)
|
||||
|
||||
```bash
|
||||
nerve agent status # CLI 版本与各注入目录中的 skill 版本
|
||||
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
|
||||
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
|
||||
nerve agent inject cursor # 注入到当前项目 .cursorrules
|
||||
nerve agent inject claude # 注入到 ~/.claude/CLAUDE.md
|
||||
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
|
||||
nerve agent remove hermes # 移除 Hermes 注入
|
||||
nerve agent remove cursor # 移除 Cursor 注入
|
||||
nerve agent remove claude # 移除 Claude Code 注入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## nerve.yaml 配置参考
|
||||
|
||||
```yaml
|
||||
# 引擎全局配置
|
||||
max_rounds: 100 # moderator 最大轮次(默认 100)
|
||||
|
||||
# Sense 配置
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system # 必填,同 group 的 sense 共享 worker
|
||||
interval: 10s # 轮询间隔(duration: 5s, 10m, 1h)
|
||||
throttle: 5s # 最小计算间隔
|
||||
timeout: 10s # compute 超时
|
||||
grace_period: null # 超时后优雅等待
|
||||
|
||||
system-health:
|
||||
group: derived
|
||||
on: [cpu-usage, disk-usage] # 响应式:被列出的 sense 完成 compute 时触发
|
||||
throttle: null
|
||||
timeout: null
|
||||
|
||||
# Workflow 配置
|
||||
workflows:
|
||||
my-workflow:
|
||||
concurrency: 1 # 必填,并发数
|
||||
overflow: drop # 必填,超并发时处理:drop | queue
|
||||
max_queue: 100 # overflow=queue 时的队列上限(默认 100)
|
||||
|
||||
# HTTP API
|
||||
api:
|
||||
port: 3000 # null = 不启用 HTTP
|
||||
host: "127.0.0.1" # 监听地址
|
||||
token: null # 非 loopback 时必填
|
||||
|
||||
# LLM Extract(可选)
|
||||
extract:
|
||||
provider: anthropic
|
||||
model: claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sense 开发指南
|
||||
|
||||
### compute 函数签名
|
||||
|
||||
Sense 的 `compute` 接收当前状态,返回新状态和可选的 shell trigger(`{ command: string }`)。状态以 JSON 文件持久化在 `data/senses/<name>.json`。Workflow 只能通过 CLI / daemon IPC 启动,不能从 sense 返回值直接启动。
|
||||
|
||||
```typescript
|
||||
import type { SenseComputeFn } from "@uncaged/nerve-core";
|
||||
|
||||
type MyState = {
|
||||
lastRun: number | null;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const initialState: MyState = { lastRun: null, count: 0 };
|
||||
|
||||
export async function compute(state: MyState): Promise<{
|
||||
state: MyState;
|
||||
trigger: { command: string } | null;
|
||||
}> {
|
||||
return {
|
||||
state: { lastRun: Date.now(), count: state.count + 1 },
|
||||
trigger: null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 状态持久化
|
||||
|
||||
| 时机 | 行为 |
|
||||
|------|------|
|
||||
| Worker 启动 | 读取 `data/senses/<name>.json`;不存在或损坏则使用 `initialState` |
|
||||
| Compute 成功 | 原子写入新状态(temp + rename),然后更新内存 |
|
||||
| Compute 失败 | 状态不变(磁盘和内存都不变) |
|
||||
| Daemon 重启 | 从上次成功写入恢复 |
|
||||
|
||||
### 返回值
|
||||
|
||||
```typescript
|
||||
// trigger: null → 不执行 shell 命令
|
||||
// trigger: { command } → sense worker 在成功的 compute 后以 shell 执行该命令(cwd = nerve 根目录)
|
||||
// 启动 workflow:在 shell 中调用 `nerve workflow trigger ...`,或使用 daemon IPC / HTTP API
|
||||
```
|
||||
|
||||
### Sense 模块导出
|
||||
|
||||
每个 sense 的 `src/index.ts` 必须导出两个东西:
|
||||
|
||||
```typescript
|
||||
// senses/<name>/src/index.ts
|
||||
|
||||
// 1. 初始状态
|
||||
export const initialState: MyState = { ... };
|
||||
|
||||
// 2. compute 函数
|
||||
export async function compute(state: MyState): Promise<{
|
||||
state: MyState;
|
||||
trigger: { command: string } | null;
|
||||
}> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 调度方式
|
||||
|
||||
1. **interval 轮询**:`interval: 10s` — 每 10 秒执行一次
|
||||
2. **响应式触发**:`on: [cpu-usage]` — 当 cpu-usage 完成 compute 后触发
|
||||
3. 两者可以组合
|
||||
|
||||
### 调试
|
||||
|
||||
```bash
|
||||
nerve dev # 前台运行,看实时输出
|
||||
nerve sense trigger <name> # 手动触发一次
|
||||
```
|
||||
|
||||
### 完整示例:CPU 监控
|
||||
|
||||
```typescript
|
||||
// senses/cpu-usage/src/index.ts
|
||||
import { loadavg } from "node:os";
|
||||
|
||||
type CpuState = {
|
||||
samples: Array<{ ts: number; value: number }>;
|
||||
};
|
||||
|
||||
export const initialState: CpuState = { samples: [] };
|
||||
|
||||
export async function compute(state: CpuState): Promise<{
|
||||
state: CpuState;
|
||||
trigger: null;
|
||||
}> {
|
||||
const [oneMin] = loadavg();
|
||||
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
|
||||
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
|
||||
return { state: { samples: newSamples }, trigger: null };
|
||||
}
|
||||
```
|
||||
|
||||
nerve.yaml:
|
||||
```yaml
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system
|
||||
interval: 10s
|
||||
throttle: 5s
|
||||
timeout: 10s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow 开发指南
|
||||
|
||||
### 核心类型
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
WorkflowDefinition,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
RoleMeta,
|
||||
Moderator,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
// Role<Meta> — (ctx: ThreadContext) => Promise<RoleResult<Meta>>
|
||||
// RoleResult<Meta> — { content: string; meta: Meta }
|
||||
// ThreadContext<M extends RoleMeta> — threadId, start(__start__ 帧), steps(各 role 轮次)
|
||||
// Moderator<M> — (ctx) => 下一个 role 名 | END
|
||||
// WorkflowDefinition<M extends RoleMeta> — name, roles, moderator
|
||||
```
|
||||
|
||||
### createRole 四元组(接入 LLM 时推荐)
|
||||
|
||||
工作区根目录需安装 **`@uncaged/nerve-workflow-utils`**(及所选 agent 适配器包)。默认 `nerve init` 的 `package.json` 不含该依赖时,在 `~/.uncaged-nerve` 下执行 `pnpm add @uncaged/nerve-workflow-utils`(或 npm 等价命令)。
|
||||
|
||||
使用 **`createRole`**,按固定顺序传入四件事:
|
||||
|
||||
1. **adapter** — `AgentFn`,`(ctx, systemPrompt) => Promise<string>`(原始模型输出文本)。
|
||||
2. **prompt** — `string`,或 `async (ctx: ThreadContext) => string`。
|
||||
3. **meta** — `z.ZodType<M>`,供 moderator 路由的结构化 meta。
|
||||
4. **extract** — `{ provider: LlmProvider; dryRun: boolean | null }`,声明从回复中抽取 meta 时用的 LLM(OpenAI 兼容)及是否 dry-run。
|
||||
|
||||
```typescript
|
||||
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { ThreadContext } from "@uncaged/nerve-core";
|
||||
import { z } from "zod";
|
||||
|
||||
const provider = {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: process.env.EXAMPLE_API_KEY!,
|
||||
model: "gpt-4o-mini",
|
||||
};
|
||||
|
||||
const planMeta = z.object({ next: z.enum(["execute", "stop"]) });
|
||||
|
||||
export const planner = createRole(
|
||||
createLlmAdapter(provider),
|
||||
async (ctx: ThreadContext) => `规划任务:${ctx.start.content}`,
|
||||
planMeta,
|
||||
{ provider, dryRun: null },
|
||||
);
|
||||
```
|
||||
|
||||
`createLlmAdapter` 仅位于 **`@uncaged/nerve-workflow-utils`**:用 `LlmProvider` 生成 `AgentFn`,单轮对话里 **system** 来自 `createRole` 解析后的 prompt 字符串,**user** 为线程起点 `ctx.start.content`。
|
||||
|
||||
### 基本 Workflow 示例(平铺 `roles/<role>.ts`)
|
||||
|
||||
```typescript
|
||||
// workflows/example/roles/main.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
|
||||
const prompt = ctx.start.content;
|
||||
return {
|
||||
content: `处理完成: ${prompt}`,
|
||||
meta: { round: ctx.steps.length },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/example/index.ts
|
||||
import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
import { main } from "./roles/main.js";
|
||||
|
||||
type Meta = Record<"main", { round: number }>;
|
||||
|
||||
const workflow: WorkflowDefinition<Meta> = {
|
||||
name: "example",
|
||||
roles: { main },
|
||||
moderator(ctx: ThreadContext<Meta>) {
|
||||
return ctx.steps.length === 0 ? "main" : END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
```
|
||||
|
||||
可选:将 `moderator` 挪到 `moderator.ts` 再 `import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`。
|
||||
|
||||
### 多 Role Workflow 示例
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/roles/planner.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "计划: ...", meta: { status: "planned" } };
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/roles/executor.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "执行: ...", meta: { status: "executed" } };
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/roles/reviewer.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "审核通过", meta: { status: "approved" } };
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/index.ts
|
||||
import type { WorkflowDefinition, ThreadContext } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
import { executor } from "./roles/executor.js";
|
||||
import { planner } from "./roles/planner.js";
|
||||
import { reviewer } from "./roles/reviewer.js";
|
||||
|
||||
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
|
||||
|
||||
const workflow: WorkflowDefinition<Roles> = {
|
||||
name: "plan-execute-review",
|
||||
roles: { planner, executor, reviewer },
|
||||
moderator(ctx: ThreadContext<Roles>) {
|
||||
if (ctx.steps.length === 0) return "planner";
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
if (last.role === "planner") return "executor";
|
||||
if (last.role === "executor") return "reviewer";
|
||||
return END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
```
|
||||
|
||||
### Agent 适配器
|
||||
|
||||
Workflow role 可以集成 AI agent。已知适配器 **ID**:`echo`、`cursor`、`hermes`、`codex`。
|
||||
|
||||
```typescript
|
||||
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
||||
```
|
||||
|
||||
没有现成 agent 包时,用 **`createLlmAdapter`(`@uncaged/nerve-workflow-utils`)** 从 OpenAI 兼容的 `LlmProvider` 构造 `AgentFn`,再交给 **`createRole`** 的四元组。
|
||||
|
||||
### Workflow 运行状态
|
||||
|
||||
`queued` → `started` → `completed` | `failed` | `crashed` | `killed` | `interrupted` | `dropped`
|
||||
|
||||
---
|
||||
|
||||
## 日常操作 Pattern
|
||||
|
||||
### 查看系统整体状态
|
||||
|
||||
```bash
|
||||
nerve daemon status # daemon 是否在运行
|
||||
nerve sense list # 所有 sense 及其调度配置
|
||||
nerve workflow status # 运行中的 workflow
|
||||
nerve thread list # 最近的 workflow 执行记录
|
||||
```
|
||||
|
||||
### 手动触发 workflow
|
||||
|
||||
```bash
|
||||
nerve workflow trigger my-workflow --prompt "手动检查"
|
||||
nerve thread list --workflow my-workflow # 查看执行状态
|
||||
nerve thread show <runId> # 查看对话详情
|
||||
```
|
||||
|
||||
### 排查 sense 报错
|
||||
|
||||
```bash
|
||||
nerve daemon logs --follow # 查看实时日志
|
||||
nerve sense trigger <name> # 手动触发看报错
|
||||
nerve dev # 前台模式,更详细的输出
|
||||
```
|
||||
|
||||
### 开发新 sense
|
||||
|
||||
```bash
|
||||
nerve create sense my-sensor # 脚手架
|
||||
# 编辑 senses/my-sensor/src/index.ts — 实现 compute(state) + initialState
|
||||
nerve validate # 验证配置
|
||||
nerve dev # 前台测试
|
||||
nerve sense trigger my-sensor # 单次触发验证
|
||||
```
|
||||
|
||||
### 开发新 workflow
|
||||
|
||||
```bash
|
||||
nerve create workflow my-flow # 脚手架
|
||||
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
|
||||
nerve validate # 验证配置
|
||||
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建;勿在单个 workflow 子目录单独跑 build
|
||||
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
|
||||
nerve thread show <runId> # 查看执行轨迹
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Sense 状态**:`compute(state)` 必须返回 `{ state, workflow }` 对象。不要返回 null 或 undefined。
|
||||
- **initialState**:每个 sense 必须导出 `initialState`,否则加载失败。
|
||||
- **状态持久化**:daemon 自动管理状态读写,业务代码不要自行读写 state.json。
|
||||
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
|
||||
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`。
|
||||
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
|
||||
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
|
||||
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
|
||||
@@ -252,7 +252,22 @@ export async function compute(): Promise<ComputeResult<MySignalShape>> {
|
||||
|
||||
### 返回值
|
||||
|
||||
当前引擎:`compute(state)` 返回 `{ state, trigger }`,`trigger` 为 `null` 或 `{ command: string }`(shell 命令)。Workflow 仅通过 CLI / daemon IPC 启动;类型见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseTrigger`。
|
||||
```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`)。
|
||||
|
||||
### Sense 模块导出
|
||||
|
||||
|
||||
@@ -245,7 +245,22 @@ export async function compute(): Promise<ComputeResult<MySignalShape>> {
|
||||
|
||||
### 返回值
|
||||
|
||||
当前引擎:`compute(state)` 返回 `{ state, trigger }`,其中 `trigger` 为 `null` 或 `{ command: string }`(sense worker 内 `shell: true` 执行)。Workflow 仅通过 CLI / daemon IPC 启动,类型见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseTrigger`。
|
||||
```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`)。
|
||||
|
||||
### Sense 模块导出
|
||||
|
||||
@@ -258,7 +273,7 @@ type Row = { ts: number; value: number };
|
||||
|
||||
export async function compute(): Promise<ComputeResult<Row>> {
|
||||
const row: Row = { ts: Date.now(), value: Math.random() }; // 替换为真实观测逻辑
|
||||
return { signal: row, trigger: null };
|
||||
return { signal: row, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
@@ -310,7 +325,7 @@ type Row = { ts: number; value: number };
|
||||
|
||||
export async function compute(): Promise<ComputeResult<Row>> {
|
||||
const oneMin = os.loadavg()[0];
|
||||
return { signal: { ts: Date.now(), value: oneMin }, trigger: null };
|
||||
return { signal: { ts: Date.now(), value: oneMin }, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("buildSenseIndexTs", () => {
|
||||
expect(ts).toContain("type SenseState");
|
||||
expect(ts).toContain("export const initialState");
|
||||
expect(ts).toContain("export async function compute");
|
||||
expect(ts).toContain("trigger: null");
|
||||
expect(ts).toContain("workflow: null");
|
||||
expect(ts).toContain("lastRun");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,10 +27,10 @@ describe("buildWorkflowScaffold", () => {
|
||||
expect(roleMainIndexTs).toContain("my-workflow started");
|
||||
});
|
||||
|
||||
it("root index contains WorkflowDefinition import from @uncaged/workflow", () => {
|
||||
it("root index contains WorkflowDefinition import from nerve-core", () => {
|
||||
const { indexTs } = buildWorkflowScaffold("test");
|
||||
expect(indexTs).toContain("WorkflowDefinition");
|
||||
expect(indexTs).toContain("@uncaged/workflow");
|
||||
expect(indexTs).toContain("@uncaged/nerve-core");
|
||||
});
|
||||
|
||||
it("root index wires moderator with ThreadContext and END", () => {
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
* E2E-style tests for `nerve create workflow` and `nerve create sense`.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { defineCommand, runCommand } from "citty";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -15,23 +12,6 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createCommand } from "../commands/create.js";
|
||||
import { initCommand } from "../commands/init.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const requireFromHere = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Default init pins `@uncaged/workflow` to npm `latest`, but that package is not published yet.
|
||||
* Install from the monorepo workspace so `pnpm run build` can resolve workflow types.
|
||||
*/
|
||||
async function installWorkspaceWithLocalWorkflow(nerveRoot: string): Promise<void> {
|
||||
const pkgPath = join(nerveRoot, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { dependencies: Record<string, string> };
|
||||
const wfRoot = dirname(requireFromHere.resolve("@uncaged/workflow/package.json"));
|
||||
pkg.dependencies["@uncaged/workflow"] = `file:${wfRoot}`;
|
||||
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
|
||||
await execFileAsync("pnpm", ["install", "--no-frozen-lockfile"], { cwd: nerveRoot });
|
||||
await execFileAsync("pnpm", ["run", "build"], { cwd: nerveRoot });
|
||||
}
|
||||
|
||||
const testRootCommand = defineCommand({
|
||||
meta: { name: "nerve", description: "e2e-create" },
|
||||
subCommands: {
|
||||
@@ -148,8 +128,7 @@ describe("e2e create", () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
|
||||
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
await installWorkspaceWithLocalWorkflow(nerveRoot);
|
||||
await runTestCli(fakeHome, ["init", "--force"]);
|
||||
|
||||
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
|
||||
expect(wf.exitCode).toBe(0);
|
||||
@@ -174,8 +153,7 @@ describe("e2e create", () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
|
||||
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
await installWorkspaceWithLocalWorkflow(nerveRoot);
|
||||
await runTestCli(fakeHome, ["init", "--force"]);
|
||||
|
||||
const sense = await runTestCli(fakeHome, ["create", "sense", "e2e-sense"]);
|
||||
expect(sense.exitCode).toBe(0);
|
||||
|
||||
@@ -51,9 +51,8 @@ import { workflowCommand } from "../commands/workflow.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
|
||||
const nerveWorkflowRoot = dirname(require.resolve("@uncaged/workflow/package.json"));
|
||||
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
|
||||
const workflowWorkerScript = join(nerveWorkflowRoot, "dist", "worker.js");
|
||||
const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js");
|
||||
|
||||
const nerveYamlTemplate = `senses:
|
||||
counter:
|
||||
@@ -121,7 +120,29 @@ const counterIndexJs = `export const initialState = { count: 0 };
|
||||
export async function compute(state) {
|
||||
return {
|
||||
state: { count: state.count + 1 },
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
/** 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 },
|
||||
workflow: {
|
||||
name: "noop",
|
||||
maxRounds: 3,
|
||||
prompt: "e2e-archive",
|
||||
dryRun: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: { launched: state.launched, idleTicks: state.idleTicks + 1 },
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
@@ -183,7 +204,11 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi
|
||||
withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(join(nerveRoot, "dist", "senses", "counter", "index.js"), counterIndexJs, "utf8");
|
||||
writeFileSync(
|
||||
join(nerveRoot, "dist", "senses", "counter", "index.js"),
|
||||
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "dist", "workflows", "echo", "index.js"),
|
||||
echoWorkflowIndexJs,
|
||||
@@ -209,8 +234,8 @@ export type TestDaemonHandle = {
|
||||
|
||||
export type StartTestDaemonOpts = {
|
||||
/**
|
||||
* When true, bundles a local `noop` workflow under `dist/workflows/noop` for tests that
|
||||
* start runs via `nerve workflow trigger` (real workflow-worker child).
|
||||
* 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`.
|
||||
*/
|
||||
withNoopWorkflow: boolean;
|
||||
} | null;
|
||||
@@ -275,7 +300,7 @@ export async function startTestDaemon(
|
||||
}
|
||||
if (!existsSync(workflowWorkerScript)) {
|
||||
throw new Error(
|
||||
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/workflow build\`.`,
|
||||
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,14 +46,8 @@ describe("e2e store archive", () => {
|
||||
daemon = await startTestDaemon({ withNoopWorkflow: true });
|
||||
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
|
||||
|
||||
const wfTrigger = await runCli(daemon, [
|
||||
"workflow",
|
||||
"trigger",
|
||||
"noop",
|
||||
"--prompt",
|
||||
"e2e-archive",
|
||||
]);
|
||||
expect(wfTrigger.exitCode).toBe(0);
|
||||
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
|
||||
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
|
||||
await pollUntil(() => {
|
||||
@@ -107,14 +101,8 @@ describe("e2e store archive", () => {
|
||||
daemon = await startTestDaemon({ withNoopWorkflow: true });
|
||||
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
|
||||
|
||||
const wfTrigger = await runCli(daemon, [
|
||||
"workflow",
|
||||
"trigger",
|
||||
"noop",
|
||||
"--prompt",
|
||||
"e2e-archive",
|
||||
]);
|
||||
expect(wfTrigger.exitCode).toBe(0);
|
||||
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
|
||||
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
|
||||
await pollUntil(() => {
|
||||
|
||||
@@ -65,10 +65,6 @@ function writeVersionFile(skillDir: string, version: string): void {
|
||||
|
||||
const CURSOR_VERSION_MARKER_RE = /<!--\s*nerve-cli-version:\s*([^>]+?)\s*-->/;
|
||||
|
||||
const CLAUDE_BLOCK_START_RE = /<!--\s*nerve-cli:start\s+v([^\s>]+)\s*-->/;
|
||||
const CLAUDE_BLOCK_END = "<!-- nerve-cli:end -->";
|
||||
const CLAUDE_BLOCK_RE = /<!--\s*nerve-cli:start\s+v[^\s>]+\s*-->[\s\S]*?<!--\s*nerve-cli:end\s*-->/;
|
||||
|
||||
function resolveCursorProjectDir(pathArg: string | null): string {
|
||||
const raw = pathArg !== null && pathArg !== "" ? pathArg : process.cwd();
|
||||
return resolvePath(raw);
|
||||
@@ -168,82 +164,6 @@ function removeHermes(profile: string | null): void {
|
||||
process.stdout.write(`✅ Removed Hermes nerve skill${loc}\n`);
|
||||
}
|
||||
|
||||
// --- Claude Code ---
|
||||
|
||||
function getClaudeGlobalFile(): string {
|
||||
return join(homedir(), ".claude", "CLAUDE.md");
|
||||
}
|
||||
|
||||
function readClaudeBlockVersion(): string | null {
|
||||
const filePath = getClaudeGlobalFile();
|
||||
if (!existsSync(filePath)) return null;
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const match = content.match(CLAUDE_BLOCK_START_RE);
|
||||
return match !== null ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
function injectClaude(): void {
|
||||
const templatePath = join(getSkillSourceDir(), "claude", "CLAUDE.md");
|
||||
if (!existsSync(templatePath)) {
|
||||
throw new Error("Cannot locate claude/CLAUDE.md template. Is the CLI package intact?");
|
||||
}
|
||||
const version = cliVersion();
|
||||
const existingVer = readClaudeBlockVersion();
|
||||
|
||||
if (existingVer === version) {
|
||||
process.stdout.write(`✅ Claude Code nerve skill is already up to date (v${version})\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = readFileSync(templatePath, "utf8");
|
||||
const block = `<!-- nerve-cli:start v${version} -->\n${body.trim()}\n${CLAUDE_BLOCK_END}`;
|
||||
|
||||
const filePath = getClaudeGlobalFile();
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
const existing = readFileSync(filePath, "utf8");
|
||||
if (CLAUDE_BLOCK_RE.test(existing)) {
|
||||
// Replace existing block
|
||||
const updated = existing.replace(CLAUDE_BLOCK_RE, block);
|
||||
writeFileSync(filePath, updated, "utf8");
|
||||
} else {
|
||||
// Append
|
||||
const sep = existing.endsWith("\n") ? "\n" : "\n\n";
|
||||
writeFileSync(filePath, `${existing}${sep}${block}\n`, "utf8");
|
||||
}
|
||||
} else {
|
||||
writeFileSync(filePath, `${block}\n`, "utf8");
|
||||
}
|
||||
|
||||
const action = existingVer !== null ? "Updated" : "Installed";
|
||||
process.stdout.write(`✅ ${action} Claude Code nerve skill v${version}\n`);
|
||||
process.stdout.write(` → ${filePath}\n`);
|
||||
}
|
||||
|
||||
function removeClaude(): void {
|
||||
const filePath = getClaudeGlobalFile();
|
||||
if (!existsSync(filePath)) {
|
||||
process.stdout.write("ℹ️ Claude Code nerve skill is not installed.\n");
|
||||
return;
|
||||
}
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
if (!CLAUDE_BLOCK_RE.test(content)) {
|
||||
process.stdout.write("ℹ️ Claude Code nerve skill is not installed.\n");
|
||||
return;
|
||||
}
|
||||
const cleaned = content
|
||||
.replace(CLAUDE_BLOCK_RE, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
if (cleaned.length === 0) {
|
||||
rmSync(filePath, { force: true });
|
||||
} else {
|
||||
writeFileSync(filePath, `${cleaned}\n`, "utf8");
|
||||
}
|
||||
process.stdout.write("✅ Removed Claude Code nerve skill\n");
|
||||
}
|
||||
|
||||
function printCursorStatusLine(projectDir: string): void {
|
||||
const rulesPath = join(projectDir, ".cursorrules");
|
||||
const label = `Cursor (${projectDir})`;
|
||||
@@ -273,11 +193,6 @@ function printStatus(): void {
|
||||
printCursorStatusLine(process.cwd());
|
||||
process.stdout.write("\n");
|
||||
|
||||
// Claude Code
|
||||
const claudeVer = readClaudeBlockVersion();
|
||||
printAgentLine("Claude Code", claudeVer);
|
||||
process.stdout.write("\n");
|
||||
|
||||
// Default profile
|
||||
const defaultDir = getHermesSkillDir(null);
|
||||
const defaultVer = readVersionFile(defaultDir);
|
||||
@@ -322,7 +237,7 @@ const injectCommand = defineCommand({
|
||||
args: {
|
||||
target: {
|
||||
type: "positional",
|
||||
description: "Agent target: hermes | cursor | claude",
|
||||
description: "Agent target: hermes | cursor",
|
||||
},
|
||||
profile: {
|
||||
type: "string",
|
||||
@@ -352,12 +267,8 @@ const injectCommand = defineCommand({
|
||||
injectCursor(resolveCursorProjectDir(pathArg));
|
||||
return;
|
||||
}
|
||||
if (target === "claude") {
|
||||
injectClaude();
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
|
||||
process.stderr.write(" Supported targets: hermes, cursor, claude\n");
|
||||
process.stderr.write(" Supported targets: hermes, cursor\n");
|
||||
process.exit(1);
|
||||
},
|
||||
});
|
||||
@@ -396,12 +307,6 @@ const updateCommand = defineCommand({
|
||||
if (updated === 0) {
|
||||
process.stdout.write("ℹ️ No injected skills found. Run `nerve agent inject hermes` first.\n");
|
||||
}
|
||||
|
||||
// Claude Code (always check, no profiles)
|
||||
const claudeVer = readClaudeBlockVersion();
|
||||
if (claudeVer !== null) {
|
||||
injectClaude();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -443,12 +348,8 @@ const removeCommand = defineCommand({
|
||||
removeCursor(resolveCursorProjectDir(pathArg));
|
||||
return;
|
||||
}
|
||||
if (target === "claude") {
|
||||
removeClaude();
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
|
||||
process.stderr.write(" Supported targets: hermes, cursor, claude\n");
|
||||
process.stderr.write(" Supported targets: hermes, cursor\n");
|
||||
process.exit(1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,8 +31,8 @@ export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
|
||||
}
|
||||
|
||||
function buildWorkflowIndexTs(name: string): string {
|
||||
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/workflow";
|
||||
import { END } from "@uncaged/workflow";
|
||||
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
import { mainRole } from "./roles/main/index.js";
|
||||
|
||||
@@ -56,7 +56,7 @@ export default workflow;
|
||||
}
|
||||
|
||||
function buildWorkflowMainRoleIndexTs(name: string): string {
|
||||
return `import type { RoleResult, ThreadContext } from "@uncaged/workflow";
|
||||
return `import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
/**
|
||||
* Main role — implement LLM calls, scripts, HTTP, etc.
|
||||
@@ -94,12 +94,12 @@ export const initialState: SenseState = { lastRun: null };
|
||||
|
||||
export async function compute(state: SenseState): Promise<{
|
||||
state: SenseState;
|
||||
trigger: null;
|
||||
workflow: null;
|
||||
}> {
|
||||
// TODO: implement sense logic
|
||||
return {
|
||||
state: { lastRun: Date.now() },
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -54,7 +54,6 @@ const PACKAGE_JSON = `${JSON.stringify(
|
||||
dependencies: {
|
||||
"@uncaged/nerve-core": "latest",
|
||||
"@uncaged/nerve-daemon": "latest",
|
||||
"@uncaged/workflow": "latest",
|
||||
zod: "^4.3.6",
|
||||
},
|
||||
devDependencies: {
|
||||
@@ -225,12 +224,12 @@ export const initialState: CpuState = { samples: [] };
|
||||
|
||||
export async function compute(state: CpuState): Promise<{
|
||||
state: CpuState;
|
||||
trigger: null;
|
||||
workflow: null;
|
||||
}> {
|
||||
const [oneMin] = loadavg();
|
||||
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
|
||||
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
|
||||
return { state: { samples: newSamples }, trigger: null };
|
||||
return { state: { samples: newSamples }, workflow: null };
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
|
||||
import { DEFAULT_ENGINE_MAX_ROUNDS, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import { defineCommand } from "citty";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
|
||||
@@ -20,8 +20,12 @@ import type {
|
||||
SenseInfo,
|
||||
WorkflowStatus,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { isPlainRecord, isSenseInfo, isWorkflowStatus } from "@uncaged/nerve-core";
|
||||
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
|
||||
import {
|
||||
DEFAULT_ENGINE_MAX_ROUNDS,
|
||||
isPlainRecord,
|
||||
isSenseInfo,
|
||||
isWorkflowStatus,
|
||||
} from "@uncaged/nerve-core";
|
||||
|
||||
import { getCliDaemonApiToken, getCliDaemonHost } from "./cli-global.js";
|
||||
import { HttpTransport } from "./http-transport.js";
|
||||
|
||||
@@ -6,8 +6,12 @@ import type {
|
||||
SenseInfo,
|
||||
WorkflowStatus,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { isPlainRecord, isSenseInfo, isWorkflowStatus } from "@uncaged/nerve-core";
|
||||
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
|
||||
import {
|
||||
DEFAULT_ENGINE_MAX_ROUNDS,
|
||||
isPlainRecord,
|
||||
isSenseInfo,
|
||||
isWorkflowStatus,
|
||||
} from "@uncaged/nerve-core";
|
||||
|
||||
function normalizeBaseUrl(host: string): string {
|
||||
const t = host.trim();
|
||||
|
||||
+13
-8
@@ -4,9 +4,9 @@ Shared types and configuration parser for the [nerve](../../README.md) observati
|
||||
|
||||
## What's Inside
|
||||
|
||||
- **Type definitions** — `SenseConfig`, `SenseInfo`, `SenseComputeFn`, `SenseModule`, `SenseTrigger`, `WorkflowConfig`, `NerveConfig`, and related types
|
||||
- **Type definitions** — `SenseConfig`, `SenseInfo`, `SenseComputeFn`, `SenseModule`, `WorkflowConfig`, `NerveConfig`, `WorkflowTrigger`, 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)
|
||||
- **Sense triggers** — `parseSenseTrigger` validates `{ command: string }` from sense compute results or worker IPC (`trigger` field on `compute-result`)
|
||||
- **Workflow triggers** — `parseWorkflowTrigger` validates structured workflow launch objects from Sense compute results or IPC
|
||||
- **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,18 +23,23 @@ if (result.ok) {
|
||||
}
|
||||
```
|
||||
|
||||
### Sense trigger validation
|
||||
### Workflow trigger validation
|
||||
|
||||
```typescript
|
||||
import { parseSenseTrigger } from "@uncaged/nerve-core";
|
||||
import { parseWorkflowTrigger } from "@uncaged/nerve-core";
|
||||
|
||||
const parsed = parseSenseTrigger({ command: "echo hello" });
|
||||
if (parsed.ok) {
|
||||
console.log(parsed.value.command);
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
## Duration Format
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseWorkflowTrigger } from "../sense.js";
|
||||
|
||||
describe("parseWorkflowTrigger", () => {
|
||||
it("accepts a valid trigger object", () => {
|
||||
const r = parseWorkflowTrigger({
|
||||
name: "my-wf",
|
||||
maxRounds: 3,
|
||||
prompt: "go",
|
||||
dryRun: true,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value).toEqual({ name: "my-wf", maxRounds: 3, prompt: "go", dryRun: true });
|
||||
});
|
||||
|
||||
it("trims workflow name", () => {
|
||||
const r = parseWorkflowTrigger({
|
||||
name: " spaced ",
|
||||
maxRounds: 1,
|
||||
prompt: "",
|
||||
dryRun: false,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.name).toBe("spaced");
|
||||
});
|
||||
|
||||
it("rejects empty name", () => {
|
||||
const r = parseWorkflowTrigger({ name: "", maxRounds: 1, prompt: "x", dryRun: false });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-integer maxRounds", () => {
|
||||
const r = parseWorkflowTrigger({
|
||||
name: "w",
|
||||
maxRounds: 1.5,
|
||||
prompt: "",
|
||||
dryRun: false,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxRounds < 1", () => {
|
||||
const r = parseWorkflowTrigger({ name: "w", maxRounds: 0, prompt: "", dryRun: false });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-boolean dryRun", () => {
|
||||
const r = parseWorkflowTrigger({
|
||||
name: "w",
|
||||
maxRounds: 1,
|
||||
prompt: "",
|
||||
dryRun: "no" as unknown as boolean,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
+20
-13
@@ -1,12 +1,7 @@
|
||||
import { parse } from "yaml";
|
||||
|
||||
import type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig } from "@uncaged/workflow";
|
||||
import { type Result, err, isPlainRecord, ok, parseDurationStringToMs } from "./util.js";
|
||||
|
||||
export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig };
|
||||
|
||||
/** Engine-wide fallback when nerve.yaml omits max_rounds (keep in sync with workflow package default). */
|
||||
export const DEFAULT_ENGINE_MAX_ROUNDS = 100;
|
||||
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
|
||||
|
||||
export type SenseConfig = {
|
||||
group: string;
|
||||
@@ -19,6 +14,19 @@ export type SenseConfig = {
|
||||
on: string[];
|
||||
};
|
||||
|
||||
export type DropOverflowConfig = {
|
||||
concurrency: number;
|
||||
overflow: "drop";
|
||||
};
|
||||
|
||||
export type QueueOverflowConfig = {
|
||||
concurrency: number;
|
||||
overflow: "queue";
|
||||
maxQueue: number;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
|
||||
|
||||
/** Optional HTTP control plane. When `port` is null, the HTTP server is not started. */
|
||||
export type NerveApiConfig = {
|
||||
port: number | null;
|
||||
@@ -44,13 +52,12 @@ export type ExtractConfig = {
|
||||
model: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** Parameters for starting a workflow from a Sense compute result (or CLI trigger). */
|
||||
export type WorkflowTrigger = {
|
||||
name: string;
|
||||
maxRounds: number;
|
||||
prompt: string;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
export type NerveConfig = {
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
export type {
|
||||
SenseConfig,
|
||||
DropOverflowConfig,
|
||||
QueueOverflowConfig,
|
||||
WorkflowConfig,
|
||||
NerveApiConfig,
|
||||
AgentConfig,
|
||||
ExtractConfig,
|
||||
NerveConfig,
|
||||
SenseTrigger,
|
||||
WorkflowTrigger,
|
||||
} from "./config.js";
|
||||
export type { SenseInfo } from "./sense.js";
|
||||
export type { SenseComputeFn, SenseModule } from "./sense.js";
|
||||
export { senseTriggerLabels } from "./sense.js";
|
||||
export type {
|
||||
WorkflowMessage,
|
||||
RoleResult,
|
||||
Role,
|
||||
RoleMeta,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowContext,
|
||||
AgentFn,
|
||||
RoleStep,
|
||||
ModeratorContext,
|
||||
Moderator,
|
||||
WorkflowDefinition,
|
||||
} from "./workflow.js";
|
||||
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
|
||||
export type { Schema, ExtractFn } from "./agent.js";
|
||||
export { ExtractError } from "./agent.js";
|
||||
export type { Result } from "./util.js";
|
||||
@@ -26,7 +44,7 @@ export type { KnowledgeConfig } from "./config.js";
|
||||
export { parseKnowledgeYaml } from "./config.js";
|
||||
export { isPlainRecord } from "./util.js";
|
||||
|
||||
export { parseSenseTrigger } from "./sense.js";
|
||||
export { parseWorkflowTrigger } from "./sense.js";
|
||||
|
||||
export { isSenseInfo, isWorkflowStatus } from "./daemon.js";
|
||||
export type {
|
||||
|
||||
+23
-14
@@ -1,4 +1,4 @@
|
||||
import type { SenseConfig, SenseTrigger } from "./config.js";
|
||||
import type { SenseConfig, WorkflowTrigger } from "./config.js";
|
||||
import { type Result, err, isPlainRecord, ok } from "./util.js";
|
||||
|
||||
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
|
||||
@@ -16,11 +16,11 @@ export type SenseInfo = {
|
||||
* `compute` export.
|
||||
*
|
||||
* Pure: no DB, no peers.
|
||||
* Returns the next sense state and an optional trigger (`trigger: null` means no side effect).
|
||||
* Returns the next sense state and an optional workflow to start (`workflow: null` means no workflow).
|
||||
*/
|
||||
export type SenseComputeFn<S = unknown> = (
|
||||
state: S,
|
||||
) => Promise<{ state: S; trigger: SenseTrigger | null }>;
|
||||
) => Promise<{ state: S; workflow: WorkflowTrigger | null }>;
|
||||
|
||||
/**
|
||||
* The full shape a sense module (`src/index.ts`) must export.
|
||||
@@ -69,19 +69,28 @@ export function senseTriggerLabels(
|
||||
return [labelSenseTrigger({ interval: sc.interval, on: sc.on })];
|
||||
}
|
||||
|
||||
/** Validates `{ command: string }` from Sense compute or IPC (`trigger` field). */
|
||||
export function parseSenseTrigger(value: unknown): Result<SenseTrigger> {
|
||||
/**
|
||||
* Validates a structured workflow trigger object from Sense compute or IPC.
|
||||
*/
|
||||
export function parseWorkflowTrigger(value: unknown): Result<WorkflowTrigger> {
|
||||
if (!isPlainRecord(value)) {
|
||||
return err(new Error("sense trigger must be a plain object"));
|
||||
return err(new Error("workflow trigger must be a plain object"));
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (key !== "command") {
|
||||
return err(new Error(`sense trigger: unexpected property "${key}"`));
|
||||
}
|
||||
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 command = value.command;
|
||||
if (typeof command !== "string" || command.trim().length === 0) {
|
||||
return err(new Error('sense trigger: "command" 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'));
|
||||
}
|
||||
return ok({ command: command.trim() });
|
||||
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({ name: nameRaw.trim(), maxRounds, prompt, dryRun });
|
||||
}
|
||||
|
||||
@@ -22,11 +22,10 @@
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build",
|
||||
"pretest": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build",
|
||||
"pretest": "pnpm --filter @uncaged/nerve-core run build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/nerve-store": "workspace:*",
|
||||
"yaml": "^2.8.3"
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"sense-worker": "src/sense-worker.ts",
|
||||
"workflow-worker": "src/workflow-worker.ts",
|
||||
"experimental-warning-suppression": "src/experimental-warning-suppression.ts",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow";
|
||||
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockChild = EventEmitter & {
|
||||
@@ -63,7 +62,7 @@ vi.mock("node:child_process", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createWorkflowManager } = await import("@uncaged/workflow");
|
||||
const { createWorkflowManager } = await import("../workflow-manager.js");
|
||||
|
||||
function makeConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConfig {
|
||||
return {
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("createFileWatcher", () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
writeFileSync(
|
||||
join(root, "senses", "cpu-usage", "index.js"),
|
||||
"export const initialState = {}; export async function compute(state) { return { state, trigger: null }; }",
|
||||
"export const initialState = {}; export async function compute(state) { return { state, workflow: null }; }",
|
||||
);
|
||||
|
||||
await waitFor(() => changes.length > 0, 3000);
|
||||
|
||||
@@ -35,7 +35,7 @@ process.on("message", (msg) => {
|
||||
type: "compute-result",
|
||||
sense: msg.sense,
|
||||
state: 42,
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Behaviour:
|
||||
* - Sends { type: "ready" } on startup
|
||||
* - On { type: "compute", sense } → sends back compute-result with state + trigger:null
|
||||
* - On { type: "compute", sense } → sends back compute-result with state + workflow:null
|
||||
* - On { type: "shutdown" } → exits cleanly with code 0
|
||||
*/
|
||||
|
||||
@@ -27,7 +27,7 @@ process.on("message", (msg) => {
|
||||
type: "compute-result",
|
||||
sense: msg.sense,
|
||||
state: 42,
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ process.on("message", (msg) => {
|
||||
type: "compute-result",
|
||||
sense: msg.sense,
|
||||
state: "late",
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import type { WorkflowConfig } from "@uncaged/workflow";
|
||||
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockChild = EventEmitter & {
|
||||
@@ -68,7 +67,7 @@ vi.mock("node:child_process", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createWorkflowManager } = await import("@uncaged/workflow");
|
||||
const { createWorkflowManager } = await import("../workflow-manager.js");
|
||||
const { createKernel } = await import("../kernel.js");
|
||||
|
||||
function makeWfConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConfig {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Integration tests for Kernel + WorkflowManager integration.
|
||||
*
|
||||
* 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;
|
||||
* Verifies that sense compute-result IPC triggers workflow runs when `workflow`
|
||||
* is non-null; 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.
|
||||
@@ -58,7 +57,7 @@ function makeMockChild(pid = 1): MockChild {
|
||||
type: "compute-result",
|
||||
sense: m.sense,
|
||||
state: 42,
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -154,10 +153,20 @@ describe("kernel + workflowManager integration", () => {
|
||||
rmSync(nerveRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("workflowManager.startWorkflow", () => {
|
||||
it("spawns a workflow worker and sends start-thread", async () => {
|
||||
describe("sense compute triggers workflow via return value", () => {
|
||||
it("calls workflowManager.startWorkflow when a sense compute returns a workflow launch", 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" } },
|
||||
});
|
||||
|
||||
@@ -168,14 +177,24 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
kernel.workflowManager.startWorkflow("my-workflow", {
|
||||
prompt: "run this workflow",
|
||||
maxRounds: 10,
|
||||
dryRun: false,
|
||||
});
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: { reason: "test" },
|
||||
workflow: {
|
||||
name: "my-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "run this workflow",
|
||||
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[]) =>
|
||||
@@ -191,9 +210,19 @@ describe("kernel + workflowManager integration", () => {
|
||||
await stopPromise;
|
||||
});
|
||||
|
||||
it("passes prompt and maxRounds on start-thread", async () => {
|
||||
it("passes prompt and maxRounds from the workflow field to the workflow", 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" } },
|
||||
});
|
||||
|
||||
@@ -204,14 +233,24 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
kernel.workflowManager.startWorkflow("alert-workflow", {
|
||||
prompt: "handle critical alert",
|
||||
maxRounds: 5,
|
||||
dryRun: false,
|
||||
});
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: { level: "critical" },
|
||||
workflow: {
|
||||
name: "alert-workflow",
|
||||
maxRounds: 5,
|
||||
prompt: "handle critical alert",
|
||||
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(
|
||||
@@ -234,10 +273,8 @@ describe("kernel + workflowManager integration", () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await stopPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe("sense compute-result triggers", () => {
|
||||
it("logs compute-complete before shell-launch when shell trigger is present", async () => {
|
||||
it("logs compute-complete before workflow-launch when workflow is present", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
workflows: { "order-wf": { concurrency: 1, overflow: "drop" } },
|
||||
@@ -256,7 +293,12 @@ describe("kernel + workflowManager integration", () => {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: { seq: 1 },
|
||||
trigger: { command: "echo order-test" },
|
||||
workflow: {
|
||||
name: "order-wf",
|
||||
maxRounds: 2,
|
||||
prompt: "p",
|
||||
dryRun: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,16 +309,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 shellAt = typeOrder.indexOf("shell-launch");
|
||||
const launchAt = typeOrder.indexOf("workflow-launch");
|
||||
expect(completeAt).toBeGreaterThanOrEqual(0);
|
||||
expect(shellAt).toBeGreaterThan(completeAt);
|
||||
expect(launchAt).toBeGreaterThan(completeAt);
|
||||
|
||||
const stopPromise = kernel.stop();
|
||||
await vi.runAllTimersAsync();
|
||||
await stopPromise;
|
||||
});
|
||||
|
||||
it("does not trigger workflow when compute-result has trigger null", async () => {
|
||||
it("does not trigger workflow when compute-result has workflow null", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
senses: {
|
||||
@@ -313,10 +355,11 @@ describe("kernel + workflowManager integration", () => {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: 50,
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
}
|
||||
|
||||
// No workflow should have been started
|
||||
const workflowWorkerSpawned = mockChildren.some((c) =>
|
||||
(c.send as ReturnType<typeof vi.fn>).mock.calls.some(
|
||||
(args: unknown[]) =>
|
||||
@@ -331,10 +374,25 @@ describe("kernel + workflowManager integration", () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await stopPromise;
|
||||
});
|
||||
});
|
||||
|
||||
it("logs shell-launch and does not start a workflow for shell triggers", async () => {
|
||||
describe("workflow events are logged", () => {
|
||||
it("logs a 'started' event when workflow thread is triggered via sense compute", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({ workflows: {} });
|
||||
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" } },
|
||||
});
|
||||
|
||||
const kernel = createKernel(config, nerveRoot, {
|
||||
workerScript: "fake-worker.js",
|
||||
logStore,
|
||||
@@ -347,58 +405,18 @@ describe("kernel + workflowManager integration", () => {
|
||||
workerPool.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: {},
|
||||
trigger: {
|
||||
command: "echo nerve-shell-test",
|
||||
state: { note: "log" },
|
||||
workflow: {
|
||||
name: "log-test-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "test prompt",
|
||||
dryRun: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const shellLaunch = logStore.append.mock.calls
|
||||
.map((c) => c[0] as { source: string; type: string })
|
||||
.find((e) => e.type === "shell-launch");
|
||||
expect(shellLaunch).toBeDefined();
|
||||
|
||||
const startThread = mockChildren
|
||||
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
|
||||
.some(
|
||||
([msg]) =>
|
||||
msg !== null &&
|
||||
typeof msg === "object" &&
|
||||
(msg as Record<string, unknown>).type === "start-thread",
|
||||
);
|
||||
expect(startThread).toBe(false);
|
||||
|
||||
const stopPromise = kernel.stop();
|
||||
await vi.runAllTimersAsync();
|
||||
await stopPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow events are logged", () => {
|
||||
it("logs a 'started' event when workflow thread is started via workflowManager", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
workflows: { "log-test-workflow": { concurrency: 2, overflow: "drop" } },
|
||||
});
|
||||
|
||||
const kernel = createKernel(config, nerveRoot, {
|
||||
workerScript: "fake-worker.js",
|
||||
logStore,
|
||||
});
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
kernel.workflowManager.startWorkflow("log-test-workflow", {
|
||||
prompt: "test prompt",
|
||||
maxRounds: 10,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: "workflow", type: "started" }),
|
||||
expect.objectContaining({ workflow: "log-test-workflow", status: "started" }),
|
||||
@@ -414,6 +432,16 @@ 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,
|
||||
});
|
||||
@@ -425,6 +453,7 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Reload with a workflow added
|
||||
const newConfig: NerveConfig = {
|
||||
senses: {
|
||||
"cpu-usage": {
|
||||
@@ -443,11 +472,20 @@ describe("kernel + workflowManager integration", () => {
|
||||
};
|
||||
kernel.reloadConfig(newConfig);
|
||||
|
||||
kernel.workflowManager.startWorkflow("new-workflow", {
|
||||
prompt: "reload test",
|
||||
maxRounds: 10,
|
||||
dryRun: false,
|
||||
});
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: { phase: "reload" },
|
||||
workflow: {
|
||||
name: "new-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "reload test",
|
||||
dryRun: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
@@ -471,6 +509,16 @@ 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" } },
|
||||
});
|
||||
|
||||
@@ -481,6 +529,7 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Reload with the workflow removed
|
||||
const newConfig: NerveConfig = {
|
||||
senses: {
|
||||
"cpu-usage": {
|
||||
@@ -499,15 +548,25 @@ describe("kernel + workflowManager integration", () => {
|
||||
};
|
||||
kernel.reloadConfig(newConfig);
|
||||
|
||||
// Clear send history
|
||||
for (const c of mockChildren) {
|
||||
(c.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
}
|
||||
|
||||
kernel.workflowManager.startWorkflow("old-workflow", {
|
||||
prompt: "should not work",
|
||||
maxRounds: 10,
|
||||
dryRun: false,
|
||||
});
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: { stale: true },
|
||||
workflow: {
|
||||
name: "old-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "should not work",
|
||||
dryRun: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
@@ -532,6 +591,16 @@ 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" } },
|
||||
});
|
||||
|
||||
@@ -542,11 +611,20 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
kernel.workflowManager.startWorkflow("shutdown-test", {
|
||||
prompt: "test",
|
||||
maxRounds: 10,
|
||||
dryRun: false,
|
||||
});
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: { shutdownCase: true },
|
||||
workflow: {
|
||||
name: "shutdown-test",
|
||||
maxRounds: 10,
|
||||
prompt: "test",
|
||||
dryRun: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
@@ -577,6 +655,16 @@ 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" } },
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ function makeMockChild(pid = 1): MockChild {
|
||||
type: "compute-result",
|
||||
sense: m.sense,
|
||||
state: 42,
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -140,7 +140,7 @@ describe("kernel — message routing", () => {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: 42,
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("kernel — message routing", () => {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: 123,
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
const rows = logStore.query({
|
||||
source: "sense",
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("executeCompute", () => {
|
||||
it("passes state into compute and persists returned state", async () => {
|
||||
const path = makeTempStatePath();
|
||||
const runtime = makeRuntime(
|
||||
async (s) => ({ state: { n: s.n + 1 }, trigger: null }),
|
||||
async (s) => ({ state: { n: s.n + 1 }, workflow: null }),
|
||||
{ n: 0 },
|
||||
path,
|
||||
);
|
||||
@@ -71,7 +71,7 @@ describe("executeCompute", () => {
|
||||
const result = await executeCompute(runtime);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toEqual({ state: { n: 1 }, trigger: null });
|
||||
expect(result.value).toEqual({ state: { n: 1 }, workflow: null });
|
||||
expect(runtime.state).toEqual({ n: 1 });
|
||||
expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ n: 1 });
|
||||
});
|
||||
@@ -93,7 +93,7 @@ describe("executeCompute", () => {
|
||||
it("returns err when compute exceeds timeoutMs", async () => {
|
||||
const runtime = makeRuntime(
|
||||
async (s) =>
|
||||
new Promise((resolve) => setTimeout(() => resolve({ state: s, trigger: null }), 5_000)),
|
||||
new Promise((resolve) => setTimeout(() => resolve({ state: s, workflow: null }), 5_000)),
|
||||
{ n: 0 },
|
||||
);
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("executeCompute", () => {
|
||||
});
|
||||
|
||||
it("completes within timeout when compute is fast", async () => {
|
||||
const runtime = makeRuntime(async (s) => ({ state: { n: s.n }, trigger: null }), { n: 42 });
|
||||
const runtime = makeRuntime(async (s) => ({ state: { n: s.n }, workflow: null }), { n: 42 });
|
||||
const result = await executeCompute(runtime, 5_000);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
@@ -84,13 +84,13 @@ describe("createSenseWorkerPool", () => {
|
||||
type: "compute-result",
|
||||
sense: "s",
|
||||
state: 1,
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
expect(onWorkerMessage).toHaveBeenCalledWith({
|
||||
type: "compute-result",
|
||||
sense: "s",
|
||||
state: 1,
|
||||
trigger: null,
|
||||
workflow: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createWorkerRuntime } from "@uncaged/workflow";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createWorkerRuntime } from "../worker-runtime.js";
|
||||
|
||||
const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
|
||||
const echoWorkerPath = join(fixturesDir, "echo-worker.js");
|
||||
|
||||
@@ -61,7 +61,7 @@ vi.mock("node:child_process", () => ({
|
||||
}));
|
||||
|
||||
// Import after mock is set up
|
||||
const { createWorkflowManager } = await import("@uncaged/workflow");
|
||||
const { createWorkflowManager } = await import("../workflow-manager.js");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HealthInfo, SenseInfo, WorkflowStatus } from "@uncaged/nerve-core";
|
||||
|
||||
import type { WorkflowManager } from "@uncaged/workflow";
|
||||
import type { WorkflowManager } from "./workflow-manager.js";
|
||||
|
||||
export type DaemonHandlerBundle = {
|
||||
health: () => HealthInfo;
|
||||
|
||||
@@ -12,7 +12,7 @@ export type {
|
||||
ResumeThreadMessage,
|
||||
ThreadEventMessage,
|
||||
WorkflowErrorMessage,
|
||||
ThreadWorkflowMessage,
|
||||
ThreadWorkflowMessageMessage,
|
||||
} from "./ipc.js";
|
||||
|
||||
export { loadSenseModule, executeCompute, readState, writeState } from "./sense-runtime.js";
|
||||
@@ -45,5 +45,5 @@ export type {
|
||||
WorkflowRunStatus,
|
||||
} from "@uncaged/nerve-store";
|
||||
|
||||
export { createWorkflowManager } from "@uncaged/workflow";
|
||||
export type { WorkflowManager } from "@uncaged/workflow";
|
||||
export { createWorkflowManager } from "./workflow-manager.js";
|
||||
export type { WorkflowManager } from "./workflow-manager.js";
|
||||
|
||||
+273
-55
@@ -1,30 +1,10 @@
|
||||
/**
|
||||
* IPC message types for parent (kernel) ↔ sense worker communication.
|
||||
* Protocol per RFC §5.2: hub-and-spoke, all messages through engine.
|
||||
*
|
||||
* Workflow worker IPC types and parsers live in `@uncaged/workflow`.
|
||||
*/
|
||||
|
||||
import type { Result, SenseTrigger } from "@uncaged/nerve-core";
|
||||
import { err, isPlainRecord, ok, parseSenseTrigger } from "@uncaged/nerve-core";
|
||||
import type {
|
||||
KillThreadMessage,
|
||||
ResumeThreadMessage,
|
||||
StartThreadMessage,
|
||||
ThreadEventMessage,
|
||||
ThreadWorkflowMessage,
|
||||
WorkflowErrorMessage,
|
||||
} from "@uncaged/workflow";
|
||||
import { parseWorkflowParentMessage, parseWorkflowWorkerToParentMessage } from "@uncaged/workflow";
|
||||
|
||||
export type {
|
||||
KillThreadMessage,
|
||||
ResumeThreadMessage,
|
||||
StartThreadMessage,
|
||||
ThreadEventMessage,
|
||||
ThreadWorkflowMessage,
|
||||
WorkflowErrorMessage,
|
||||
} from "@uncaged/workflow";
|
||||
import type { Result, WorkflowTrigger } from "@uncaged/nerve-core";
|
||||
import { err, isPlainRecord, ok, parseWorkflowTrigger } from "@uncaged/nerve-core";
|
||||
|
||||
/** Parent → Worker: trigger one compute cycle for a sense */
|
||||
export type ComputeMessage = {
|
||||
@@ -42,6 +22,40 @@ export type HealthRequestMessage = {
|
||||
type: "health-request";
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow IPC messages (RFC-002 §5.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parent → Workflow Worker: start a new thread */
|
||||
export type StartThreadMessage = {
|
||||
type: "start-thread";
|
||||
runId: string;
|
||||
workflow: string;
|
||||
prompt: string;
|
||||
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
|
||||
maxRounds: number;
|
||||
/** When true, roles may skip side effects (thread-level hint on the start frame). */
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
|
||||
export type ResumeThreadMessage = {
|
||||
type: "resume-thread";
|
||||
runId: string;
|
||||
/** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */
|
||||
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
|
||||
/** Safety-valve: max moderator rounds for this thread. */
|
||||
maxRounds: number;
|
||||
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
/** Parent → Workflow Worker: kill a specific running thread */
|
||||
export type KillThreadMessage = {
|
||||
type: "kill-thread";
|
||||
runId: string;
|
||||
};
|
||||
|
||||
/** Union of all messages the parent sends to a worker */
|
||||
export type ParentToWorkerMessage =
|
||||
| ComputeMessage
|
||||
@@ -51,12 +65,19 @@ export type ParentToWorkerMessage =
|
||||
| ResumeThreadMessage
|
||||
| KillThreadMessage;
|
||||
|
||||
/** Worker → Parent: sense compute finished (state persisted in worker; optional shell trigger). */
|
||||
/** Worker → Parent: sense compute finished (state persisted in worker; workflow optional). */
|
||||
export type ComputeResultMessage = {
|
||||
type: "compute-result";
|
||||
sense: string;
|
||||
state: unknown;
|
||||
trigger: SenseTrigger | null;
|
||||
workflow: WorkflowTrigger | 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 */
|
||||
@@ -78,6 +99,46 @@ export type HealthResponseMessage = {
|
||||
inFlightCount: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow Worker → Parent messages (RFC-002 §5.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Valid lifecycle event types for a workflow thread. */
|
||||
export type ThreadEventType =
|
||||
| "queued"
|
||||
| "started"
|
||||
| "step_complete"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "killed";
|
||||
|
||||
/**
|
||||
* Workflow Worker → Parent: a thread lifecycle event.
|
||||
*/
|
||||
export type ThreadEventMessage = {
|
||||
type: "thread-event";
|
||||
runId: string;
|
||||
eventType: ThreadEventType;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
/** Workflow Worker → Parent: a thread encountered an unrecoverable error. */
|
||||
export type WorkflowErrorMessage = {
|
||||
type: "workflow-error";
|
||||
runId: string;
|
||||
error: string;
|
||||
/** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
|
||||
export type ThreadWorkflowMessageMessage = {
|
||||
type: "thread-workflow-message";
|
||||
runId: string;
|
||||
/** The WorkflowMessage produced by the role — persisted for crash recovery. */
|
||||
message: { role: string; content: string; meta: unknown; timestamp: number };
|
||||
};
|
||||
|
||||
/** Union of all messages a worker sends to the parent */
|
||||
export type WorkerToParentMessage =
|
||||
| ComputeResultMessage
|
||||
@@ -86,7 +147,8 @@ export type WorkerToParentMessage =
|
||||
| HealthResponseMessage
|
||||
| ThreadEventMessage
|
||||
| WorkflowErrorMessage
|
||||
| ThreadWorkflowMessage;
|
||||
| ThreadWorkflowMessageMessage
|
||||
| SenseWorkflowTriggerMessage;
|
||||
|
||||
const PARENT_MSG_TYPES = new Set([
|
||||
"compute",
|
||||
@@ -97,6 +159,24 @@ const PARENT_MSG_TYPES = new Set([
|
||||
"kill-thread",
|
||||
]);
|
||||
|
||||
function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
|
||||
if (typeof obj.runId !== "string") return "'start-thread' message missing string 'runId'";
|
||||
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
|
||||
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
|
||||
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
|
||||
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
|
||||
if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'";
|
||||
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
|
||||
if (typeof obj.maxRounds !== "number")
|
||||
return "'resume-thread' message missing number 'maxRounds'";
|
||||
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseParentCompute(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
|
||||
if (typeof obj.sense !== "string") {
|
||||
return err(new Error("IPC 'compute' message missing string 'sense' field"));
|
||||
@@ -104,6 +184,40 @@ function parseParentCompute(obj: Record<string, unknown>): Result<ParentToWorker
|
||||
return ok({ type: "compute", sense: obj.sense });
|
||||
}
|
||||
|
||||
function parseParentStartThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
|
||||
const errMsg = validateStartThreadMsg(obj);
|
||||
if (errMsg !== null) return err(new Error(errMsg));
|
||||
// Field types are validated above; `Record<string, unknown>` values stay `unknown` to TypeScript.
|
||||
return ok({
|
||||
type: "start-thread",
|
||||
runId: obj.runId,
|
||||
workflow: obj.workflow,
|
||||
prompt: obj.prompt,
|
||||
maxRounds: obj.maxRounds,
|
||||
dryRun: obj.dryRun,
|
||||
} as StartThreadMessage);
|
||||
}
|
||||
|
||||
function parseParentResumeThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
|
||||
const errMsg = validateResumeThreadMsg(obj);
|
||||
if (errMsg !== null) return err(new Error(errMsg));
|
||||
// Elements are validated as plain objects by the kernel; trust the wire shape here.
|
||||
return ok({
|
||||
type: "resume-thread",
|
||||
runId: obj.runId,
|
||||
messages: obj.messages as ResumeThreadMessage["messages"],
|
||||
maxRounds: obj.maxRounds,
|
||||
dryRun: obj.dryRun,
|
||||
} as ResumeThreadMessage);
|
||||
}
|
||||
|
||||
function parseParentKillThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
|
||||
if (typeof obj.runId !== "string") {
|
||||
return err(new Error("'kill-thread' message missing string 'runId'"));
|
||||
}
|
||||
return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage);
|
||||
}
|
||||
|
||||
/** Validate and parse an unknown IPC message received from the parent process. */
|
||||
export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage> {
|
||||
if (!isPlainRecord(raw)) {
|
||||
@@ -124,14 +238,11 @@ export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage>
|
||||
case "health-request":
|
||||
return ok({ type: "health-request" });
|
||||
case "start-thread":
|
||||
return parseParentStartThread(obj);
|
||||
case "resume-thread":
|
||||
case "kill-thread": {
|
||||
const wf = parseWorkflowParentMessage(raw);
|
||||
if (!wf.ok) {
|
||||
return wf;
|
||||
}
|
||||
return ok(wf.value as ParentToWorkerMessage);
|
||||
}
|
||||
return parseParentResumeThread(obj);
|
||||
case "kill-thread":
|
||||
return parseParentKillThread(obj);
|
||||
default:
|
||||
return err(new Error(`Unhandled IPC message type: "${obj.type}"`));
|
||||
}
|
||||
@@ -144,26 +255,26 @@ function parseComputeResultMsg(obj: Record<string, unknown>): Result<WorkerToPar
|
||||
if (!("state" in obj)) {
|
||||
return err(new Error("Worker 'compute-result' message missing 'state' field"));
|
||||
}
|
||||
if (!("trigger" in obj)) {
|
||||
return err(new Error("Worker 'compute-result' message missing 'trigger' field"));
|
||||
if (!("workflow" in obj)) {
|
||||
return err(new Error("Worker 'compute-result' message missing 'workflow' field"));
|
||||
}
|
||||
const triggerRaw = obj.trigger;
|
||||
if (triggerRaw !== null && !isPlainRecord(triggerRaw)) {
|
||||
return err(new Error("Worker 'compute-result' trigger must be an object or null"));
|
||||
const wfRaw = obj.workflow;
|
||||
if (wfRaw !== null && !isPlainRecord(wfRaw)) {
|
||||
return err(new Error("Worker 'compute-result' workflow must be an object or null"));
|
||||
}
|
||||
let trigger: SenseTrigger | null;
|
||||
if (triggerRaw === null) {
|
||||
trigger = null;
|
||||
let workflow: WorkflowTrigger | null;
|
||||
if (wfRaw === null) {
|
||||
workflow = null;
|
||||
} else {
|
||||
const parsed = parseSenseTrigger(triggerRaw);
|
||||
const parsed = parseWorkflowTrigger(wfRaw);
|
||||
if (!parsed.ok) return err(parsed.error);
|
||||
trigger = parsed.value;
|
||||
workflow = parsed.value;
|
||||
}
|
||||
return ok({
|
||||
type: "compute-result",
|
||||
sense: obj.sense,
|
||||
state: obj.state,
|
||||
trigger,
|
||||
workflow,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,11 +307,55 @@ function parseHealthResponseMsg(obj: Record<string, unknown>): Result<WorkerToPa
|
||||
});
|
||||
}
|
||||
|
||||
const WORKFLOW_WORKER_MSG_TYPES = new Set([
|
||||
"thread-event",
|
||||
"workflow-error",
|
||||
"thread-workflow-message",
|
||||
]);
|
||||
function isThreadEventType(value: string): value is ThreadEventType {
|
||||
switch (value) {
|
||||
case "queued":
|
||||
case "started":
|
||||
case "step_complete":
|
||||
case "completed":
|
||||
case "failed":
|
||||
case "killed":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseThreadEventMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||
if (typeof obj.runId !== "string") {
|
||||
return err(new Error("Worker 'thread-event' message missing string 'runId' field"));
|
||||
}
|
||||
if (typeof obj.eventType !== "string" || !isThreadEventType(obj.eventType)) {
|
||||
return err(
|
||||
new Error(`Worker 'thread-event' message has invalid 'eventType': "${obj.eventType}"`),
|
||||
);
|
||||
}
|
||||
if (!("payload" in obj)) {
|
||||
return err(new Error("Worker 'thread-event' message missing 'payload' field"));
|
||||
}
|
||||
return ok({
|
||||
type: "thread-event",
|
||||
runId: obj.runId,
|
||||
eventType: obj.eventType,
|
||||
payload: obj.payload,
|
||||
});
|
||||
}
|
||||
|
||||
function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||
if (typeof obj.runId !== "string") {
|
||||
return err(new Error("Worker 'workflow-error' message missing string 'runId' field"));
|
||||
}
|
||||
if (typeof obj.error !== "string") {
|
||||
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
|
||||
}
|
||||
const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1;
|
||||
return ok({
|
||||
type: "workflow-error",
|
||||
runId: obj.runId,
|
||||
error: obj.error,
|
||||
exitCode,
|
||||
});
|
||||
}
|
||||
|
||||
const WORKER_MSG_TYPES = new Set([
|
||||
"compute-result",
|
||||
@@ -210,8 +365,74 @@ const WORKER_MSG_TYPES = new Set([
|
||||
"thread-event",
|
||||
"workflow-error",
|
||||
"thread-workflow-message",
|
||||
"sense-workflow-trigger",
|
||||
]);
|
||||
|
||||
function parseThreadWorkflowMessageMsg(
|
||||
obj: Record<string, unknown>,
|
||||
): Result<WorkerToParentMessage> {
|
||||
if (typeof obj.runId !== "string") {
|
||||
return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field"));
|
||||
}
|
||||
if (!isPlainRecord(obj.message)) {
|
||||
return err(new Error("Worker 'thread-workflow-message' missing object 'message' field"));
|
||||
}
|
||||
const msg = obj.message;
|
||||
if (typeof msg.role !== "string") {
|
||||
return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field"));
|
||||
}
|
||||
if (typeof msg.content !== "string") {
|
||||
return err(
|
||||
new Error("Worker 'thread-workflow-message' message missing string 'content' field"),
|
||||
);
|
||||
}
|
||||
if (typeof msg.timestamp !== "number") {
|
||||
return err(
|
||||
new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"),
|
||||
);
|
||||
}
|
||||
return ok({
|
||||
type: "thread-workflow-message",
|
||||
runId: obj.runId,
|
||||
message: {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
meta: "meta" in msg ? msg.meta : undefined,
|
||||
timestamp: msg.timestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 wf = obj.workflow;
|
||||
if (typeof wf.name !== "string")
|
||||
return err(new Error("Worker 'sense-workflow-trigger' workflow missing string 'name'"));
|
||||
if (typeof wf.maxRounds !== "number")
|
||||
return err(new Error("Worker 'sense-workflow-trigger' workflow missing number 'maxRounds'"));
|
||||
if (typeof wf.prompt !== "string")
|
||||
return err(new Error("Worker 'sense-workflow-trigger' workflow missing string 'prompt'"));
|
||||
if (typeof wf.dryRun !== "boolean")
|
||||
return err(new Error("Worker 'sense-workflow-trigger' workflow missing boolean 'dryRun'"));
|
||||
return ok({
|
||||
type: "sense-workflow-trigger",
|
||||
sense: obj.sense,
|
||||
workflow: {
|
||||
name: wf.name,
|
||||
maxRounds: wf.maxRounds,
|
||||
prompt: wf.prompt,
|
||||
dryRun: wf.dryRun,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate and parse an unknown IPC message received from a worker process. */
|
||||
export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage> {
|
||||
if (!isPlainRecord(raw)) {
|
||||
@@ -224,15 +445,12 @@ export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage>
|
||||
if (!WORKER_MSG_TYPES.has(obj.type)) {
|
||||
return err(new Error(`Unknown worker IPC message type: "${obj.type}"`));
|
||||
}
|
||||
if (WORKFLOW_WORKER_MSG_TYPES.has(obj.type)) {
|
||||
const wf = parseWorkflowWorkerToParentMessage(raw);
|
||||
if (!wf.ok) {
|
||||
return wf;
|
||||
}
|
||||
return ok(wf.value as WorkerToParentMessage);
|
||||
}
|
||||
if (obj.type === "compute-result") return parseComputeResultMsg(obj);
|
||||
if (obj.type === "error") return parseErrorMsg(obj);
|
||||
if (obj.type === "health-response") return parseHealthResponseMsg(obj);
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||
|
||||
import type { LogStore } from "@uncaged/nerve-store";
|
||||
import type { WorkflowManager } from "@uncaged/workflow";
|
||||
import type { WorkflowManager } from "./workflow-manager.js";
|
||||
|
||||
export type KernelFileWatchDeps = {
|
||||
nerveRoot: string;
|
||||
|
||||
@@ -12,14 +12,12 @@ import {
|
||||
type HealthInfo,
|
||||
type NerveConfig,
|
||||
type SenseInfo,
|
||||
type SenseTrigger,
|
||||
type WorkflowTrigger,
|
||||
senseTriggerLabels,
|
||||
} from "@uncaged/nerve-core";
|
||||
|
||||
import { createLogStore } from "@uncaged/nerve-store";
|
||||
import type { LogStore } from "@uncaged/nerve-store";
|
||||
import { createWorkflowManager } from "@uncaged/workflow";
|
||||
import type { WorkflowManager } from "@uncaged/workflow";
|
||||
import { createDaemonHandlers } from "./daemon-handlers.js";
|
||||
import { createDaemonIpcServer } from "./daemon-ipc.js";
|
||||
import type { DaemonIpcServer } from "./daemon-ipc.js";
|
||||
@@ -38,6 +36,8 @@ import {
|
||||
import { createSenseScheduler } from "./sense-scheduler.js";
|
||||
import type { SenseScheduler } from "./sense-scheduler.js";
|
||||
import { createSenseWorkerPool, resolveWorkerScript } from "./worker-pool.js";
|
||||
import { createWorkflowManager } from "./workflow-manager.js";
|
||||
import type { WorkflowManager } from "./workflow-manager.js";
|
||||
|
||||
export type KernelHealth = {
|
||||
uptime: number;
|
||||
@@ -145,7 +145,7 @@ export function createKernel(
|
||||
}
|
||||
}
|
||||
|
||||
function handleComputeResult(senseName: string, trigger: SenseTrigger | null): void {
|
||||
function handleComputeResult(senseName: string, workflow: WorkflowTrigger | null): void {
|
||||
logStore.append({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
@@ -154,12 +154,17 @@ export function createKernel(
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
if (trigger !== null) {
|
||||
if (workflow !== null) {
|
||||
workflowManager.startWorkflow(workflow.name, {
|
||||
prompt: workflow.prompt,
|
||||
maxRounds: workflow.maxRounds,
|
||||
dryRun: workflow.dryRun,
|
||||
});
|
||||
logStore.append({
|
||||
source: "sense",
|
||||
type: "shell-launch",
|
||||
type: "workflow-launch",
|
||||
refId: senseName,
|
||||
payload: JSON.stringify(trigger),
|
||||
payload: JSON.stringify(workflow),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
@@ -197,7 +202,7 @@ export function createKernel(
|
||||
}
|
||||
|
||||
if (msg.type === "compute-result") {
|
||||
handleComputeResult(msg.sense, msg.trigger);
|
||||
handleComputeResult(msg.sense, msg.workflow);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import type { Result, SenseComputeFn, SenseTrigger } from "@uncaged/nerve-core";
|
||||
import type { Result, SenseComputeFn, WorkflowTrigger } from "@uncaged/nerve-core";
|
||||
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
|
||||
|
||||
/** All state held for one sense inside a worker */
|
||||
@@ -19,9 +19,7 @@ export function readState(statePath: string, initialState: unknown): unknown {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(
|
||||
`[sense-runtime] warning: failed to read state from "${statePath}": ${msg} — using initialState\n`,
|
||||
);
|
||||
process.stderr.write(`[sense-runtime] warning: failed to read state from "${statePath}": ${msg} — using initialState\n`);
|
||||
return initialState;
|
||||
}
|
||||
}
|
||||
@@ -74,7 +72,7 @@ export async function loadSenseModule(
|
||||
export async function executeCompute(
|
||||
runtime: SenseRuntime,
|
||||
timeoutMs?: number,
|
||||
): Promise<Result<{ state: unknown; trigger: SenseTrigger | null }>> {
|
||||
): Promise<Result<{ state: unknown; workflow: WorkflowTrigger | null }>> {
|
||||
const controller = new AbortController();
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
@@ -14,12 +14,11 @@
|
||||
|
||||
import "./experimental-warning-suppression.js";
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig, SenseTrigger } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig, WorkflowTrigger } from "@uncaged/nerve-core";
|
||||
|
||||
import type { WorkerToParentMessage } from "./ipc.js";
|
||||
import { parseParentMessage } from "./ipc.js";
|
||||
@@ -43,36 +42,9 @@ function sendReady(): void {
|
||||
|
||||
function sendComputeResult(
|
||||
sense: string,
|
||||
value: { state: unknown; trigger: SenseTrigger | null },
|
||||
value: { state: unknown; workflow: WorkflowTrigger | null },
|
||||
): void {
|
||||
send({ type: "compute-result", sense, state: value.state, trigger: value.trigger });
|
||||
}
|
||||
|
||||
function executeShellTriggerIfNeeded(nerveRoot: string, trigger: SenseTrigger | null): void {
|
||||
if (trigger === null) return;
|
||||
const child = spawn(trigger.command, {
|
||||
shell: true,
|
||||
cwd: nerveRoot,
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
process.stderr.write(`[sense-worker] shell trigger failed: ${err.message}\n`);
|
||||
});
|
||||
if (child.stderr) {
|
||||
let stderrBuf = "";
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code !== null && code !== 0 && stderrBuf.length > 0) {
|
||||
process.stderr.write(
|
||||
`[sense-worker] shell trigger exited with code ${code}: ${stderrBuf.trimEnd()}\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
child.unref();
|
||||
send({ type: "compute-result", sense, state: value.state, workflow: value.workflow });
|
||||
}
|
||||
|
||||
function sendError(sense: string, error: string): void {
|
||||
@@ -160,7 +132,6 @@ async function runCompute(
|
||||
runtime: SenseRuntime,
|
||||
timeoutMs: number,
|
||||
gracePeriodMs: number | null,
|
||||
nerveRoot: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await executeCompute(runtime, timeoutMs);
|
||||
@@ -172,7 +143,6 @@ async function runCompute(
|
||||
return;
|
||||
}
|
||||
clearGracePeriodTimer(senseName);
|
||||
executeShellTriggerIfNeeded(nerveRoot, result.value.trigger);
|
||||
sendComputeResult(senseName, result.value);
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
@@ -190,7 +160,6 @@ function handleMessage(
|
||||
group: string,
|
||||
senseConfigs: Map<string, { timeout: number | null; gracePeriod: number | null }>,
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
nerveRoot: string,
|
||||
): void {
|
||||
const parseResult = parseParentMessage(raw);
|
||||
if (!parseResult.ok) {
|
||||
@@ -227,7 +196,7 @@ function handleMessage(
|
||||
|
||||
const previous = inFlight.get(msg.sense) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.then(() => runCompute(msg.sense, runtime, timeoutMs, gracePeriodMs, nerveRoot))
|
||||
.then(() => runCompute(msg.sense, runtime, timeoutMs, gracePeriodMs))
|
||||
.catch((e: unknown) => {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
sendError(msg.sense, errMsg);
|
||||
@@ -288,7 +257,7 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
|
||||
sendReady();
|
||||
|
||||
process.on("message", (raw: unknown) => {
|
||||
handleMessage(raw, runtimes, group, senseConfigs, inFlight, nerveRoot);
|
||||
handleMessage(raw, runtimes, group, senseConfigs, inFlight);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { ComputeMessage } from "./ipc.js";
|
||||
import {
|
||||
createWorkerRuntime,
|
||||
formatCapturedStderrTail,
|
||||
formatChildExitSummary,
|
||||
} from "@uncaged/workflow";
|
||||
import type { ComputeMessage } from "./ipc.js";
|
||||
} from "./worker-runtime.js";
|
||||
|
||||
export function resolveWorkerScript(): string {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
+19
-10
@@ -2,13 +2,15 @@
|
||||
* Pure helpers and IPC branching for workflow-manager (keeps workflow-manager.ts lean).
|
||||
*/
|
||||
|
||||
import { isPlainRecord } from "@uncaged/nerve-core";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { WorkflowMessage } from "@uncaged/nerve-core";
|
||||
import { START, isPlainRecord } from "@uncaged/nerve-core";
|
||||
|
||||
import type { LogStore, WorkflowRunStatus } from "@uncaged/nerve-store";
|
||||
import type { ResumeThreadMessage, ThreadEventMessage } from "./ipc.js";
|
||||
import type { WorkflowChildToParentMessage } from "./ipc.js";
|
||||
import type { WorkflowMessage } from "./types.js";
|
||||
import { START } from "./types.js";
|
||||
import type { WorkerToParentMessage } from "./ipc.js";
|
||||
|
||||
export type PendingThread = {
|
||||
runId: string;
|
||||
@@ -31,7 +33,7 @@ export const WORKFLOW_WORKER_RESPAWN = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Worker shutdown timeout — must stay in sync with shutdown handling in `worker.ts`.
|
||||
* Worker shutdown timeout — must stay in sync with SHUTDOWN_TIMEOUT_MS in workflow-worker.ts.
|
||||
* The drain timeout passed to drainAndRespawn must be >= this value so the worker has
|
||||
* enough time to finish in-flight threads before the parent force-kills it.
|
||||
*/
|
||||
@@ -77,6 +79,12 @@ export function ensureThreadMessagesWithStart(
|
||||
return [start, ...mapped];
|
||||
}
|
||||
|
||||
export function resolveWorkflowWorkerScript(): string {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
return join(__dir, "workflow-worker.js");
|
||||
}
|
||||
|
||||
export function mapWorkflowRunStatus(eventType: string): WorkflowRunStatus | null {
|
||||
const map: Record<string, WorkflowRunStatus> = {
|
||||
started: "started",
|
||||
@@ -215,13 +223,9 @@ export type WorkflowManagerMessageDeps = {
|
||||
|
||||
export function dispatchWorkflowWorkerMessage(
|
||||
workflowName: string,
|
||||
msg: WorkflowChildToParentMessage,
|
||||
msg: WorkerToParentMessage,
|
||||
deps: WorkflowManagerMessageDeps,
|
||||
): void {
|
||||
if (msg.type === "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "thread-event") {
|
||||
deps.handleThreadEvent(workflowName, msg);
|
||||
return;
|
||||
@@ -243,5 +247,10 @@ export function dispatchWorkflowWorkerMessage(
|
||||
`[workflow-manager] workflow-error for runId "${msg.runId}" in "${workflowName}": ${msg.error}\n`,
|
||||
);
|
||||
deps.onWorkflowRoleError(workflowName, msg.runId, msg.error, msg.exitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
process.stderr.write(`[workflow-manager] error from "${workflowName}" worker: ${msg.error}\n`);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@
|
||||
* Concurrency and overflow (drop/queue) are enforced here in the parent process.
|
||||
*/
|
||||
|
||||
import type { NerveConfig, WorkflowStatus } from "@uncaged/nerve-core";
|
||||
|
||||
import type { WorkflowConfig } from "./config.js";
|
||||
import type { NerveConfig, WorkflowConfig, WorkflowStatus } from "@uncaged/nerve-core";
|
||||
|
||||
import type { LogStore } from "@uncaged/nerve-store";
|
||||
import type { KillThreadMessage, StartThreadMessage, ThreadEventMessage } from "./ipc.js";
|
||||
import { parseWorkflowChildMessage } from "./ipc.js";
|
||||
import { parseWorkerMessage } from "./ipc.js";
|
||||
import {
|
||||
createWorkerRuntime,
|
||||
formatCapturedStderrTail,
|
||||
formatChildExitSummary,
|
||||
} from "./worker-runtime.js";
|
||||
import {
|
||||
DEFAULT_MAX_QUEUE,
|
||||
WORKER_SHUTDOWN_TIMEOUT_MS,
|
||||
@@ -22,13 +25,8 @@ import {
|
||||
dispatchWorkflowWorkerMessage,
|
||||
extractExitCodeFromPayload,
|
||||
recoverThreadsFromStore,
|
||||
} from "./manager-support.js";
|
||||
import { WORKFLOW_WORKER_PATH } from "./paths.js";
|
||||
import {
|
||||
createWorkerRuntime,
|
||||
formatCapturedStderrTail,
|
||||
formatChildExitSummary,
|
||||
} from "./worker-runtime.js";
|
||||
resolveWorkflowWorkerScript,
|
||||
} from "./workflow-manager-support.js";
|
||||
|
||||
export type WorkflowLaunchParams = {
|
||||
prompt: string;
|
||||
@@ -37,7 +35,7 @@ export type WorkflowLaunchParams = {
|
||||
};
|
||||
|
||||
export type WorkflowManager = {
|
||||
/** Trigger a new workflow thread (CLI / daemon IPC). */
|
||||
/** Trigger a new workflow thread (Sense-driven launch or CLI / IPC). */
|
||||
startWorkflow: (workflowName: string, launch: WorkflowLaunchParams) => void;
|
||||
/**
|
||||
* Kill a running or queued workflow thread by runId.
|
||||
@@ -58,9 +56,8 @@ export type WorkflowManager = {
|
||||
* Drain active threads for a workflow, then respawn its worker process.
|
||||
* Used for hot reload when bundled workflow output under dist/workflows/<name>/ changes.
|
||||
* Waits up to `drainTimeoutMs` for threads to complete before force-killing.
|
||||
* Pass `null` to use the manager default timeout.
|
||||
*/
|
||||
drainAndRespawn: (workflowName: string, drainTimeoutMs?: number | null) => Promise<void>;
|
||||
drainAndRespawn: (workflowName: string, drainTimeoutMs?: number) => Promise<void>;
|
||||
/**
|
||||
* Schedule a drain+respawn that waits for in-flight runs to finish first.
|
||||
* If no runs are active, drains immediately. Otherwise marks a pending reload
|
||||
@@ -76,7 +73,7 @@ export function createWorkflowManager(
|
||||
initialConfig: NerveConfig,
|
||||
logStore: LogStore,
|
||||
): WorkflowManager {
|
||||
const workerScript = WORKFLOW_WORKER_PATH;
|
||||
const workerScript = resolveWorkflowWorkerScript();
|
||||
|
||||
/**
|
||||
* Default drain timeout must be at least WORKER_SHUTDOWN_TIMEOUT_MS so the worker
|
||||
@@ -312,7 +309,7 @@ export function createWorkflowManager(
|
||||
}
|
||||
|
||||
function handleWorkerMessage(workflowName: string, raw: unknown): void {
|
||||
const result = parseWorkflowChildMessage(raw);
|
||||
const result = parseWorkerMessage(raw);
|
||||
if (!result.ok) {
|
||||
process.stderr.write(
|
||||
`[workflow-manager] invalid message from "${workflowName}" worker: ${result.error.message}\n`,
|
||||
@@ -451,16 +448,13 @@ export function createWorkflowManager(
|
||||
|
||||
async function drainAndRespawn(
|
||||
workflowName: string,
|
||||
drainTimeoutMs: number | null = null,
|
||||
drainTimeoutMs: number = DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
if (!trackedWorkflows.has(workflowName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shutdownMs = Math.max(
|
||||
drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
WORKER_SHUTDOWN_TIMEOUT_MS,
|
||||
);
|
||||
const shutdownMs = Math.max(drainTimeoutMs, WORKER_SHUTDOWN_TIMEOUT_MS);
|
||||
hotReloadEvicting.add(workflowName);
|
||||
try {
|
||||
await runtime.evict(workflowName, { shutdownTimeoutMs: shutdownMs });
|
||||
@@ -14,14 +14,6 @@ import "./experimental-warning-suppression.js";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { isPlainRecord } from "@uncaged/nerve-core";
|
||||
|
||||
import type {
|
||||
ThreadEventType,
|
||||
ThreadWorkflowMessage,
|
||||
WorkflowChildToParentMessage,
|
||||
} from "./ipc.js";
|
||||
import { parseWorkflowParentMessage } from "./ipc.js";
|
||||
import type {
|
||||
RoleMeta,
|
||||
RoleStep,
|
||||
@@ -29,15 +21,22 @@ import type {
|
||||
ThreadContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowMessage,
|
||||
} from "./types.js";
|
||||
import { END, START } from "./types.js";
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END, START, isPlainRecord } from "@uncaged/nerve-core";
|
||||
|
||||
import type {
|
||||
ThreadEventType,
|
||||
ThreadWorkflowMessageMessage,
|
||||
WorkerToParentMessage,
|
||||
} from "./ipc.js";
|
||||
import { parseParentMessage } from "./ipc.js";
|
||||
import { ignoreSessionBroadcastSignals } from "./worker-signals.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function send(msg: WorkflowChildToParentMessage): void {
|
||||
function send(msg: WorkerToParentMessage): void {
|
||||
if (process.send) {
|
||||
process.send(msg);
|
||||
}
|
||||
@@ -56,7 +55,7 @@ function sendWorkflowError(runId: string, error: string, exitCode = 1): void {
|
||||
}
|
||||
|
||||
function sendWorkflowMessage(runId: string, message: WorkflowMessage): void {
|
||||
const msg: ThreadWorkflowMessage = {
|
||||
const msg: ThreadWorkflowMessageMessage = {
|
||||
type: "thread-workflow-message",
|
||||
runId,
|
||||
message: {
|
||||
@@ -335,7 +334,7 @@ function handleMessage(
|
||||
killFlags: Map<string, KillFlag>,
|
||||
shuttingDown: { value: boolean },
|
||||
): void {
|
||||
const parseResult = parseWorkflowParentMessage(raw);
|
||||
const parseResult = parseParentMessage(raw);
|
||||
if (!parseResult.ok) {
|
||||
process.stderr.write(`[workflow-worker] Invalid IPC message: ${parseResult.error.message}\n`);
|
||||
return;
|
||||
@@ -15,7 +15,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/nerve-workflow-utils": "workspace:*",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole, decorateRole, onFail, withDryRun } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { z } from "zod";
|
||||
|
||||
export const committerMetaSchema = z.object({
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/nerve-workflow-utils": "workspace:*",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { z } from "zod";
|
||||
|
||||
export const reviewerMetaSchema = z.object({
|
||||
|
||||
@@ -150,30 +150,46 @@ export async function compute(
|
||||
}
|
||||
```
|
||||
|
||||
**Shell trigger vs Workflow**:Sense `compute` 只能请求 `{ command: string }`,由 worker 执行 shell。要跑 workflow,请在命令里调用 CLI(例如 `nerve workflow trigger <name> ...`)或由外部通过 daemon IPC 触发。
|
||||
**Signal + Workflow 联动**:Signal 和 Workflow 是蕴含关系 — 有 signal 才可能触发 workflow,两者不互斥:
|
||||
|
||||
```typescript
|
||||
// 示例:异常时在 shell 里触发 workflow(需 PATH 中能调用 nerve)
|
||||
export async function compute(state) {
|
||||
export async function compute(db) {
|
||||
const anomaly = detectAnomaly();
|
||||
if (!anomaly) return { state, trigger: null };
|
||||
if (!anomaly) return null;
|
||||
|
||||
return {
|
||||
state: { ...state, lastAlert: Date.now() },
|
||||
trigger: {
|
||||
command:
|
||||
'nerve workflow trigger alert --prompt "CPU 持续高负载" --max-rounds 5',
|
||||
signal: { level: "critical", cpu: anomaly.cpu },
|
||||
workflow: {
|
||||
name: "alert",
|
||||
maxRounds: 5,
|
||||
prompt: "CPU 持续高负载,需要分析",
|
||||
dryRun: false,
|
||||
},
|
||||
};
|
||||
// → 先发 Signal,再启动 alert workflow
|
||||
}
|
||||
```
|
||||
|
||||
**`SenseTrigger` 类型**(`@uncaged/nerve-core`):`{ command: string }`。由 `parseSenseTrigger` 校验(仅允许 `command` 键)。
|
||||
**`WorkflowTrigger` 类型**(定义在 `@uncaged/nerve-core`):
|
||||
|
||||
| `trigger` | 行为 |
|
||||
```typescript
|
||||
type WorkflowTrigger = {
|
||||
name: string; // workflow 名称
|
||||
maxRounds: number; // 最大轮数(>= 1)
|
||||
prompt: string; // 传递给 workflow 的 prompt
|
||||
dryRun: boolean; // 是否 dry-run
|
||||
};
|
||||
```
|
||||
|
||||
**compute 返回值路由规则**(由 `routeSenseComputeOutput()` 决定):
|
||||
|
||||
| 返回值 | 行为 |
|
||||
|--------|------|
|
||||
| `null` | 只持久化 state |
|
||||
| `{ command }` | 持久化 state + worker 执行 shell 命令 |
|
||||
| `null` | 静默,不发 Signal |
|
||||
| `{ signal: T, workflow: null }` | 发出 **Signal**,不触发 Workflow |
|
||||
| `{ signal: T, workflow: WorkflowTrigger }` | 先发 **Signal**,再启动 **Workflow** |
|
||||
| `{ signal: T, workflow: 非法对象 }` | 降级为 signal-only(workflow 被忽略) |
|
||||
| 裸值(无 `signal` 键) | 兼容模式:整个值作为 signal payload,不触发 workflow |
|
||||
|
||||
### Drizzle Schema 与迁移
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/nerve-role-committer": "workspace:*",
|
||||
"@uncaged/nerve-role-reviewer": "workspace:*",
|
||||
"@uncaged/nerve-workflow-utils": "workspace:*",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { createCommitterRole } from "@uncaged/nerve-role-committer";
|
||||
import { createReviewerRole } from "@uncaged/nerve-role-reviewer";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, WorkflowDefinition } from "@uncaged/workflow";
|
||||
|
||||
import { moderator } from "./moderator.js";
|
||||
import type { SenseMeta } from "./moderator.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
import type { Moderator } from "@uncaged/nerve-core";
|
||||
import type { CommitterMeta } from "@uncaged/nerve-role-committer";
|
||||
import type { ReviewerMeta } from "@uncaged/nerve-role-reviewer";
|
||||
import { END } from "@uncaged/workflow";
|
||||
import type { Moderator } from "@uncaged/workflow";
|
||||
import type { CoderMeta } from "./roles/coder.js";
|
||||
import type { PlannerMeta } from "./roles/planner.js";
|
||||
import type { TesterMeta } from "./roles/tester.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { z } from "zod";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { z } from "zod";
|
||||
|
||||
export const plannerMetaSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { z } from "zod";
|
||||
|
||||
export const testerMetaSchema = z.object({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { createCommitterRole } from "@uncaged/nerve-role-committer";
|
||||
import { createReviewerRole } from "@uncaged/nerve-role-reviewer";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, WorkflowDefinition } from "@uncaged/workflow";
|
||||
|
||||
import { moderator } from "./moderator.js";
|
||||
import type { WorkflowMeta } from "./moderator.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
import type { Moderator } from "@uncaged/nerve-core";
|
||||
import type { CommitterMeta } from "@uncaged/nerve-role-committer";
|
||||
import type { ReviewerMeta } from "@uncaged/nerve-role-reviewer";
|
||||
import { END } from "@uncaged/workflow";
|
||||
import type { Moderator } from "@uncaged/workflow";
|
||||
import type { CoderMeta } from "./roles/coder.js";
|
||||
import type { PlannerMeta } from "./roles/planner.js";
|
||||
import type { TesterMeta } from "./roles/tester.js";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { z } from "zod";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { z } from "zod";
|
||||
|
||||
export const plannerMetaSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { z } from "zod";
|
||||
|
||||
export const testerMetaSchema = z.object({
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"@uncaged/nerve-adapter-cursor": "workspace:*",
|
||||
"@uncaged/nerve-adapter-hermes": "workspace:*",
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { START, type ThreadContext } from "@uncaged/workflow";
|
||||
import { START, type ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
import { createLlmAdapter } from "../create-llm-adapter.js";
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
RoleMeta,
|
||||
ThreadContext,
|
||||
WorkflowDefinition,
|
||||
} from "@uncaged/workflow";
|
||||
import { END, START } from "@uncaged/workflow";
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END, START } from "@uncaged/nerve-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Role, ThreadContext } from "@uncaged/workflow";
|
||||
import type { Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
import { START } from "@uncaged/workflow";
|
||||
import { START } from "@uncaged/nerve-core";
|
||||
import { decorateRole, onFail, withDryRun } from "../role-decorators.js";
|
||||
|
||||
type TestMeta = Record<string, unknown> & { ok: boolean };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { START, type ThreadContext } from "@uncaged/workflow";
|
||||
import { START, type ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
import { createCursorRole } from "../role-cursor.js";
|
||||
import { createHermesRole } from "../role-hermes.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
||||
import type { AgentFn, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
import { formatLlmError } from "./shared/format-error.js";
|
||||
import { chatCompletionText } from "./shared/llm-chat.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { extractMetaOrThrow } from "./shared/extract-fn.js";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type CursorAgentMode, cursorAgent } from "@uncaged/nerve-adapter-cursor";
|
||||
import type { SpawnEnv } from "@uncaged/nerve-core";
|
||||
import type { Role } from "@uncaged/workflow";
|
||||
import type { Role, SpawnEnv } from "@uncaged/nerve-core";
|
||||
|
||||
import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js";
|
||||
import { formatLlmError } from "./shared/format-error.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Role, ThreadContext } from "@uncaged/workflow";
|
||||
import type { Role, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decorator types
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Role } from "@uncaged/workflow";
|
||||
import type { Role } from "@uncaged/nerve-core";
|
||||
|
||||
import type { HermesRoleDefaults, HermesRoleRequired } from "./role-types.js";
|
||||
import { formatLlmError } from "./shared/format-error.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Role } from "@uncaged/workflow";
|
||||
import type { Role } from "@uncaged/nerve-core";
|
||||
|
||||
import type { LlmMessage, LlmRoleDefaults, LlmRoleRequired } from "./role-types.js";
|
||||
import { formatLlmError } from "./shared/format-error.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Role } from "@uncaged/workflow";
|
||||
import type { Role } from "@uncaged/nerve-core";
|
||||
|
||||
import type { LlmMessage, ReActRoleDefaults, ReActRoleRequired } from "./role-types.js";
|
||||
import { formatLlmError } from "./shared/format-error.js";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { SpawnEnv } from "@uncaged/nerve-core";
|
||||
import type { ThreadContext } from "@uncaged/workflow";
|
||||
import type { SpawnEnv, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { LlmProvider } from "./shared/llm-extract.js";
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./public-types": {
|
||||
"types": "./dist/public-types.d.ts",
|
||||
"default": "./dist/public-types.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build && pnpm run build:public-types",
|
||||
"build:public-types": "tsc -p tsconfig.public-types.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "^0.5.0",
|
||||
"@uncaged/nerve-store": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rslib/core": "^0.21.3",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
export default defineConfig({
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
dts: true,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
worker: "src/worker.ts",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: "node",
|
||||
cleanDistPath: true,
|
||||
},
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
export type DropOverflowConfig = {
|
||||
concurrency: number;
|
||||
overflow: "drop";
|
||||
};
|
||||
|
||||
export type QueueOverflowConfig = {
|
||||
concurrency: number;
|
||||
overflow: "queue";
|
||||
maxQueue: number;
|
||||
};
|
||||
|
||||
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Patches `process.emit` so `ExperimentalWarning` (e.g. from `node:sqlite`) is not
|
||||
* forwarded to the default handler. Other warning types are unchanged.
|
||||
*
|
||||
* Import this module before any code that loads `node:sqlite`.
|
||||
*/
|
||||
|
||||
const WARNING_EVENT = "warning";
|
||||
const EXPERIMENTAL_WARNING_NAME = "ExperimentalWarning";
|
||||
|
||||
type EmitFn = typeof process.emit;
|
||||
|
||||
const originalEmit = process.emit.bind(process) as EmitFn;
|
||||
|
||||
process.emit = ((event: string | symbol, ...args: unknown[]): boolean => {
|
||||
if (event === WARNING_EVENT) {
|
||||
const w = args[0];
|
||||
if (w instanceof Error && w.name === EXPERIMENTAL_WARNING_NAME) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return Reflect.apply(originalEmit, process, [event, ...args]) as boolean;
|
||||
}) as EmitFn;
|
||||
@@ -1,52 +0,0 @@
|
||||
// @uncaged/workflow — standalone workflow orchestration engine
|
||||
|
||||
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js";
|
||||
export type {
|
||||
WorkflowMessage,
|
||||
RoleResult,
|
||||
Role,
|
||||
RoleMeta,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowContext,
|
||||
AgentFn,
|
||||
RoleStep,
|
||||
ModeratorContext,
|
||||
Moderator,
|
||||
WorkflowDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
export type { DropOverflowConfig, QueueOverflowConfig, WorkflowConfig } from "./config.js";
|
||||
|
||||
export type {
|
||||
KillThreadMessage,
|
||||
ResumeThreadMessage,
|
||||
StartThreadMessage,
|
||||
WorkflowParentToWorkerMessage,
|
||||
WorkflowWorkerShutdownMessage,
|
||||
ThreadEventType,
|
||||
ThreadLifecycleEvent,
|
||||
ThreadEventMessage,
|
||||
WorkflowErrorMessage,
|
||||
ThreadWorkflowMessage,
|
||||
WorkflowWorkerToParentMessage,
|
||||
WorkflowWorkerReadyMessage,
|
||||
WorkflowChildToParentMessage,
|
||||
} from "./ipc.js";
|
||||
export {
|
||||
parseWorkflowParentMessage,
|
||||
parseWorkflowWorkerToParentMessage,
|
||||
parseWorkflowChildMessage,
|
||||
} from "./ipc.js";
|
||||
|
||||
export { WORKFLOW_WORKER_PATH } from "./paths.js";
|
||||
|
||||
export {
|
||||
createWorkerRuntime,
|
||||
formatCapturedStderrTail,
|
||||
formatChildExitSummary,
|
||||
} from "./worker-runtime.js";
|
||||
export type { WorkerDrainOpts, WorkerRuntime, WorkerRuntimeConfig } from "./worker-runtime.js";
|
||||
|
||||
export { createWorkflowManager } from "./manager.js";
|
||||
export type { WorkflowLaunchParams, WorkflowManager } from "./manager.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user