Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8e42c838b | |||
| eb7de9954f | |||
| fc7fc9158c | |||
| be1f86044e | |||
| 5e054facb2 | |||
| e789c7bb34 | |||
| 06b91c2e63 | |||
| b7200ce51c | |||
| 3eab2e29f5 | |||
| 10c4cf4148 | |||
| 5db80c99a0 | |||
| 49f3d91d1b | |||
| 4d75c8683f | |||
| 99b0e58fb6 | |||
| a1b1d5eaf1 | |||
| 1849789c02 | |||
| 7ce46e7735 | |||
| 0455f928f5 | |||
| 11cedfb5a5 | |||
| dc4454d23e |
+18
-20
@@ -9,7 +9,7 @@ alwaysApply: true
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Reflex → Workflow → Log
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
↑ ↑
|
||||
"what to observe" "what to do"
|
||||
```
|
||||
@@ -20,19 +20,17 @@ External World → Sense → Signal → Reflex → Workflow → Log
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **Sense** | A `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
|
||||
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
|
||||
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
|
||||
| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling (`interval`, `on`) is configured per sense in nerve.yaml. |
|
||||
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
|
||||
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. |
|
||||
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
|
||||
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger senses or workflows** — prevents feedback loops. |
|
||||
| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. |
|
||||
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
|
||||
|
||||
### Architecture Rules
|
||||
|
||||
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
|
||||
- **Two orthogonal extension points**: Sense (what to observe + when), Workflow (what to do)
|
||||
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
|
||||
- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain.
|
||||
- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
|
||||
|
||||
|
||||
|
||||
@@ -44,18 +42,18 @@ Use `function` + `type`, not `class` + `interface`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type Signal = {
|
||||
senseId: string;
|
||||
value: unknown;
|
||||
type WorkflowLaunch = {
|
||||
senseName: string;
|
||||
workflowName: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function createSignal(senseId: string, value: unknown): Signal {
|
||||
return { senseId, value, ts: Date.now() };
|
||||
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
|
||||
return { senseName, workflowName, ts: Date.now() };
|
||||
}
|
||||
|
||||
// ❌ Bad — no class, no interface
|
||||
class Signal implements ISignal { ... }
|
||||
class WorkflowLaunch implements IWorkflowLaunch { ... }
|
||||
```
|
||||
|
||||
### Rules
|
||||
@@ -100,9 +98,9 @@ For mutually exclusive fields, use discriminated unions:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type ReflexConfig =
|
||||
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
|
||||
| { kind: "workflow"; workflow: string; on: string[] | null };
|
||||
type WorkflowConfig =
|
||||
| { concurrency: number; overflow: "drop" }
|
||||
| { concurrency: number; overflow: "queue"; maxQueue: number };
|
||||
```
|
||||
|
||||
## Modules & Exports
|
||||
@@ -123,9 +121,9 @@ export default function startEngine() { ... }
|
||||
|
||||
| Type | Style | Example |
|
||||
|------|-------|---------|
|
||||
| Files | kebab-case | `signal-bus.ts` |
|
||||
| Types | PascalCase | `SignalBus` |
|
||||
| Functions/variables | camelCase | `createSignalBus` |
|
||||
| Files | kebab-case | `sense-scheduler.ts` |
|
||||
| Types | PascalCase | `SenseScheduler` |
|
||||
| Functions/variables | camelCase | `createSenseScheduler` |
|
||||
| Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` |
|
||||
| Generics | Single letter or descriptive | `T`, `TValue` |
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Reflex → Workflow → Log
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
↑ ↑
|
||||
"what to observe" "what to do"
|
||||
```
|
||||
@@ -14,19 +14,17 @@ External World → Sense → Signal → Reflex → Workflow → Log
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **Sense** | A `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
|
||||
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
|
||||
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
|
||||
| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling (`interval`, `on`) is configured per sense in nerve.yaml. |
|
||||
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
|
||||
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. |
|
||||
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
|
||||
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger senses or workflows** — prevents feedback loops. |
|
||||
| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. |
|
||||
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
|
||||
|
||||
### Architecture Rules
|
||||
|
||||
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
|
||||
- **Two orthogonal extension points**: Sense (what to observe + when), Workflow (what to do)
|
||||
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
|
||||
- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain.
|
||||
- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
|
||||
|
||||
|
||||
|
||||
@@ -38,18 +36,18 @@ Use `function` + `type`, not `class` + `interface`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type Signal = {
|
||||
senseId: string;
|
||||
value: unknown;
|
||||
type WorkflowLaunch = {
|
||||
senseName: string;
|
||||
workflowName: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function createSignal(senseId: string, value: unknown): Signal {
|
||||
return { senseId, value, ts: Date.now() };
|
||||
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
|
||||
return { senseName, workflowName, ts: Date.now() };
|
||||
}
|
||||
|
||||
// ❌ Bad — no class, no interface
|
||||
class Signal implements ISignal { ... }
|
||||
class WorkflowLaunch implements IWorkflowLaunch { ... }
|
||||
```
|
||||
|
||||
### Rules
|
||||
@@ -94,9 +92,9 @@ For mutually exclusive fields, use discriminated unions:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type ReflexConfig =
|
||||
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
|
||||
| { kind: "workflow"; workflow: string; on: string[] | null };
|
||||
type WorkflowConfig =
|
||||
| { concurrency: number; overflow: "drop" }
|
||||
| { concurrency: number; overflow: "queue"; maxQueue: number };
|
||||
```
|
||||
|
||||
## Modules & Exports
|
||||
@@ -108,9 +106,9 @@ type ReflexConfig =
|
||||
|
||||
| Type | Style | Example |
|
||||
|------|-------|---------|
|
||||
| Files | kebab-case | `signal-bus.ts` |
|
||||
| Types | PascalCase | `SignalBus` |
|
||||
| Functions/variables | camelCase | `createSignalBus` |
|
||||
| Files | kebab-case | `sense-scheduler.ts` |
|
||||
| Types | PascalCase | `SenseScheduler` |
|
||||
| Functions/variables | camelCase | `createSenseScheduler` |
|
||||
| Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` |
|
||||
| Generics | Single letter or descriptive | `T`, `TValue` |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,14 +25,11 @@ Senses own both the "what" (compute logic) and the "when" (config-driven schedul
|
||||
- One worker per Workflow type (on-demand)
|
||||
- Workers never talk to each other
|
||||
- All user code runs in isolated Workers; kernel never loads user code directly
|
||||
- **`WorkerRuntime`** (`packages/daemon/src/worker-runtime.ts`) centralizes fork lifecycle for both sense groups (`worker-pool.ts`) and workflow types (`workflow-manager.ts`); see `.knowledge/worker-isolation.md`
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -5,7 +5,7 @@ nerve/
|
||||
packages/
|
||||
core/ # @uncaged/nerve-core — shared types, config parser, Result, spawn-safe
|
||||
cli/ # @uncaged/nerve-cli — CLI (init, validate, dev, daemon, knowledge)
|
||||
daemon/ # @uncaged/nerve-daemon — kernel, workers, signal bus, scheduler
|
||||
daemon/ # @uncaged/nerve-daemon — kernel, workers, sense scheduler, workflow manager
|
||||
store/ # @uncaged/nerve-store — append-only log, SQLite, CAS blob store
|
||||
workflow-utils/ # @uncaged/nerve-workflow-utils — role factories, extract, LLM helpers
|
||||
adapter-cursor/ # @uncaged/nerve-adapter-cursor — cursor-agent CLI adapter
|
||||
|
||||
+30
-24
@@ -1,33 +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:**
|
||||
- `ComputeResult<T>` = `null | { signal: T; workflow: WorkflowTrigger | null }`
|
||||
- `null` → silent, no storage, no signal
|
||||
- `{ signal: data, workflow: null }` → persist + emit signal
|
||||
- `{ signal, workflow: WorkflowTrigger }` → persist + emit signal + trigger workflow
|
||||
- Any other value → treated as `{ signal: value, workflow: null }`
|
||||
**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:**
|
||||
- Exceptions caught by worker, logged as errors (no signal emitted)
|
||||
- Signal payload must be JSON-serializable (passed via IPC)
|
||||
- Invalid workflow triggers silently dropped (signal still emitted)
|
||||
**Error Handling:**
|
||||
- Exceptions caught by worker, logged as errors (state unchanged)
|
||||
- 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)
|
||||
@@ -46,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,91 +1,31 @@
|
||||
# Signal Routing
|
||||
# Sense compute → workflow (RFC #308)
|
||||
|
||||
Signal routing is the core mechanism that determines how Sense outputs flow through the Nerve system.
|
||||
Stateful senses no longer emit signals or pass outputs through `routeSenseComputeOutput`. The worker runs `compute(state)` and returns `{ state, workflow }`.
|
||||
|
||||
## Routing Logic
|
||||
|
||||
When a Sense `compute()` function returns non-null, the output goes through `routeSenseComputeOutput()` in `packages/core/src/sense-workflow-directive.ts`:
|
||||
## Flow
|
||||
|
||||
```
|
||||
Sense compute() → non-null → routeSenseComputeOutput() → { signal, workflow }
|
||||
↓
|
||||
kernel.ts → signal ALWAYS emitted + optional workflow start
|
||||
Sense worker: compute(state) → { state, workflow }
|
||||
↓
|
||||
persist state JSON (data/senses/<name>.json)
|
||||
↓
|
||||
IPC compute-result → kernel
|
||||
↓
|
||||
workflow !== null → parseWorkflowTrigger (validation) → workflowManager.startWorkflow
|
||||
scheduler.onSenseCompleted(senseName) → dependents with `on: [senseName]`
|
||||
```
|
||||
|
||||
## Two Output Formats
|
||||
## Workflow trigger shape
|
||||
|
||||
### 1. Explicit Format
|
||||
```typescript
|
||||
{
|
||||
signal: any, // emitted as signal
|
||||
workflow: { // optional workflow trigger
|
||||
name: string,
|
||||
maxRounds: number,
|
||||
prompt: string,
|
||||
dryRun: boolean
|
||||
} | null
|
||||
}
|
||||
```
|
||||
When `workflow` is non-null it must be a plain object validated by `parseWorkflowTrigger()` in `packages/core/src/sense.ts`:
|
||||
|
||||
### 2. Shorthand Format
|
||||
Any other value is treated as:
|
||||
```typescript
|
||||
{ signal: payload, workflow: null }
|
||||
```
|
||||
- `name`: non-empty string
|
||||
- `maxRounds`: integer ≥ 1
|
||||
- `prompt`: string
|
||||
- `dryRun`: boolean
|
||||
|
||||
## Workflow Directive Parsing
|
||||
Invalid triggers are rejected by the daemon when handling the worker message (workflow is not started).
|
||||
|
||||
## Concrete Routing Predicates
|
||||
## Scheduling
|
||||
|
||||
The routing decision is implemented in `routeSenseComputeOutput()` using these exact matching criteria:
|
||||
|
||||
### 1. Explicit Format Detection
|
||||
```typescript
|
||||
if (isPlainRecord(payload) && Object.hasOwn(payload, "signal"))
|
||||
```
|
||||
- Payload must be a plain object
|
||||
- Must have `signal` property (any value)
|
||||
- Workflow extracted from `workflow` property or defaults to null
|
||||
|
||||
### 2. Workflow Validation
|
||||
When workflow is non-null, it's validated via `parseWorkflowTrigger()`:
|
||||
- `name`: non-empty string (trimmed)
|
||||
- `maxRounds`: positive integer >= 1
|
||||
- `prompt`: string
|
||||
- `dryRun`: boolean
|
||||
|
||||
**Critical behavior**: Invalid workflows are silently dropped (become null) but signal emission continues. This prevents malformed workflow config from blocking signals.
|
||||
|
||||
### 3. Fallback to Shorthand
|
||||
Any value that doesn't match explicit format becomes:
|
||||
```typescript
|
||||
{ signal: payload, workflow: null }
|
||||
```
|
||||
|
||||
## Processing Flow
|
||||
|
||||
```typescript
|
||||
// In kernel.ts handleSenseWorkerSignal()
|
||||
const { signal: signalPayload, workflow } = routeResult.value;
|
||||
|
||||
// Signal is ALWAYS emitted when compute returns non-null
|
||||
bus.emit({ id, senseId, payload: signalPayload, timestamp });
|
||||
|
||||
// Workflow is started ONLY if workflow is non-null
|
||||
if (workflow !== null) {
|
||||
workflowManager.startWorkflow(workflow.name, { ... });
|
||||
}
|
||||
```
|
||||
|
||||
## Legacy String Format (Deprecated)
|
||||
|
||||
The old `"name|maxRounds|prompt"` string format is converted to the structured format internally but should not be used in new code.
|
||||
|
||||
## Key Behaviors
|
||||
|
||||
1. **Signal priority**: Every non-null compute result emits a signal, regardless of workflow
|
||||
2. **Additive behavior**: Valid workflow triggers are executed in addition to signal emission
|
||||
3. **Failure tolerance**: Invalid workflow directives are silently ignored, signal still emits
|
||||
4. **Structure-based routing**: No complex predicates - simply checks object structure and property existence
|
||||
|
||||
This routing mechanism ensures clean separation between perception (signals) and action (workflows) while maintaining backward compatibility.
|
||||
Other senses list this sense under `on` in `nerve.yaml` to be scheduled when this sense completes a successful compute (see sense scheduler reverse-index in the daemon).
|
||||
|
||||
+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
|
||||
|
||||
@@ -12,13 +12,19 @@ Kernel (Main Process)
|
||||
└── Workflow Worker (review) ── review workflow instances
|
||||
```
|
||||
|
||||
### WorkerRuntime (RFC-006)
|
||||
|
||||
Forked worker processes are managed by **`WorkerRuntime`** (`worker-runtime.ts`): one Node child per logical key, cold start, optional respawn after crash, drain/evict, and coordinated shutdown over IPC. **`worker-pool.ts`** (sense groups) and **`workflow-manager.ts`** (workflow types) both configure and delegate to `createWorkerRuntime` instead of owning ad-hoc fork logic.
|
||||
|
||||
Worker **entrypoints** (`sense-worker.ts`, `workflow-worker.ts`) import lightweight helpers only — e.g. `worker-signals.ts` for session broadcast signal handling — so they do not pull in the parent-side runtime module.
|
||||
|
||||
## Isolation Boundaries
|
||||
|
||||
### 1. Sense Workers
|
||||
- **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)
|
||||
@@ -53,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
|
||||
@@ -111,10 +117,10 @@ workflows:
|
||||
### Process Management
|
||||
|
||||
#### Signal Handling
|
||||
Workers ignore session broadcast signals (SIGINT/SIGTERM):
|
||||
Workers ignore session broadcast signals (SIGINT/SIGTERM) via `ignoreSessionBroadcastSignals()` in `worker-signals.ts`:
|
||||
```typescript
|
||||
// Workers ignore terminal signals; kernel coordinates shutdown
|
||||
process.on("SIGINT", () => {});
|
||||
process.on("SIGINT", () => {});
|
||||
process.on("SIGTERM", () => {});
|
||||
```
|
||||
|
||||
@@ -122,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
|
||||
@@ -132,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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Workflow → Log
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
↑ ↑
|
||||
"what to observe" "what to do"
|
||||
```
|
||||
@@ -14,21 +14,33 @@ External World → Sense → Signal → Workflow → Log
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **Sense** | A `compute()` function that samples or derives data. Returns `ComputeResult<T>` — non-null emits a Signal (and optionally triggers a Workflow), null is silent. Each Sense has its own SQLite database. Scheduling (interval, on) is configured in nerve.yaml. |
|
||||
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
|
||||
| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling configured in nerve.yaml. |
|
||||
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
|
||||
| **Log** | Immutable audit trail. Records executions, state transitions, errors. Cannot trigger Senses — prevents feedback loops. |
|
||||
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
|
||||
| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. |
|
||||
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
|
||||
|
||||
### Architecture Rules
|
||||
|
||||
- **Two extension points**: Sense (what to observe + when), Workflow (what to do)
|
||||
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
|
||||
- **Causality is one-directional**: External world → Sense → Signal → Workflow + Log. Logs are the end of the chain.
|
||||
- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
|
||||
|
||||
|
||||
|
||||
### Sense State Persistence
|
||||
|
||||
Each sense's state is persisted as a JSON file at `data/senses/<name>.json` (relative to the nerve root, typically `~/.uncaged-nerve/`).
|
||||
|
||||
| Event | Behavior |
|
||||
|-------|----------|
|
||||
| **Worker start** | Read `state.json`; if missing or corrupt, use `initialState` from the sense module |
|
||||
| **Compute success** | Write new state atomically (write-temp + rename), then update in-memory state |
|
||||
| **Compute failure** | State unchanged (both disk and memory) |
|
||||
| **Daemon restart** | State restored from last successful write |
|
||||
|
||||
State files are written atomically (temp file + rename) to prevent corruption on crash.
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
### Functional-first
|
||||
@@ -37,18 +49,18 @@ Use `function` + `type`, not `class` + `interface`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type Signal = {
|
||||
senseId: string;
|
||||
value: unknown;
|
||||
type WorkflowLaunch = {
|
||||
senseName: string;
|
||||
workflowName: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function createSignal(senseId: string, value: unknown): Signal {
|
||||
return { senseId, value, ts: Date.now() };
|
||||
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
|
||||
return { senseName, workflowName, ts: Date.now() };
|
||||
}
|
||||
|
||||
// ❌ Bad — no class, no interface
|
||||
class Signal implements ISignal { ... }
|
||||
class WorkflowLaunch implements IWorkflowLaunch { ... }
|
||||
```
|
||||
|
||||
### Rules
|
||||
@@ -92,12 +104,20 @@ type SenseConfig = {
|
||||
For mutually exclusive fields, use discriminated unions:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type ComputeResult<T> =
|
||||
| null
|
||||
| { signal: T; workflow: WorkflowTrigger | null };
|
||||
// ✅ Good — sense modules return explicit next state + optional workflow trigger
|
||||
type SenseComputeReturn<S> = {
|
||||
state: S;
|
||||
workflow: WorkflowTrigger | null;
|
||||
};
|
||||
```
|
||||
|
||||
### Workflow Naming
|
||||
|
||||
Workflow identifiers — `WorkflowDefinition.name`, the directory under `workflows/`, and keys under `workflows:` in `nerve.yaml` — must use **verb-first** kebab-case phrases so the name reads as an action.
|
||||
|
||||
- ✅ `solve-issue`, `extract-knowledge`, `develop-sense`
|
||||
- ❌ `knowledge-extraction`, `issue-solver`
|
||||
|
||||
### Workflow authoring (user modules)
|
||||
|
||||
Roles and moderators take **ThreadContext** (`threadId`, `start`, `steps`) — not separate `StartStep` / message arrays.
|
||||
@@ -132,9 +152,9 @@ const workflow: WorkflowDefinition<Record<"planner", MyMeta>> = {
|
||||
|
||||
| Type | Style | Example |
|
||||
|------|-------|---------|
|
||||
| Files | kebab-case | `signal-bus.ts` |
|
||||
| Types | PascalCase | `SignalBus` |
|
||||
| Functions/variables | camelCase | `createSignalBus` |
|
||||
| Files | kebab-case | `sense-scheduler.ts` |
|
||||
| Types | PascalCase | `SenseScheduler` |
|
||||
| Functions/variables | camelCase | `createSenseScheduler` |
|
||||
| Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` |
|
||||
| Generics | Single letter or descriptive | `T`, `TValue` |
|
||||
|
||||
|
||||
@@ -2,35 +2,34 @@
|
||||
|
||||
**Observation engine for autonomous agents** — sense the world, react to changes, run workflows.
|
||||
|
||||
Nerve is a lightweight daemon that continuously observes external state through **Senses**, reacts via declarative **Reflexes**, and orchestrates multi-step **Workflows**. Built for the [Uncaged](https://github.com/uncaged) agent framework.
|
||||
Nerve is a lightweight daemon that continuously observes external state through **Senses** (stateful `compute(state)` + JSON persistence), schedules them via **`interval` / `on`** in `nerve.yaml`, and orchestrates multi-step **Workflows**. Built for the [Uncaged](https://github.com/uncaged) agent framework.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense ─┬→ Signal → Reflex → Sense (scheduled compute)
|
||||
│
|
||||
└→ Workflow (Sense return with workflow directive) → Log
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
↑
|
||||
scheduling: interval / on (per sense in nerve.yaml)
|
||||
```
|
||||
|
||||
| Concept | Metaphor | Role |
|
||||
|---------|----------|------|
|
||||
| **Sense** | 👁️ Perception | A `compute()` function that samples or derives data. Each sense has its own SQLite database. |
|
||||
| **Reflex** | ⚡ Reaction | Declarative rules that **only schedule Sense computes** (interval and/or `on` signal names). Reflex YAML cannot reference workflows. |
|
||||
| **Signal** | 📡 Notification | Emitted when a sense returns a non-null value that is routed as a normal signal (see Sense → Workflow below). Other reflexes can listen via `on`. |
|
||||
| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started from a Sense return value or from CLI/daemon IPC—not from reflex YAML. |
|
||||
| **Log** | 📝 Record | Immutable audit trail. Queryable by senses, but **cannot** trigger reflexes (prevents feedback loops). |
|
||||
| **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 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 → Workflow:** if `compute()` returns a plain object with a string field `workflow` in the form `name|maxRounds|prompt` (only the first two `|` delimit name and rounds; the rest is the prompt), the engine starts that workflow and **does not** emit a Signal for that return. `workflow: null` or `""` means “emit a signal” and strip the key from the payload. Invalid `workflow` strings are treated like a normal signal (directive stripped). See `@uncaged/nerve-core` `routeSenseComputeOutput` / `parseSenseWorkflowDirective`.
|
||||
**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.
|
||||
|
||||
Three extension points for **what / when / multi-step action** — reflexes never replace Sense-driven workflow launches.
|
||||
Two extension points for **what to observe (+ when)** vs **multi-step action** — scheduling is declarative config on each sense, not a separate YAML section.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, Sense→workflow routing, 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, workers, signal bus, sense scheduler, workflow manager, file watcher, IPC |
|
||||
| [`@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 |
|
||||
|
||||
## Quick Start
|
||||
@@ -43,24 +42,28 @@ pnpm add -g @uncaged/nerve-cli
|
||||
mkdir my-agent && cd my-agent
|
||||
nerve init
|
||||
|
||||
# Write a sense
|
||||
cat > senses/cpu-usage/compute.ts << 'EOF'
|
||||
export async function compute() {
|
||||
const [load] = (await import("node:os")).loadavg();
|
||||
return load > 2.0 ? { load } : null; // signal only when load is high
|
||||
# Write a sense (see `nerve init` for the full template)
|
||||
mkdir -p senses/cpu-usage/src
|
||||
cat > senses/cpu-usage/src/index.ts << 'EOF'
|
||||
import { loadavg } from "node:os";
|
||||
|
||||
type CpuState = { lastLoad: number };
|
||||
|
||||
export const initialState: CpuState = { lastLoad: 0 };
|
||||
|
||||
export async function compute(state: CpuState) {
|
||||
const [oneMin] = loadavg();
|
||||
const lastLoad = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
|
||||
return { state: { lastLoad }, workflow: null };
|
||||
}
|
||||
EOF
|
||||
|
||||
# Configure reflexes in nerve.yaml
|
||||
# Configure scheduling on each sense in nerve.yaml
|
||||
cat > nerve.yaml << 'EOF'
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system
|
||||
throttle: 10s
|
||||
|
||||
reflexes:
|
||||
- kind: sense
|
||||
sense: cpu-usage
|
||||
interval: 30s
|
||||
EOF
|
||||
|
||||
@@ -73,7 +76,7 @@ nerve logs # view logs
|
||||
|
||||
## Configuration
|
||||
|
||||
`nerve.yaml` declares senses, reflexes (sense-only), optional workflows (concurrency), and optional engine `max_rounds`:
|
||||
`nerve.yaml` declares senses (each with optional `interval` / `on`), optional workflows (concurrency), and optional engine `max_rounds`. Top-level `reflexes` is **not** supported — use `interval` and `on` on each sense.
|
||||
|
||||
```yaml
|
||||
max_rounds: 100 # default moderator cap (e.g. CLI workflow trigger)
|
||||
@@ -84,12 +87,15 @@ senses:
|
||||
throttle: 10s # min interval between computes
|
||||
timeout: 30s # max compute duration
|
||||
grace_period: 5s # wait before first compute after startup
|
||||
|
||||
reflexes:
|
||||
- kind: sense
|
||||
sense: cpu-usage
|
||||
interval: 30s # periodic trigger
|
||||
on: [disk-pressure] # also trigger on signals from other senses
|
||||
|
||||
derived-example:
|
||||
group: system
|
||||
throttle: null
|
||||
timeout: 30s
|
||||
grace_period: null
|
||||
on:
|
||||
- cpu-usage # run after cpu-usage completes a compute
|
||||
|
||||
workflows:
|
||||
cleanup:
|
||||
@@ -101,17 +107,24 @@ workflows:
|
||||
max_queue: 20
|
||||
```
|
||||
|
||||
YAML must **not** include `workflow:` under `reflexes` — the parser rejects it. Declare workflows under `workflows:` and start them from Sense `compute()` or `nerve workflow trigger`.
|
||||
Declare workflows under `workflows:` and start them from Sense `compute()` (non-null `workflow`) or `nerve workflow trigger`.
|
||||
|
||||
**Example — Sense starts a workflow** (`senses/disk-pressure/compute.ts`):
|
||||
**Example — Sense starts a workflow** (`senses/disk-pressure/src/index.ts`):
|
||||
|
||||
```typescript
|
||||
export async function compute() {
|
||||
export const initialState = { checked: false };
|
||||
|
||||
export async function compute(state: typeof initialState) {
|
||||
const full = await diskNearlyFull();
|
||||
if (!full) return null;
|
||||
if (!full) return { state: { ...state, checked: true }, workflow: null };
|
||||
return {
|
||||
path: "/data",
|
||||
workflow: "cleanup|10|Disk partition nearly full", // name|maxRounds|prompt
|
||||
state: { ...state, checked: true },
|
||||
workflow: {
|
||||
name: "cleanup",
|
||||
maxRounds: 10,
|
||||
prompt: "Disk partition nearly full",
|
||||
dryRun: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -138,12 +151,9 @@ export async function compute() {
|
||||
│ │ └──────────────┼──────────────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────┐ │
|
||||
│ │ │ Signal Bus │ │
|
||||
│ │ │Sense Scheduler │
|
||||
│ │ │(interval + on) │
|
||||
│ │ └──────┬───────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────────┐ │
|
||||
│ │ │ Reflex Scheduler│ │
|
||||
│ │ └────────┬─────────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌───────────────────┐ │
|
||||
│ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │
|
||||
@@ -152,8 +162,7 @@ export async function compute() {
|
||||
```
|
||||
|
||||
- **Worker pool** — one child process per sense group; isolation between groups.
|
||||
- **Signal Bus** — in-memory pub/sub for signal distribution.
|
||||
- **Reflex Scheduler** — interval timers + signal subscriptions, with throttle/coalesce.
|
||||
- **Sense scheduler** — interval timers + `on` reverse-index (upstream sense → dependents), with throttle/coalesce.
|
||||
- **Workflow Manager** — concurrency (drop/queue), per-workflow workers, crash recovery.
|
||||
- **File watcher** — hot reload for config, sense modules, and workflow modules.
|
||||
- **Daemon IPC** — Unix domain socket; used by the CLI when the daemon is running.
|
||||
@@ -161,8 +170,8 @@ export async function compute() {
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Zero native addons** — uses Node.js built-in `node:sqlite` (DatabaseSync)
|
||||
- **Drizzle ORM** v1.0 for sense databases
|
||||
- **Zero native addons** — uses Node.js built-in `node:sqlite` (DatabaseSync) for logs / workflow persistence via `@uncaged/nerve-store`
|
||||
- **Sense state as JSON** — files under `data/senses/` written by sense workers
|
||||
- **rslib** (rspack) for building
|
||||
- **Biome** for formatting/linting
|
||||
- **Vitest** for testing
|
||||
@@ -180,7 +189,7 @@ pnpm -r test # run all tests
|
||||
|
||||
## Design Documents
|
||||
|
||||
- [RFC-001: Observation Engine](./docs/rfc-001-observation-engine.md) — Sense, Signal, Reflex model
|
||||
- [RFC-001: Observation Engine](./docs/rfc-001-observation-engine.md) — historical sense / scheduling model (superseded in places by stateful senses — see `CLAUDE.md`)
|
||||
- [RFC-002: Workflow Engine](./docs/rfc-002-workflow-engine.md) — Stateful workflow execution
|
||||
- [Coding Conventions](./docs/coding-conventions.md)
|
||||
|
||||
|
||||
+26
-23
@@ -8,25 +8,29 @@
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type Signal = {
|
||||
senseId: string
|
||||
value: unknown
|
||||
type WorkflowLaunch = {
|
||||
senseName: string
|
||||
workflowName: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
function createSignal(senseId: string, value: unknown): Signal {
|
||||
return { senseId, value, ts: Date.now() }
|
||||
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
|
||||
return { senseName, workflowName, ts: Date.now() }
|
||||
}
|
||||
|
||||
// ❌ Bad
|
||||
interface ISignal {
|
||||
senseId: string
|
||||
value: unknown
|
||||
interface IWorkflowLaunch {
|
||||
senseName: string
|
||||
workflowName: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
class Signal implements ISignal {
|
||||
constructor(public senseId: string, public value: unknown, public ts: number) {}
|
||||
class WorkflowLaunch implements IWorkflowLaunch {
|
||||
constructor(
|
||||
public senseName: string,
|
||||
public workflowName: string,
|
||||
public ts: number,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -65,17 +69,16 @@ type SenseConfig = {
|
||||
当多个字段互斥时,用 discriminated union 代替一堆 optional:
|
||||
|
||||
```typescript
|
||||
// ✅ Good — 编译器保证 sense 和 workflow 不会同时出现
|
||||
type ReflexConfig =
|
||||
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
|
||||
| { kind: "workflow"; workflow: string; on: string[] | null }
|
||||
// ✅ Good — 编译器保证两种 overflow 形态互斥且字段完整
|
||||
type WorkflowConfig =
|
||||
| { concurrency: number; overflow: "drop" }
|
||||
| { concurrency: number; overflow: "queue"; maxQueue: number }
|
||||
|
||||
// ❌ Bad — sense 和 workflow 都 optional,运行时才知道到底填了哪个
|
||||
type ReflexConfig = {
|
||||
sense?: string;
|
||||
workflow?: string;
|
||||
interval?: string;
|
||||
on?: string[];
|
||||
// ❌ Bad — 字段一堆 optional,运行时才知道到底填了哪种并发策略
|
||||
type WorkflowConfig = {
|
||||
concurrency?: number;
|
||||
overflow?: string;
|
||||
maxQueue?: number;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -103,9 +106,9 @@ export default function startEngine() { ... }
|
||||
|
||||
| 类型 | 风格 | 示例 |
|
||||
|------|------|------|
|
||||
| 文件 | kebab-case | `signal-bus.ts` |
|
||||
| 类型 | PascalCase | `SignalBus` |
|
||||
| 函数/变量 | camelCase | `createSignalBus` |
|
||||
| 文件 | kebab-case | `sense-scheduler.ts` |
|
||||
| 类型 | PascalCase | `SenseScheduler` |
|
||||
| 函数/变量 | camelCase | `createSenseScheduler` |
|
||||
| 常量 | UPPER_SNAKE | `MAX_RETRY_COUNT` |
|
||||
| 泛型参数 | 单字母或描述性 | `T`, `TValue` |
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Nerve 单仓死代码分析报告
|
||||
|
||||
**分析日期**: 2026-04-30
|
||||
**范围**: `packages/core`, `daemon`, `store`, `cli`, `khala`, `workflow-utils`, `workflow-meta`, `adapter-cursor`, `adapter-hermes` 的 TypeScript 源码与 `package.json` 依赖。
|
||||
**方法**: 全仓 `ripgrep` 交叉验证 `import` / `export` 路径;未运行 Knip/TS 死代码专项工具。
|
||||
**说明**: 未包含 `role-reviewer` / `role-committer` / `skills` 等包(你列出的范围外),但 `workflow-meta` 对其中部分有依赖,相关结论已注明。
|
||||
|
||||
---
|
||||
|
||||
## 方法限制(读前必读)
|
||||
|
||||
| 限制 | 影响 |
|
||||
|------|------|
|
||||
| **动态 `import(url)`** | `cli` 的 `loadDaemonModule` 在运行时加载 `@uncaged/nerve-daemon`,静态分析无法把 `createKernel` 等映射到具体导出。 |
|
||||
| **已发布包的公共 API** | 大量导出仅被仓外消费者使用;本报告中的「仓内未引用」≠「应删除」。 |
|
||||
| **构建入口** | Rslib 多入口(如 `sense-worker`、`workflow-worker`、`daemon-bootstrap`)视为存活入口,不作为孤儿文件。 |
|
||||
| **内部未导出函数** | 未做逐函数调用图;「死函数」仅列出高置信个例。 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 未使用导出(仓内无 `import`)
|
||||
|
||||
以下符号在 **monorepo 内** 没有任何文件从 `@uncaged/nerve-core` / `@uncaged/nerve-workflow-utils` / `@uncaged/nerve-daemon` 等包根导出处引用(测试与定义自身除外)。
|
||||
|
||||
### 1.1 `@uncaged/nerve-core`
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/core/src/index.ts` → `agent.js` | `KNOWN_AGENT_ADAPTER_IDS` | **高** | **调查**:若 CLI/校验应约束 adapter id,可接入;否则可从公共 API 移除或保留作文档性常量并注明。 |
|
||||
| `packages/core/src/index.ts` → `sense.js` | `labelSenseTrigger`(`senseTriggerLabels` 在 `cli`/`daemon` 有使用) | **高(相对仓内)** | **保留或收敛导出**:仅 `senseTriggerLabels` 对外即可;`labelSenseTrigger` 可作为内部函数若不需要单独暴露。 |
|
||||
| `packages/core/src/index.ts` → `util.js` | `parseDurationStringToMs`(仅 `config.ts` 内部使用) | **高** | **调查**:无需在 `index` 再导出则改为非导出或移除 re-export,减少 API 面。 |
|
||||
| `packages/core/src/index.ts` → `sense.js` | 类型 `SenseModule` | **中** | **保留**:用户文档/外部 Sense 模块常用;仓内未按名引用属正常。 |
|
||||
| `packages/core/src/index.ts` → `config.js` | 单独导出的类型 `NerveApiConfig`(仅 `config.ts` 与 `index` 出现) | **低** | **保留**:通常随 `NerveConfig` 一并使用;单独 export 多为 TS 公共类型便利。 |
|
||||
|
||||
### 1.2 `@uncaged/nerve-workflow-utils`
|
||||
|
||||
下列项在 **`packages/*/src/**/*.ts` 中无人从包入口导入**(`workflow-meta`、`role-committer` 等主要只用 `createRole`、`decorateRole`、`onFail`、`withDryRun`、`LlmExtractorConfig`)。
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/workflow-utils/src/index.ts` | `createLlmAdapter` | **高** | **保留(对外 API)** 或标注文档;仓内无调用。 |
|
||||
| 同上 | `llmExtract`、`llmExtractWithRetry` | **高** | **保留**:高级用法 / 文档示例;内部与测试使用。 |
|
||||
| 同上 | `mergeExtractConfig`、`ExtractConfigLayer` | **高** | **调查**:若 RFC 分层配置仍在推进则保留;否则评估移除导出。 |
|
||||
| 同上 | `assertZodMetaSchemas`、`createLlmExtractFn`、`extractMetaOrThrow`、`zodMeta`、`ZodMetaSchema` | **高** | **保留(对外 API)**;当前仅 `workflow-utils` 测试与内部 `create-role` 链路使用 `extractMetaOrThrow`。 |
|
||||
| 同上 | `readNerveYaml`、`nerveAgentContext` 及相关类型 | **高** | **调查**:若已无 YAML 上下文注入场景可删导出;否则保留给 Agent 工具链。 |
|
||||
| 同上 | `spawnSafe`、`nerveCommandEnv` 及 `Spawn*` 类型(从 core 再导出) | **高** | **删除再导出或改为文档链接**:仓内均直接从 `@uncaged/nerve-core` 引用 spawn。 |
|
||||
| 同上 | `isDryRun` | **高** | **删除或实现**:见 §3「死函数」。 |
|
||||
| 同上 | `LlmMessage`、`MetaExtractConfig`、`LlmChatError`、`LlmError`、`LlmProvider` 等类型再导出 | **中** | **保留**:供外部精细类型标注;仓内未单独 import。 |
|
||||
|
||||
### 1.3 `@uncaged/nerve-daemon`
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/daemon/src/index.ts` → `agent-adapters/echo.js` | `createEchoAgent` | **高** | **调查**:无任何测试或运行时引用;若设计保留 `type: "echo"` 适配器,应在 workflow/agent 装载路径接线或删除导出与文件。 |
|
||||
| `packages/daemon/src/index.ts`(及其它导出) | 多数 IPC / runtime 符号 | **低** | **保留**:动态加载与外部集成无法静态判定;仅 `createEchoAgent` 可确定为当前静态图下的死角。 |
|
||||
|
||||
### 1.4 `@uncaged/nerve-adapter-cursor` / `@uncaged/nerve-adapter-hermes`
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/adapter-cursor/src/index.ts` | `cursorAdapter`、`createCursorAdapter`(以及 `cursorAgent` 仅被 `workflow-utils/src/role-cursor.ts` 使用) | **中** | **保留**:默认实例与工厂供下游与工作流仓使用;仓内业务包未直接 import `cursorAdapter` 属预期。 |
|
||||
| `packages/adapter-hermes/src/index.ts` | `hermesAdapter`、`createHermesAdapter`(`hermesAgent` 经 `workflow-utils` 间接使用) | **中** | 同上。 |
|
||||
|
||||
### 1.5 `@uncaged/nerve-cli`
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/cli/src/index.ts` | `getNerveRoot`、`loadDaemonModule` 等程序化导出 | **高(仓内)** | **保留**:无任何 workspace 包引用 `@uncaged/nerve-cli`;面向 CLI 二次开发者。 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 孤儿文件(非入口、未被其它源码引用)
|
||||
|
||||
在当前 Rslib / `cli` 入口定义下,**未发现**「零 import、且非 entry / 非测试」的 `.ts` 业务文件:
|
||||
|
||||
- `workflow-utils` 下的 `role-cursor.ts`、`role-hermes.ts`、`role-llm.ts`、`role-react.ts` 虽**未出现在包 `index.ts`**,但被 `packages/workflow-utils/src/__tests__/role-factories.test.ts` 直接引用,**不是孤儿文件**。
|
||||
- `daemon` 的 `sense-worker.ts`、`workflow-worker.ts` 等为 **显式构建入口**。
|
||||
|
||||
**置信度**: 对全表扫描为 **中**(未跑依赖图工具;动态路径可能掩盖极少数脚本引用)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 死函数(内部未导出且未被调用)
|
||||
|
||||
| 路径 | 说明 | 置信度 | 建议 |
|
||||
|------|------|--------|------|
|
||||
| `packages/workflow-utils/src/role-types.ts` | `isDryRun(_start: StartStep)`:导出在 `index.ts`,但全仓无调用(函数体恒返回 `false` 且标注 deprecated) | **高** | **删除**或改为内部常量;若需向后兼容则保留并文档标注「保留占位」。 |
|
||||
|
||||
其它内部函数未系统逐文件枚举。
|
||||
|
||||
---
|
||||
|
||||
## 4. 未使用的 npm 依赖(package.json)
|
||||
|
||||
在声明范围内通过源码 `import` 抽查:**未发现明显「完全未使用」的生产依赖**。
|
||||
|
||||
| 包 | 依赖 | 结论 |
|
||||
|----|------|------|
|
||||
| `cli` | `citty`、`yaml`、`picomatch` | 均有对应源码引用。 |
|
||||
| `khala` | `hono`、`jsonata`、`ulidx`、`@uncaged/nerve-core` | 均有引用。 |
|
||||
| `core` / `daemon` | `yaml`、`drizzle-orm` | 均有引用。 |
|
||||
| `workflow-utils` / `workflow-meta` | `zod`、workspace 包 | 均有引用。 |
|
||||
|
||||
**置信度**: **中**(未对 peer、可选路径、CLI `bin` 专用依赖做自动化扫描)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 陈旧测试夹具 / 未引用辅助文件
|
||||
|
||||
未发现独立的「fixture 目录」明显失联;`cli` 下 `e2e-harness`、`__tests__` 内 helper 均有对应测试引用。
|
||||
|
||||
**置信度**: **低**(未枚举每个 `__tests__` 资源文件)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 重构遗留 / 文档漂移
|
||||
|
||||
| 项目 | 位置 | 说明 | 置信度 | 建议 |
|
||||
|------|------|------|--------|------|
|
||||
| ~~已更名 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` 适配器包;与常量未被引用叠加 | **中** | **对齐产品**:实现适配器或从列表移除。 |
|
||||
|
||||
未发现用户提到的 `worker-fork-support` 等字符串副本(全仓无匹配)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 汇总建议优先级
|
||||
|
||||
1. **高优先级调查**: `createEchoAgent` 与 `KNOWN_AGENT_ADAPTER_IDS` — 要么接入运行时,要么删减以免维护假象。
|
||||
2. **API 面收敛**: `parseDurationStringToMs`、`labelSenseTrigger` 若无意对外,可从 `core` 公共导出移除。
|
||||
3. **`workflow-utils`**: 评估 `isDryRun` 删除;`spawnSafe` 等从 `workflow-utils` 再导出是否仍有必要。
|
||||
4. ~~**文档**: 修正 `packages/core/README.md` 中 Sense→Workflow 路由 API 名称。~~(已完成)
|
||||
|
||||
---
|
||||
|
||||
## 8. 重新验证命令示例
|
||||
|
||||
后续可在本地采用工具增强置信度(非本次执行):
|
||||
|
||||
```bash
|
||||
pnpm add -D -w knip
|
||||
pnpm exec knip
|
||||
```
|
||||
|
||||
或在各包对 `@uncaged/nerve-*` 的导出做面向消费者的契约测试,避免误删对外 API。
|
||||
+11
-16
@@ -1,22 +1,17 @@
|
||||
import { loadavg } from "node:os";
|
||||
|
||||
import type { DrizzleDB, PeerMap } from "@uncaged/nerve-daemon";
|
||||
type CpuState = {
|
||||
samples: Array<{ ts: number; value: number }>;
|
||||
};
|
||||
|
||||
import { samples } from "./schema.js";
|
||||
export const initialState: CpuState = { samples: [] };
|
||||
|
||||
/**
|
||||
* Read the 1-minute CPU load average, persist it, and emit a Signal.
|
||||
*
|
||||
* Returns `null` only if `loadavg` is unavailable (non-POSIX platforms).
|
||||
* On every successful read a row is inserted and a Signal is emitted with the load value.
|
||||
*/
|
||||
export async function compute(db: DrizzleDB, _peers: PeerMap) {
|
||||
export async function compute(state: CpuState): Promise<{
|
||||
state: CpuState;
|
||||
workflow: null;
|
||||
}> {
|
||||
const [oneMin] = loadavg();
|
||||
|
||||
if (typeof oneMin !== "number" || Number.isNaN(oneMin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await db.insert(samples).values({ ts: Date.now(), value: oneMin });
|
||||
return { signal: oneMin, workflow: null };
|
||||
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
|
||||
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
|
||||
return { state: { samples: newSamples }, workflow: null };
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Migration: 0001_init
|
||||
-- Creates the samples table for the cpu-usage sense.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS samples (
|
||||
ts INTEGER PRIMARY KEY,
|
||||
value REAL NOT NULL
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
/**
|
||||
* Each row records one CPU load sample.
|
||||
* `ts` is the Unix timestamp in milliseconds (primary key, append-only).
|
||||
* `value` is the 1-minute load average from os.loadavg()[0].
|
||||
*/
|
||||
export const samples = sqliteTable("samples", {
|
||||
ts: integer("ts").primaryKey(),
|
||||
value: real("value").notNull(),
|
||||
});
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
# Example nerve.yaml demonstrating Signal Bus & Reflex Scheduler (Phase 3)
|
||||
# Example nerve.yaml demonstrating per-sense scheduling (interval + on)
|
||||
#
|
||||
# Layout:
|
||||
# - cpu-usage: periodic every 10s, throttled to 5s minimum between computes
|
||||
# - disk-usage: periodic every 30s
|
||||
# - system-health: derived sense, triggered whenever cpu-usage OR disk-usage emits
|
||||
# - system-health: derived sense, scheduled when cpu-usage OR disk-usage completes a compute
|
||||
|
||||
senses:
|
||||
cpu-usage:
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
* nerve-health — built-in sense that reports daemon health via IPC.
|
||||
*
|
||||
* When running inside a sense worker, this compute function sends a
|
||||
* "health-request" to the parent kernel process and returns the
|
||||
* health-response payload as its signal.
|
||||
* "health-request" to the parent kernel process and merges the response into state.
|
||||
*
|
||||
* Usage in nerve.yaml:
|
||||
* senses:
|
||||
@@ -22,9 +21,23 @@ export type NerveHealth = {
|
||||
workerUptime: number;
|
||||
};
|
||||
|
||||
export async function compute() {
|
||||
type HealthState = {
|
||||
lastCheck: number | null;
|
||||
lastHealth: NerveHealth | null;
|
||||
};
|
||||
|
||||
export const initialState: HealthState = { lastCheck: null, lastHealth: null };
|
||||
|
||||
export async function compute(_state: HealthState): Promise<{
|
||||
state: HealthState;
|
||||
workflow: null;
|
||||
}> {
|
||||
void _state;
|
||||
const health = await requestHealthFromKernel();
|
||||
return { signal: health, workflow: null };
|
||||
return {
|
||||
state: { lastCheck: Date.now(), lastHealth: health },
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
|
||||
function requestHealthFromKernel(): Promise<NerveHealth> {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"build": "pnpm -r run build",
|
||||
"test": "pnpm -r test",
|
||||
"check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"link:dev": "bash scripts/link-dev.sh"
|
||||
|
||||
@@ -51,10 +51,10 @@ Structured rows in `data/logs.db` are surfaced via **`nerve workflow inspect`**
|
||||
```bash
|
||||
nerve sense list # List senses (live fields from daemon IPC when running)
|
||||
nerve sense trigger <name> # IPC trigger-sense — queue a compute for that sense
|
||||
nerve sense query <name> # Read-only SQL on data/senses/<name>.db (optional SQL args)
|
||||
nerve sense schema <name> # Print CREATE TABLE statements for that sense DB
|
||||
```
|
||||
|
||||
Sense state is persisted as JSON under `data/senses/<name>.json` by the sense worker after each successful compute.
|
||||
|
||||
### Store maintenance
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
<!-- nerve-cli-version: __NERVE_CLI_VERSION__ -->
|
||||
|
||||
## Cursor Agent 使用提示
|
||||
|
||||
在 Cursor 中与 Agent 对话时,可以用以下方式指代代码与配置:
|
||||
|
||||
- **`@Files` / `@file`**:引用单个文件,例如 `@nerve.yaml`、`@senses/cpu-usage/src/index.ts`,减少幻觉并让修改对准正确路径。
|
||||
- **`@Folder` / `@Codebase`**:需要跨目录理解工作区结构时使用;改动前仍应优先打开相关 sense/workflow 源文件确认。
|
||||
- **`@Terminal`**:把 CLI 输出纳入上下文,便于对照 `nerve daemon logs`、`nerve sense query` 等结果。
|
||||
- **`@Docs`**:若项目或依赖有文档索引,可用来对齐 API 与约定。
|
||||
- 工作区根目录下的 **`nerve.yaml`**、`senses/`、`workflows/` 是 nerve 的核心入口;讨论调度与配置时优先 `@` 这些路径。
|
||||
- 本规则由 `nerve agent inject cursor` 安装;更新 CLI 后在同一目录再次执行可覆盖为新版。
|
||||
|
||||
---
|
||||
|
||||
# Nerve — AI Agent 观测引擎
|
||||
|
||||
Nerve 是一个轻量级观测引擎守护进程。它持续观测外部状态,通过声明式规则响应变化,编排多步骤工作流。
|
||||
|
||||
## 核心架构
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Workflow → Log
|
||||
```
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| **Sense** | 观测函数,`compute()` 采样或推导数据。返回非 null 则发出 Signal,可选触发 Workflow。每个 Sense 有独立 SQLite 数据库。 |
|
||||
| **Signal** | Sense 返回非 null 时发出的通知。纯事实,无意图。通过内存 Signal Bus 分发,不持久化。 |
|
||||
| **Workflow** | 有状态的多步骤执行。包含 Role(有副作用的执行者)和 Moderator(纯路由器)。每个实例是一个 Thread,有唯一 runId。 |
|
||||
| **Log** | 不可变审计日志。记录执行、状态转换、错误。不能触发 Sense(防止反馈循环)。 |
|
||||
| **Engine** | 内核,持有 Signal Bus、Process Manager、Workflow Manager。不直接加载用户代码。 |
|
||||
| **Daemon** | 引擎运行时,作为后台进程运行。 |
|
||||
|
||||
**关键规则:**
|
||||
- 因果链单向:External → Sense → Signal → Workflow + Log
|
||||
- 进程隔离:每个 Sense group 一个 worker(长期),每个 Workflow 类型一个 worker(按需)
|
||||
- 两个扩展点:Sense(观测什么 + 何时)、Workflow(做什么)
|
||||
|
||||
## 工作区结构
|
||||
|
||||
由 `nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件:它与本文 skill 对齐,约定目录布局、`createRole` 用法以及**始终在仓库根目录**执行的构建命令。
|
||||
|
||||
```
|
||||
~/.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() + table
|
||||
│ ├── src/schema.ts # Drizzle 表定义
|
||||
│ └── migrations/ # SQL 迁移
|
||||
├── workflows/
|
||||
│ └── <name>/
|
||||
│ ├── index.ts # default export:WorkflowDefinition
|
||||
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
|
||||
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
|
||||
│ └── roles/
|
||||
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
|
||||
└── data/ # 运行时数据(SQLite、blobs)
|
||||
```
|
||||
|
||||
### 命名约定
|
||||
|
||||
- **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 计算
|
||||
nerve sense schema <name> # 查看 sense 数据库表结构
|
||||
nerve sense schema <name> --json # JSON 格式
|
||||
nerve sense query <name> <sql> # 对 sense 数据库执行只读 SQL
|
||||
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
|
||||
```
|
||||
|
||||
### 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(向 Hermes 注入本 skill)
|
||||
|
||||
```bash
|
||||
nerve agent status # CLI 版本与各 Hermes 注入目录中的 skill 版本
|
||||
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
|
||||
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
|
||||
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
|
||||
nerve agent remove hermes # 移除默认 profile 的注入
|
||||
nerve agent remove hermes --profile <name>
|
||||
|
||||
nerve agent inject cursor # 在 cwd 生成 .cursorrules
|
||||
nerve agent inject cursor --path /foo # 在指定目录生成
|
||||
nerve agent remove cursor [--path /foo]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 # 优雅关闭等待
|
||||
retention: 10000 # _signals 表最大行数(默认 10000)
|
||||
|
||||
system-health:
|
||||
group: derived
|
||||
on: [cpu-usage, disk-usage] # 响应式:被列出的 sense 发出 signal 时触发
|
||||
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` **无参数**。它不接收数据库句柄:daemon 在 worker 内调用 `SenseComputeFn`,由运行时负责把非 null 结果的 `signal` 写入该 sense 的 Drizzle 表并记入 `_signals`。超时由运行时控制(对应 `nerve.yaml` 里的 `timeout`),无需在业务代码里读取 `AbortSignal`。
|
||||
|
||||
```typescript
|
||||
import type { ComputeResult, SenseComputeFn } from "@uncaged/nerve-core";
|
||||
|
||||
export const compute: SenseComputeFn<MySignalShape> = async () => {
|
||||
// ...
|
||||
};
|
||||
// 或等价地:
|
||||
export async function compute(): Promise<ComputeResult<MySignalShape>> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
(运行时定义见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseModule`,daemon 侧在 `sense-runtime.ts` 的 `executeCompute` 中插入 `result.signal`。)
|
||||
|
||||
### 返回值
|
||||
|
||||
```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 模块导出
|
||||
|
||||
```typescript
|
||||
// senses/<name>/src/index.ts
|
||||
import type { ComputeResult } from "@uncaged/nerve-core";
|
||||
import { table } from "./schema.js";
|
||||
|
||||
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, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
```
|
||||
|
||||
### Schema 定义
|
||||
|
||||
```typescript
|
||||
// senses/<name>/src/schema.ts
|
||||
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const table = sqliteTable("samples", {
|
||||
ts: integer("ts").notNull(),
|
||||
value: real("value").notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
### 调度方式
|
||||
|
||||
1. **interval 轮询**:`interval: 10s` — 每 10 秒执行一次
|
||||
2. **响应式触发**:`on: [cpu-usage]` — 当 cpu-usage 发出 signal 时触发
|
||||
3. 两者可以组合
|
||||
|
||||
### 调试
|
||||
|
||||
```bash
|
||||
nerve dev # 前台运行,看实时输出
|
||||
nerve sense trigger <name> # 手动触发一次
|
||||
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 5"
|
||||
```
|
||||
|
||||
### 完整示例:CPU 监控
|
||||
|
||||
```typescript
|
||||
// senses/cpu-usage/src/schema.ts
|
||||
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const table = sqliteTable("samples", {
|
||||
ts: integer("ts").notNull(),
|
||||
value: real("value").notNull(),
|
||||
});
|
||||
|
||||
// senses/cpu-usage/src/index.ts
|
||||
import os from "node:os";
|
||||
import type { ComputeResult } from "@uncaged/nerve-core";
|
||||
import { table } from "./schema.js";
|
||||
|
||||
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 }, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
```
|
||||
|
||||
nerve.yaml:
|
||||
```yaml
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system
|
||||
interval: 10s
|
||||
throttle: 5s
|
||||
timeout: 10s
|
||||
retention: 10000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 执行记录
|
||||
```
|
||||
|
||||
### 检查某个 sense 的历史数据
|
||||
|
||||
```bash
|
||||
nerve sense query cpu-usage "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
|
||||
nerve sense schema cpu-usage # 查看表结构
|
||||
```
|
||||
|
||||
### 手动触发 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 和 schema.ts
|
||||
nerve validate # 验证配置
|
||||
nerve dev # 前台测试
|
||||
nerve sense trigger my-sensor # 单次触发验证
|
||||
nerve sense query my-sensor "SELECT * FROM ..." # 检查数据
|
||||
```
|
||||
|
||||
### 开发新 workflow
|
||||
|
||||
```bash
|
||||
nerve create workflow my-flow # 脚手架(当前 CLI 可能仍生成 roles/<name>/ 子目录)
|
||||
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
|
||||
nerve validate # 验证配置
|
||||
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建(等价:pnpm run build);勿在单个 workflow 子目录单独跑 build
|
||||
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
|
||||
nerve thread show <runId> # 查看执行轨迹
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Sense 返回值**:返回 `null` 表示静默(不发 signal);返回 `{ signal, workflow }` 才发 signal。不要返回 undefined。
|
||||
- **Sense 持久化**:daemon 在 `compute()` 返回非 null 时自动执行 `db.insert(table).values(signal)` 并写入 `_signals`;业务代码不要自行 insert。
|
||||
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
|
||||
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`。
|
||||
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
|
||||
- **_signals 表**:每个 sense 自动有 `_signals` 表记录 signal 历史,受 `retention` 配置限制。
|
||||
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
|
||||
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
|
||||
@@ -36,23 +36,34 @@ External World → Sense → Signal → Workflow → Log
|
||||
|
||||
## 工作区结构
|
||||
|
||||
由 `nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件:它与本文 skill 对齐,约定目录布局、`createRole` 用法以及**始终在仓库根目录**执行的构建命令。
|
||||
|
||||
```
|
||||
~/.uncaged-nerve/ # 默认工作区(nerve init 创建)
|
||||
~/.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() + table
|
||||
│ ├── src/schema.ts # drizzle 表定义
|
||||
│ ├── src/schema.ts # Drizzle 表定义
|
||||
│ └── migrations/ # SQL 迁移
|
||||
├── workflows/
|
||||
│ └── <name>/
|
||||
│ ├── index.ts # exports WorkflowDefinition
|
||||
│ └── roles/<role>/
|
||||
│ ├── index.ts # role 实现
|
||||
│ └── prompt.md # 可选 system prompt
|
||||
│ ├── index.ts # default export:WorkflowDefinition
|
||||
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
|
||||
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
|
||||
│ └── roles/
|
||||
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
|
||||
└── data/ # 运行时数据(SQLite、blobs)
|
||||
```
|
||||
|
||||
### 命名约定
|
||||
|
||||
- **Workflow**:动词开头的 kebab-case(例如 `review-pull-request`、`deploy-staging`)。避免单独名词式命名(如 `notifications`)。
|
||||
- **Sense**:描述性名词 kebab-case(例如 `cpu-usage`)。
|
||||
|
||||
---
|
||||
|
||||
## CLI 完整参考
|
||||
@@ -156,6 +167,17 @@ nerve remote remove <name>
|
||||
nerve remote default <name> # 设为默认远程
|
||||
```
|
||||
|
||||
### Agent(向 Hermes 注入本 skill)
|
||||
|
||||
```bash
|
||||
nerve agent status # CLI 版本与各 Hermes 注入目录中的 skill 版本
|
||||
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
|
||||
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
|
||||
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
|
||||
nerve agent remove hermes # 移除默认 profile 的注入
|
||||
nerve agent remove hermes --profile <name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## nerve.yaml 配置参考
|
||||
@@ -205,22 +227,27 @@ extract:
|
||||
|
||||
### compute 函数签名
|
||||
|
||||
```typescript
|
||||
import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
import type { ComputeResult, WorkflowTrigger } from "@uncaged/nerve-core";
|
||||
Sense 的 `compute` **无参数**。它不接收数据库句柄:daemon 在 worker 内调用 `SenseComputeFn`,由运行时负责把非 null 结果的 `signal` 写入该 sense 的 Drizzle 表并记入 `_signals`。超时由运行时控制(对应 `nerve.yaml` 里的 `timeout`),无需在业务代码里读取 `AbortSignal`。
|
||||
|
||||
export async function compute(
|
||||
db: LibSQLDatabase, // 此 sense 的 Drizzle ORM 数据库
|
||||
peers: Record<string, LibSQLDatabase>, // 其他 sense 的数据库(只读)
|
||||
options: { signal: AbortSignal }, // 超时 abort signal
|
||||
): Promise<ComputeResult<T>>
|
||||
```typescript
|
||||
import type { ComputeResult, SenseComputeFn } from "@uncaged/nerve-core";
|
||||
|
||||
export const compute: SenseComputeFn<MySignalShape> = async () => {
|
||||
// ...
|
||||
};
|
||||
// 或等价地:
|
||||
export async function compute(): Promise<ComputeResult<MySignalShape>> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
(运行时定义见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseModule`,daemon 侧在 `sense-runtime.ts` 的 `executeCompute` 中插入 `result.signal`。)
|
||||
|
||||
### 返回值
|
||||
|
||||
```typescript
|
||||
// 返回 null = 静默,不发 signal
|
||||
// 返回非 null = 发出 signal,可选触发 workflow
|
||||
// 返回非 null = 发出 signal(并写入业务表),可选触发 workflow
|
||||
type ComputeResult<T> =
|
||||
| null
|
||||
| { signal: T; workflow: WorkflowTrigger | null };
|
||||
@@ -233,21 +260,20 @@ type WorkflowTrigger = {
|
||||
};
|
||||
```
|
||||
|
||||
若返回值是普通对象且不含 `signal` 字段,内核会按 shorthand 视为 `{ signal: payload, workflow: null }`(见 core 的 `routeSenseComputeOutput`)。
|
||||
|
||||
### Sense 模块导出
|
||||
|
||||
```typescript
|
||||
// senses/<name>/src/index.ts
|
||||
import type { SenseModule, ComputeResult } from "@uncaged/nerve-core";
|
||||
import type { ComputeResult } from "@uncaged/nerve-core";
|
||||
import { table } from "./schema.js";
|
||||
|
||||
export async function compute(
|
||||
db: LibSQLDatabase,
|
||||
_peers: Record<string, LibSQLDatabase>,
|
||||
_options: { signal: AbortSignal },
|
||||
): Promise<ComputeResult<number>> {
|
||||
const value = Math.random(); // 替换为真实观测逻辑
|
||||
await db.insert(table).values({ ts: Date.now(), value });
|
||||
return { signal: value, workflow: null };
|
||||
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, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
@@ -292,18 +318,14 @@ export const table = sqliteTable("samples", {
|
||||
|
||||
// senses/cpu-usage/src/index.ts
|
||||
import os from "node:os";
|
||||
import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
import type { ComputeResult } from "@uncaged/nerve-core";
|
||||
import { table } from "./schema.js";
|
||||
|
||||
export async function compute(
|
||||
db: LibSQLDatabase,
|
||||
_peers: Record<string, LibSQLDatabase>,
|
||||
_options: { signal: AbortSignal },
|
||||
): Promise<ComputeResult<number>> {
|
||||
type Row = { ts: number; value: number };
|
||||
|
||||
export async function compute(): Promise<ComputeResult<Row>> {
|
||||
const oneMin = os.loadavg()[0];
|
||||
await db.insert(table).values({ ts: Date.now(), value: oneMin });
|
||||
return { signal: oneMin, workflow: null };
|
||||
return { signal: { ts: Date.now(), value: oneMin }, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
@@ -331,55 +353,80 @@ import type {
|
||||
WorkflowDefinition,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
StartStep,
|
||||
RoleStep,
|
||||
RoleMeta,
|
||||
Moderator,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
// Role:执行者,接收上下文返回结果
|
||||
type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>;
|
||||
type RoleResult<Meta> = { content: string; meta: Meta };
|
||||
|
||||
// Moderator:路由器,决定下一个 role 或结束
|
||||
type Moderator<M> = (ctx: ThreadContext<M>) => (keyof M & string) | typeof END;
|
||||
|
||||
// ThreadContext:对话上下文
|
||||
type ThreadContext<M = RoleMeta> = {
|
||||
threadId: string;
|
||||
start: StartStep; // 初始 prompt(role: "__start__")
|
||||
steps: RoleStep<M>[]; // 所有 role 的执行记录
|
||||
};
|
||||
|
||||
// WorkflowDefinition:完整定义
|
||||
type WorkflowDefinition<M> = {
|
||||
name: string;
|
||||
roles: { [K in keyof M & string]: Role<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
// 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
|
||||
```
|
||||
|
||||
### 基本 Workflow 示例
|
||||
### 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
|
||||
// workflows/example/index.ts
|
||||
import type { RoleResult, ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { ThreadContext } from "@uncaged/nerve-core";
|
||||
import { z } from "zod";
|
||||
|
||||
type Meta = Record<"main", { round: number }>;
|
||||
const provider = {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: process.env.EXAMPLE_API_KEY!,
|
||||
model: "gpt-4o-mini",
|
||||
};
|
||||
|
||||
async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
|
||||
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>) {
|
||||
// 执行一次 main 就结束
|
||||
return ctx.steps.length === 0 ? "main" : END;
|
||||
},
|
||||
};
|
||||
@@ -387,25 +434,50 @@ const workflow: WorkflowDefinition<Meta> = {
|
||||
export default workflow;
|
||||
```
|
||||
|
||||
可选:将 `moderator` 挪到 `moderator.ts` 再 `import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`。
|
||||
|
||||
### 多 Role Workflow 示例
|
||||
|
||||
```typescript
|
||||
import type { WorkflowDefinition, RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
// workflows/plan-execute-review/roles/planner.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
|
||||
|
||||
async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "计划: ...", meta: { status: "planned" } };
|
||||
}
|
||||
```
|
||||
|
||||
async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
```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" } };
|
||||
}
|
||||
```
|
||||
|
||||
async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
```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",
|
||||
@@ -424,12 +496,14 @@ export default workflow;
|
||||
|
||||
### Agent 适配器
|
||||
|
||||
Workflow role 可以集成 AI agent。已知适配器 ID:`echo`、`cursor`、`hermes`、`codex`。
|
||||
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`
|
||||
@@ -484,9 +558,10 @@ nerve sense query my-sensor "SELECT * FROM ..." # 检查数据
|
||||
### 开发新 workflow
|
||||
|
||||
```bash
|
||||
nerve create workflow my-flow # 脚手架
|
||||
# 编辑 workflows/my-flow/index.ts 和 roles/
|
||||
nerve create workflow my-flow # 脚手架(当前 CLI 可能仍生成 roles/<name>/ 子目录)
|
||||
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
|
||||
nerve validate # 验证配置
|
||||
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建(等价:pnpm run build);勿在单个 workflow 子目录单独跑 build
|
||||
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
|
||||
nerve thread show <runId> # 查看执行轨迹
|
||||
```
|
||||
@@ -496,10 +571,10 @@ nerve thread show <runId> # 查看执行轨迹
|
||||
## Pitfalls
|
||||
|
||||
- **Sense 返回值**:返回 `null` 表示静默(不发 signal);返回 `{ signal, workflow }` 才发 signal。不要返回 undefined。
|
||||
- **Sense 持久化**:daemon 在 `compute()` 返回非 null 时自动执行 `db.insert(table).values(signal)` 并写入 `_signals`;业务代码不要自行 insert。
|
||||
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
|
||||
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`。
|
||||
- **workflow 用 default export**:这是唯一允许 default export 的场景。
|
||||
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
|
||||
- **_signals 表**:每个 sense 自动有 `_signals` 表记录 signal 历史,受 `retention` 配置限制。
|
||||
- **peers 只读**:sense 的 `peers` 参数提供其他 sense 数据库的只读访问,不要写入。
|
||||
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
|
||||
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildSenseIndexTs,
|
||||
buildSenseMigrationSql,
|
||||
buildSenseSchemaTs,
|
||||
validateResourceName,
|
||||
} from "../commands/create.js";
|
||||
import { buildSenseIndexTs, validateResourceName } from "../commands/create.js";
|
||||
|
||||
describe("validateSenseName", () => {
|
||||
it("accepts valid ids", () => {
|
||||
@@ -25,39 +20,13 @@ describe("validateSenseName", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSenseSchemaTs", () => {
|
||||
it("maps kebab-case id to snake table and camel export", () => {
|
||||
const src = buildSenseSchemaTs("my-sense");
|
||||
expect(src).toContain('sqliteTable("my_sense"');
|
||||
expect(src).toContain("export const mySense = ");
|
||||
});
|
||||
|
||||
it("handles single-segment id", () => {
|
||||
const src = buildSenseSchemaTs("metrics");
|
||||
expect(src).toContain('sqliteTable("metrics"');
|
||||
expect(src).toContain("export const metrics = ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSenseMigrationSql", () => {
|
||||
it("uses snake_case table name", () => {
|
||||
expect(buildSenseMigrationSql("disk-io")).toContain("CREATE TABLE IF NOT EXISTS disk_io");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSenseIndexTs", () => {
|
||||
it("embeds sense id in stub with TypeScript types", () => {
|
||||
it("generates a stateful sense stub with TypeScript types", () => {
|
||||
const ts = buildSenseIndexTs("my-sense");
|
||||
expect(ts).toContain("my-sense");
|
||||
expect(ts).toContain("export { mySense as table }");
|
||||
expect(ts).toContain("type SenseState");
|
||||
expect(ts).toContain("export const initialState");
|
||||
expect(ts).toContain("export async function compute");
|
||||
expect(ts).toContain("LibSQLDatabase");
|
||||
expect(ts).toContain("Promise<SenseResult>");
|
||||
expect(ts).toContain('from "./schema.js"');
|
||||
});
|
||||
|
||||
it("imports the correct schema export", () => {
|
||||
const ts = buildSenseIndexTs("cpu-usage");
|
||||
expect(ts).toContain("cpuUsage");
|
||||
expect(ts).toContain("workflow: null");
|
||||
expect(ts).toContain("lastRun");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("e2e create", () => {
|
||||
);
|
||||
|
||||
it(
|
||||
"create sense scaffolds src/, migration, and root build emits dist/senses/<name>/index.js",
|
||||
"create sense scaffolds src/ and root build emits dist/senses/<name>/index.js",
|
||||
{ timeout: 120_000 },
|
||||
async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
@@ -162,8 +162,6 @@ describe("e2e create", () => {
|
||||
const base = join(nerveRoot, "senses", "e2e-sense");
|
||||
expect(existsSync(join(base, "package.json"))).toBe(false);
|
||||
expect(existsSync(join(base, "src", "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(base, "src", "schema.ts"))).toBe(true);
|
||||
expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "dist", "senses", "e2e-sense", "index.js"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
* Shared E2E harness: temp HOME layout (`.uncaged-nerve`), real kernel + sense worker,
|
||||
* IPC socket for CLI, and `runCommand` helpers with captured stdio.
|
||||
*
|
||||
* ## Signal persistence (CLI `nerve sense list`)
|
||||
*
|
||||
* The kernel appends a `source: "sense", type: "signal"` row to `data/logs.db` when a
|
||||
* worker emits a signal (see `packages/daemon/src/kernel.ts`). The daemon also
|
||||
* auto-persists each signal into a `_signals` table in the per-sense SQLite DB
|
||||
* (see `runtime.persistSignal` in `packages/daemon/src/sense-runtime.ts`).
|
||||
* `listSenses()` reads `lastSignalTimestamp` from the kernel's in-memory state,
|
||||
* while `sense query` reads from the `_signals` table (or a user-defined preview table).
|
||||
* Stateful senses persist JSON under `data/senses/<name>.json` (see sense-worker).
|
||||
*
|
||||
* ## Timeout guard (vitest)
|
||||
*
|
||||
@@ -37,15 +30,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -69,27 +54,6 @@ const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.j
|
||||
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
|
||||
const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js");
|
||||
|
||||
function resolveDrizzleOrmPackageRoot(): string {
|
||||
const requireFromDaemon = createRequire(join(nerveDaemonRoot, "package.json"));
|
||||
const entry = requireFromDaemon.resolve("drizzle-orm");
|
||||
let dir = dirname(entry);
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (existsSync(pkgPath)) {
|
||||
try {
|
||||
const name = (JSON.parse(readFileSync(pkgPath, "utf8")) as { name: string }).name;
|
||||
if (name === "drizzle-orm") return dir;
|
||||
} catch {
|
||||
// keep walking
|
||||
}
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error("Could not resolve drizzle-orm package root for e2e harness");
|
||||
}
|
||||
|
||||
const nerveYamlTemplate = `senses:
|
||||
counter:
|
||||
group: e2e
|
||||
@@ -150,52 +114,24 @@ api:
|
||||
host: 127.0.0.1
|
||||
`;
|
||||
|
||||
/** Schema for sense signal rows persisted via \`db.insert(table)\` (see sense-runtime). */
|
||||
const counterMigration = `CREATE TABLE IF NOT EXISTS counter_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
count INTEGER,
|
||||
launched INTEGER,
|
||||
idle INTEGER
|
||||
);
|
||||
`;
|
||||
/** Minimal counter sense — each compute increments \`count\` in persisted state. */
|
||||
const counterIndexJs = `export const initialState = { count: 0 };
|
||||
|
||||
/**
|
||||
* Minimal counter sense — each compute returns an incrementing count.
|
||||
* Does NOT touch the DB directly in compute(); the daemon inserts into \`table\`
|
||||
* and persistSignal handles \`_signals\`.
|
||||
*/
|
||||
const counterIndexJs = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const table = sqliteTable("counter_signals", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
count: integer("count"),
|
||||
launched: integer("launched"),
|
||||
idle: integer("idle"),
|
||||
});
|
||||
|
||||
let _count = 0;
|
||||
export async function compute(_db, _peers, _options) {
|
||||
_count += 1;
|
||||
return { signal: { count: _count }, workflow: null };
|
||||
export async function compute(state) {
|
||||
return {
|
||||
state: { count: state.count + 1 },
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
/** First trigger launches local noop workflow; later triggers emit a plain signal. */
|
||||
const counterIndexJsWithNoopWorkflow = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core";
|
||||
/** First trigger launches local noop workflow; later triggers only advance idleTicks. */
|
||||
const counterIndexJsWithNoopWorkflow = `export const initialState = { launched: false, idleTicks: 0 };
|
||||
|
||||
export const table = sqliteTable("counter_signals", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
count: integer("count"),
|
||||
launched: integer("launched"),
|
||||
idle: integer("idle"),
|
||||
});
|
||||
|
||||
let _launched = false;
|
||||
export async function compute(_db, _peers, _options) {
|
||||
if (!_launched) {
|
||||
_launched = true;
|
||||
export async function compute(state) {
|
||||
if (!state.launched) {
|
||||
return {
|
||||
signal: { launched: 1 },
|
||||
state: { launched: true, idleTicks: state.idleTicks },
|
||||
workflow: {
|
||||
name: "noop",
|
||||
maxRounds: 3,
|
||||
@@ -204,7 +140,10 @@ export async function compute(_db, _peers, _options) {
|
||||
},
|
||||
};
|
||||
}
|
||||
return { signal: { idle: 1 }, workflow: null };
|
||||
return {
|
||||
state: { launched: state.launched, idleTicks: state.idleTicks + 1 },
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -241,7 +180,6 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -259,7 +197,6 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig {
|
||||
function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): void {
|
||||
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "data", "blobs"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "senses", "counter", "migrations"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "dist", "senses", "counter"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "dist", "workflows", "echo"), { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -267,11 +204,6 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi
|
||||
withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "senses", "counter", "migrations", "001.sql"),
|
||||
counterMigration,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "dist", "senses", "counter", "index.js"),
|
||||
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
|
||||
@@ -325,10 +257,6 @@ export function linkWorkspaceDaemonIntoNerveRoot(nerveRoot: string): void {
|
||||
mkdirSync(linkDir, { recursive: true });
|
||||
const linkPath = join(linkDir, "nerve-daemon");
|
||||
if (!existsSync(linkPath)) symlinkSync(daemonPkgRoot, linkPath);
|
||||
|
||||
const drizzlePkgRoot = resolveDrizzleOrmPackageRoot();
|
||||
const drizzleLink = join(nm, "drizzle-orm");
|
||||
if (!existsSync(drizzleLink)) symlinkSync(drizzlePkgRoot, drizzleLink);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* E2E: `nerve sense query` against a real daemon + persisted `_signals` (issue #156).
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type TestDaemonHandle,
|
||||
pollUntil,
|
||||
runCli,
|
||||
startTestDaemon,
|
||||
stopTestDaemon,
|
||||
} from "./e2e-harness.js";
|
||||
|
||||
describe("e2e sense query", () => {
|
||||
let daemon: TestDaemonHandle | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
const h = daemon;
|
||||
daemon = null;
|
||||
if (h === null) return;
|
||||
await Promise.race([
|
||||
stopTestDaemon(h),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
async function waitForSignalsPersisted(handle: TestDaemonHandle): Promise<void> {
|
||||
const dbPath = join(handle.nerveRoot, "data", "senses", "counter.db");
|
||||
await pollUntil(() => {
|
||||
if (!existsSync(dbPath)) return false;
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as
|
||||
| { cnt: number }
|
||||
| undefined;
|
||||
db.close();
|
||||
return row !== undefined && row.cnt > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
/** Start daemon, trigger counter, wait until `_signals` has a row. */
|
||||
async function startDaemonWithPersistedSignal(): Promise<TestDaemonHandle> {
|
||||
const handle = await startTestDaemon();
|
||||
const triggerResult = await runCli(handle, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
expect(triggerResult.stdout).toContain("Triggered");
|
||||
await waitForSignalsPersisted(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
it(
|
||||
"after trigger, persisted _signals and sense query counter returns at least one row",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
|
||||
expect(queryResult.exitCode).toBe(0);
|
||||
expect(queryResult.stdout).not.toContain("(0 rows)");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"default sense query output lists payload column and counter count in payload",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
|
||||
expect(queryResult.exitCode).toBe(0);
|
||||
expect(queryResult.stdout).toContain("payload");
|
||||
expect(queryResult.stdout).toMatch(/count/);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"nerve sense query counter --json prints a JSON array with payload on each row",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const jsonResult = await runCli(daemon, ["sense", "query", "counter", "--json"]);
|
||||
expect(jsonResult.exitCode).toBe(0);
|
||||
const rows = JSON.parse(jsonResult.stdout.trim()) as unknown;
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
for (const row of rows as Record<string, unknown>[]) {
|
||||
expect(Object.keys(row)).toContain("payload");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"nerve sense query counter --sql runs custom read-only SQL and prints total column",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const sqlResult = await runCli(daemon, [
|
||||
"sense",
|
||||
"query",
|
||||
"counter",
|
||||
"--sql",
|
||||
"SELECT count(*) as total FROM _signals",
|
||||
]);
|
||||
expect(sqlResult.exitCode).toBe(0);
|
||||
expect(sqlResult.stdout).toContain("total");
|
||||
expect(sqlResult.stdout).not.toContain("(0 rows)");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Smoke test: start a real daemon with a counter sense, trigger it,
|
||||
* then verify CLI commands can list and query the persisted signal.
|
||||
* then verify CLI lists the sense and state file is written.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -28,46 +31,27 @@ describe("e2e smoke", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sense list + sense query after trigger", { timeout: 30_000 }, async () => {
|
||||
it("sense list after trigger and persisted counter state", { timeout: 30_000 }, async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
// Trigger counter sense via IPC
|
||||
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
expect(triggerResult.stdout).toContain("Triggered");
|
||||
|
||||
// Wait for signal to be persisted (_signals table in the sense DB)
|
||||
const { existsSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
|
||||
const dbPath = join(daemon.nerveRoot, "data", "senses", "counter.db");
|
||||
const statePath = join(daemon.nerveRoot, "data", "senses", "counter.json");
|
||||
await pollUntil(() => {
|
||||
if (!existsSync(dbPath)) return false;
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as
|
||||
| { cnt: number }
|
||||
| undefined;
|
||||
db.close();
|
||||
return row !== undefined && row.cnt > 0;
|
||||
const raw = readFileSync(statePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { count?: number };
|
||||
return typeof parsed.count === "number" && parsed.count >= 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
// nerve sense list — should show counter with a last signal timestamp
|
||||
const listResult = await runCli(daemon, ["sense", "list"]);
|
||||
expect(listResult.exitCode).toBe(0);
|
||||
expect(listResult.stdout).toContain("counter");
|
||||
expect(listResult.stdout).toContain("last signal:");
|
||||
// Should NOT say "(never)" since we triggered and persisted
|
||||
expect(listResult.stdout).not.toContain("(never)");
|
||||
|
||||
// nerve sense query counter — should return rows from _signals
|
||||
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
|
||||
expect(queryResult.exitCode).toBe(0);
|
||||
// Should have actual data rows (not "(0 rows)")
|
||||
expect(queryResult.stdout).not.toContain("(0 rows)");
|
||||
expect(listResult.stdout).not.toContain("last signal");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,10 +210,6 @@ describe("e2e init", () => {
|
||||
expect(agentMd).toContain("verb-first");
|
||||
expect(agentMd).toContain("createRole");
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(existsSync(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"))).toBe(true);
|
||||
|
||||
const pkgJson = readFileSync(join(nerveRoot, "package.json"), "utf8");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## sense list
|
||||
|
||||
- ✅ prints sense list with name, group, throttle, triggers, and last signal time
|
||||
- ✅ prints sense list with name, group, throttle, triggers
|
||||
- 🔲 empty state — no senses registered, prints empty message
|
||||
- 🔲 `--json` — outputs valid JSON array
|
||||
|
||||
@@ -11,19 +11,3 @@
|
||||
- ✅ trigger known sense exits 0, stdout contains "Triggered"
|
||||
- ✅ trigger non-existent sense writes error to stderr and exits 1
|
||||
- ✅ sends correct IPC message `{ type: trigger-sense, sense: <name> }` to daemon
|
||||
|
||||
## sense query
|
||||
|
||||
- ✅ after trigger, persisted `_signals` table has at least one row
|
||||
- ✅ default output lists payload column and counter count
|
||||
- ✅ `--json` prints valid JSON array with payload on each row
|
||||
- ✅ `--sql` runs custom read-only SQL and prints result
|
||||
- 🔲 query non-existent sense — error message
|
||||
- 🔲 `--limit` / `--offset` pagination
|
||||
|
||||
## sense schema
|
||||
|
||||
- ✅ prints CREATE TABLE statements for the sense database
|
||||
- ✅ includes `_signals` table in output
|
||||
- ✅ `--json` prints valid JSON array of SQL strings
|
||||
- 🔲 schema for non-existent sense — error message
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
Full round-trip integration tests that exercise multiple subcommands together.
|
||||
|
||||
- ✅ sense list + sense query after trigger — registers sense, triggers, verifies persisted signal and query output
|
||||
- ✅ sense list after trigger — daemon lists configured senses; trigger queues a compute (state persisted under `data/senses/` by the worker)
|
||||
- 🔲 init → dev → trigger workflow → thread inspect round-trip
|
||||
|
||||
@@ -72,8 +72,8 @@ describe("queryKnowledgeRepo (word overlap fallback)", () => {
|
||||
path: "a.md",
|
||||
slug: "a.md#0",
|
||||
chunkIndex: 0,
|
||||
text: "the signal bus emits notifications",
|
||||
contentHash: contentHash("the signal bus emits notifications"),
|
||||
text: "the sense scheduler triggers computes",
|
||||
contentHash: contentHash("the sense scheduler triggers computes"),
|
||||
embedding: fakeEmbeddingBytes("a"),
|
||||
},
|
||||
{
|
||||
@@ -89,7 +89,7 @@ describe("queryKnowledgeRepo (word overlap fallback)", () => {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const ranked = await queryKnowledgeRepo(root, dbPath, "signal bus", 10);
|
||||
const ranked = await queryKnowledgeRepo(root, dbPath, "sense scheduler", 10);
|
||||
expect(ranked.length).toBe(2);
|
||||
expect(ranked[0]?.path).toBe("a.md");
|
||||
expect(ranked[1]?.path).toBe("b.md");
|
||||
|
||||
@@ -27,15 +27,12 @@ describe("nerve sense list CLI (runCommand + temp HOME + mock IPC socket)", () =
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn<typeof process.stdout, "write">> | null;
|
||||
let listSensesRequests: unknown[];
|
||||
|
||||
const LAST_SIGNAL_TS = 1_714_521_600_000; // fixed wall time for stable ISO in assertions
|
||||
|
||||
const daemonSenseRow: SenseInfo = {
|
||||
name: "cpu-usage",
|
||||
group: "system",
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
triggers: ["every 5s"],
|
||||
lastSignalTimestamp: LAST_SIGNAL_TS,
|
||||
};
|
||||
|
||||
function nerveYamlFixture(): string {
|
||||
@@ -119,9 +116,7 @@ senses:
|
||||
rmSync(sockDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prints sense list from daemon path with name, group, throttle, trigger schedule, and last signal time", async () => {
|
||||
// With a real daemon, we would wait for a compute cycle; the mock server
|
||||
// returns SenseInfo as if one already produced lastSignalTimestamp.
|
||||
it("prints sense list from daemon path with name, group, throttle, and trigger schedule", async () => {
|
||||
await runCommand(senseCommand, { rawArgs: ["list"] });
|
||||
|
||||
expect(listSensesRequests).toHaveLength(1);
|
||||
@@ -133,7 +128,6 @@ senses:
|
||||
expect(out).toContain("throttle: 5s");
|
||||
expect(out).toContain("timeout: 3s");
|
||||
expect(out).toContain("trigger schedule: every 5s");
|
||||
expect(out).not.toContain("(never)");
|
||||
expect(out).toContain(new Date(LAST_SIGNAL_TS).toISOString());
|
||||
expect(out).not.toContain("last signal");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ const SAMPLE_SENSES: SenseInfo[] = [
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
triggers: ["every 30s", "on: cpu-threshold"],
|
||||
lastSignalTimestamp: 1_700_000_000_000,
|
||||
},
|
||||
{
|
||||
name: "disk-usage",
|
||||
@@ -38,7 +37,6 @@ const SAMPLE_SENSES: SenseInfo[] = [
|
||||
throttle: 30000,
|
||||
timeout: null,
|
||||
triggers: [],
|
||||
lastSignalTimestamp: null,
|
||||
},
|
||||
{
|
||||
name: "active-tasks",
|
||||
@@ -46,7 +44,6 @@ const SAMPLE_SENSES: SenseInfo[] = [
|
||||
throttle: 10000,
|
||||
timeout: 30000,
|
||||
triggers: ["every 1m"],
|
||||
lastSignalTimestamp: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -122,15 +119,9 @@ describe("formatSenseList", () => {
|
||||
expect(output).toContain("(none)");
|
||||
});
|
||||
|
||||
it("shows '(never)' when lastSignalTimestamp is null", () => {
|
||||
it("does not include last signal line", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
expect(output).toContain("(never)");
|
||||
});
|
||||
|
||||
it("shows ISO timestamp when lastSignalTimestamp is set", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
// cpu-usage has lastSignalTimestamp = 1_700_000_000_000
|
||||
expect(output).toContain(new Date(1_700_000_000_000).toISOString());
|
||||
expect(output).not.toContain("last signal");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,29 +172,13 @@ senses:
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "cpu-usage",
|
||||
group: "system",
|
||||
lastSignalTimestamp: null,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
name: "disk-usage",
|
||||
group: "system",
|
||||
lastSignalTimestamp: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("always sets lastSignalTimestamp to null (static fallback)", () => {
|
||||
const path = join(tmpDir, "nerve.yaml");
|
||||
writeFileSync(
|
||||
path,
|
||||
`
|
||||
senses:
|
||||
my-sense:
|
||||
group: default
|
||||
`.trim(),
|
||||
);
|
||||
const result = sensesFromConfig(path);
|
||||
expect(result[0].lastSignalTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it("populates throttle and timeout from config", () => {
|
||||
const path = join(tmpDir, "nerve.yaml");
|
||||
writeFileSync(
|
||||
@@ -292,7 +267,6 @@ describe("listSensesViaDaemon", () => {
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
triggers: [],
|
||||
lastSignalTimestamp: 12345,
|
||||
},
|
||||
];
|
||||
const server = createServer((s) => {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* E2E-style tests for `nerve sense schema` with a temp HOME and a real sense SQLite file.
|
||||
* `getNerveRoot()` uses `os.homedir()`, which respects `process.env.HOME` on POSIX.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { runCommand } from "citty";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { senseCommand } from "../commands/sense.js";
|
||||
|
||||
const SENSE_NAME = "e2e-schema-sense";
|
||||
|
||||
function createFakeSenseDb(nerveRoot: string): void {
|
||||
const sensesDir = join(nerveRoot, "data", "senses");
|
||||
mkdirSync(sensesDir, { recursive: true });
|
||||
const dbPath = join(sensesDir, `${SENSE_NAME}.db`);
|
||||
const db = new DatabaseSync(dbPath);
|
||||
db.exec(
|
||||
"CREATE TABLE _signals(id INTEGER PRIMARY KEY, sense TEXT, timestamp INTEGER, payload TEXT)",
|
||||
);
|
||||
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe("nerve sense schema CLI (runCommand + temp HOME)", () => {
|
||||
let prevHome: string | undefined;
|
||||
let fakeHome: string;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn> | null;
|
||||
let capturedStdout: string;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedStdout = "";
|
||||
stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(
|
||||
(chunk: string | Uint8Array, enc?: BufferEncoding, cb?: (err?: Error | null) => void) => {
|
||||
if (typeof chunk === "string") {
|
||||
capturedStdout += chunk;
|
||||
} else {
|
||||
capturedStdout += Buffer.from(chunk).toString(typeof enc === "string" ? enc : "utf8");
|
||||
}
|
||||
if (typeof cb === "function") {
|
||||
cb();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
prevHome = process.env.HOME;
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-sense-schema-e2e-"));
|
||||
process.env.HOME = fakeHome;
|
||||
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
createFakeSenseDb(nerveRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutSpy?.mockRestore();
|
||||
stdoutSpy = null;
|
||||
if (prevHome === undefined) {
|
||||
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prints CREATE TABLE statements for the sense database", async () => {
|
||||
await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] });
|
||||
expect(capturedStdout).toMatch(/CREATE TABLE/i);
|
||||
});
|
||||
|
||||
it("includes the _signals table in output", async () => {
|
||||
await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] });
|
||||
expect(capturedStdout).toContain("_signals");
|
||||
});
|
||||
|
||||
it("with --json prints a valid JSON array of SQL strings", async () => {
|
||||
await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME, "--json"] });
|
||||
const parsed: unknown = JSON.parse(capturedStdout.trim());
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
const arr = parsed as unknown[];
|
||||
expect(arr.length).toBeGreaterThanOrEqual(1);
|
||||
for (const item of arr) {
|
||||
expect(typeof item).toBe("string");
|
||||
expect(item).toMatch(/CREATE TABLE/i);
|
||||
}
|
||||
const joined = arr.join("\n");
|
||||
expect(joined).toContain("_signals");
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`.
|
||||
*/
|
||||
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertSenseDbExists,
|
||||
collectColumnKeys,
|
||||
defaultPreviewSql,
|
||||
formatRowsAsAlignedTable,
|
||||
listTableSqlStatements,
|
||||
parseSenseQueryArgs,
|
||||
pickDefaultPreviewTable,
|
||||
senseDbPath,
|
||||
} from "../sense-sqlite.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = join(
|
||||
tmpdir(),
|
||||
`nerve-sense-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
mkdirSync(join(tmpDir, "data", "senses"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("senseDbPath", () => {
|
||||
it("points at data/senses/<name>.db under the given root", () => {
|
||||
expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSenseDbExists", () => {
|
||||
it("throws when the file is missing", () => {
|
||||
expect(() => assertSenseDbExists(tmpDir, "nope")).toThrow(/No database at/);
|
||||
});
|
||||
|
||||
it("returns the path when the file exists", () => {
|
||||
const p = join(tmpDir, "data", "senses", "x.db");
|
||||
new DatabaseSync(p).close();
|
||||
expect(assertSenseDbExists(tmpDir, "x")).toBe(p);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listTableSqlStatements", () => {
|
||||
it("returns CREATE statements ordered by tbl_name", () => {
|
||||
const p = join(tmpDir, "data", "senses", "t.db");
|
||||
const db = new DatabaseSync(p);
|
||||
db.exec("CREATE TABLE zebra (id INTEGER)");
|
||||
db.exec("CREATE TABLE alpha (id INTEGER)");
|
||||
const stmts = listTableSqlStatements(db);
|
||||
db.close();
|
||||
expect(stmts).toHaveLength(2);
|
||||
expect(stmts[0]).toMatch(/^CREATE TABLE alpha/i);
|
||||
expect(stmts[1]).toMatch(/^CREATE TABLE zebra/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDefaultPreviewTable", () => {
|
||||
it("prefers non-_migrations tables when both exist", () => {
|
||||
const p = join(tmpDir, "data", "senses", "t.db");
|
||||
const db = new DatabaseSync(p);
|
||||
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
|
||||
db.exec("CREATE TABLE readings (id INTEGER)");
|
||||
expect(pickDefaultPreviewTable(db)).toBe("readings");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("uses _migrations when it is the only table", () => {
|
||||
const p = join(tmpDir, "data", "senses", "t.db");
|
||||
const db = new DatabaseSync(p);
|
||||
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
|
||||
expect(pickDefaultPreviewTable(db)).toBe("_migrations");
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultPreviewSql", () => {
|
||||
it("quotes identifiers for SQL safety", () => {
|
||||
expect(defaultPreviewSql(`weird"name`)).toContain(`weird""name`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSenseQueryArgs", () => {
|
||||
it("parses sense name only", () => {
|
||||
expect(parseSenseQueryArgs(["cpu"])).toEqual({ name: "cpu", sql: undefined });
|
||||
});
|
||||
|
||||
it("strips --json", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "--json"])).toEqual({ name: "cpu", sql: undefined });
|
||||
expect(parseSenseQueryArgs(["--json", "cpu"])).toEqual({ name: "cpu", sql: undefined });
|
||||
});
|
||||
|
||||
it("joins remaining tokens into SQL", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" });
|
||||
});
|
||||
|
||||
it("uses --sql value instead of positional SQL", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT 2"])).toEqual({
|
||||
name: "cpu",
|
||||
sql: "SELECT 2",
|
||||
});
|
||||
expect(parseSenseQueryArgs(["cpu", "--sql=SELECT 3"])).toEqual({
|
||||
name: "cpu",
|
||||
sql: "SELECT 3",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers --sql over trailing positional SQL", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT a", "SELECT b"])).toEqual({
|
||||
name: "cpu",
|
||||
sql: "SELECT a",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when --sql has no value", () => {
|
||||
expect(() => parseSenseQueryArgs(["cpu", "--sql"])).toThrow(/Missing value for --sql/);
|
||||
});
|
||||
|
||||
it("throws when name is missing", () => {
|
||||
expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRowsAsAlignedTable", () => {
|
||||
it("shows empty marker for no rows", () => {
|
||||
expect(formatRowsAsAlignedTable([])).toContain("(0 rows)");
|
||||
});
|
||||
|
||||
it("aligns columns from row data", () => {
|
||||
const out = formatRowsAsAlignedTable([
|
||||
{ a: 1, b: "x" },
|
||||
{ a: 22, b: "yy" },
|
||||
]);
|
||||
expect(out).toContain("a");
|
||||
expect(out).toContain("b");
|
||||
expect(out).toContain("22");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectColumnKeys", () => {
|
||||
it("preserves key order from first row then appends new keys", () => {
|
||||
expect(
|
||||
collectColumnKeys([
|
||||
{ z: 1, a: 2 },
|
||||
{ a: 3, b: 4 },
|
||||
]),
|
||||
).toEqual(["z", "a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readonly query integration", () => {
|
||||
it("runs default preview SQL on a real db", () => {
|
||||
const p = join(tmpDir, "data", "senses", "demo.db");
|
||||
const rw = new DatabaseSync(p);
|
||||
rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)");
|
||||
rw.exec("INSERT INTO items (v) VALUES ('a'), ('b')");
|
||||
rw.close();
|
||||
|
||||
const db = new DatabaseSync(p, { readOnly: true });
|
||||
const table = pickDefaultPreviewTable(db);
|
||||
expect(table).toBe("items");
|
||||
if (table === null) {
|
||||
throw new Error("expected items table");
|
||||
}
|
||||
const sql = defaultPreviewSql(table);
|
||||
const rows = db.prepare(sql).all() as Record<string, unknown>[];
|
||||
db.close();
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dirname, join, resolve as resolvePath } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
@@ -62,6 +63,75 @@ function writeVersionFile(skillDir: string, version: string): void {
|
||||
writeFileSync(join(skillDir, ".nerve-version"), `${version}\n`, "utf8");
|
||||
}
|
||||
|
||||
const CURSOR_VERSION_MARKER_RE = /<!--\s*nerve-cli-version:\s*([^>]+?)\s*-->/;
|
||||
|
||||
function resolveCursorProjectDir(pathArg: string | null): string {
|
||||
const raw = pathArg !== null && pathArg !== "" ? pathArg : process.cwd();
|
||||
return resolvePath(raw);
|
||||
}
|
||||
|
||||
function assertDirectory(projectDir: string, label: string): void {
|
||||
if (!existsSync(projectDir)) {
|
||||
process.stderr.write(`❌ ${label} does not exist: ${projectDir}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!statSync(projectDir).isDirectory()) {
|
||||
process.stderr.write(`❌ ${label} is not a directory: ${projectDir}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function readCursorInjectVersion(projectDir: string): string | null {
|
||||
const versionPath = join(projectDir, ".nerve-version");
|
||||
if (existsSync(versionPath)) {
|
||||
return readFileSync(versionPath, "utf8").trim();
|
||||
}
|
||||
const rulesPath = join(projectDir, ".cursorrules");
|
||||
if (!existsSync(rulesPath)) return null;
|
||||
const content = readFileSync(rulesPath, "utf8");
|
||||
const match = content.match(CURSOR_VERSION_MARKER_RE);
|
||||
return match !== null ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
function injectCursor(projectDir: string): void {
|
||||
assertDirectory(projectDir, "Project directory");
|
||||
const rulesPath = join(projectDir, ".cursorrules");
|
||||
const existingVer = readCursorInjectVersion(projectDir);
|
||||
if (existingVer === cliVersion() && existsSync(rulesPath)) {
|
||||
process.stdout.write(
|
||||
`✅ Cursor .cursorrules is already up to date (v${cliVersion()}) at ${projectDir}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const templatePath = join(getSkillSourceDir(), "cursor", ".cursorrules");
|
||||
if (!existsSync(templatePath)) {
|
||||
throw new Error("Cannot locate cursor/.cursorrules template. Is the CLI package intact?");
|
||||
}
|
||||
let body = readFileSync(templatePath, "utf8");
|
||||
body = body.replaceAll("__NERVE_CLI_VERSION__", cliVersion());
|
||||
writeFileSync(rulesPath, body, "utf8");
|
||||
writeVersionFile(projectDir, cliVersion());
|
||||
|
||||
const action = existingVer !== null ? "Updated" : "Installed";
|
||||
process.stdout.write(`✅ ${action} Cursor .cursorrules v${cliVersion()} at ${projectDir}\n`);
|
||||
}
|
||||
|
||||
function removeCursor(projectDir: string): void {
|
||||
assertDirectory(projectDir, "Project directory");
|
||||
const rulesPath = join(projectDir, ".cursorrules");
|
||||
const versionPath = join(projectDir, ".nerve-version");
|
||||
if (!existsSync(rulesPath)) {
|
||||
process.stdout.write(`ℹ️ Cursor .cursorrules is not present at ${projectDir}\n`);
|
||||
return;
|
||||
}
|
||||
rmSync(rulesPath, { force: true });
|
||||
if (existsSync(versionPath)) {
|
||||
rmSync(versionPath, { force: true });
|
||||
}
|
||||
process.stdout.write(`✅ Removed Cursor .cursorrules from ${projectDir}\n`);
|
||||
}
|
||||
|
||||
function injectHermes(profile: string | null): void {
|
||||
const sourceDir = join(getSkillSourceDir(), "hermes");
|
||||
const targetDir = getHermesSkillDir(profile);
|
||||
@@ -94,9 +164,35 @@ function removeHermes(profile: string | null): void {
|
||||
process.stdout.write(`✅ Removed Hermes nerve skill${loc}\n`);
|
||||
}
|
||||
|
||||
function printCursorStatusLine(projectDir: string): void {
|
||||
const rulesPath = join(projectDir, ".cursorrules");
|
||||
const label = `Cursor (${projectDir})`;
|
||||
if (!existsSync(rulesPath)) {
|
||||
process.stdout.write(` ${label}: ❌ not installed\n`);
|
||||
return;
|
||||
}
|
||||
const ver = readCursorInjectVersion(projectDir);
|
||||
if (ver === null) {
|
||||
process.stdout.write(
|
||||
` ${label}: ⚠️ installed (unknown version; run \`nerve agent inject cursor\`)\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ver === cliVersion()) {
|
||||
process.stdout.write(` ${label}: ✅ v${ver}\n`);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
` ${label}: ⚠️ v${ver} → v${cliVersion()} available (run \`nerve agent inject cursor\`)\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printStatus(): void {
|
||||
process.stdout.write(`nerve agent skills (CLI v${cliVersion()})\n\n`);
|
||||
|
||||
printCursorStatusLine(process.cwd());
|
||||
process.stdout.write("\n");
|
||||
|
||||
// Default profile
|
||||
const defaultDir = getHermesSkillDir(null);
|
||||
const defaultVer = readVersionFile(defaultDir);
|
||||
@@ -141,20 +237,39 @@ const injectCommand = defineCommand({
|
||||
args: {
|
||||
target: {
|
||||
type: "positional",
|
||||
description: "Agent target: hermes",
|
||||
description: "Agent target: hermes | cursor",
|
||||
},
|
||||
profile: {
|
||||
type: "string",
|
||||
description: "Hermes profile name (default: main profile)",
|
||||
},
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Project directory for Cursor rules (default: cwd); only used with cursor",
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
if (args.target !== "hermes") {
|
||||
process.stderr.write(`❌ Unknown agent target: ${args.target}\n`);
|
||||
process.stderr.write(" Supported targets: hermes\n");
|
||||
process.exit(1);
|
||||
const target = args.target;
|
||||
if (target === "hermes") {
|
||||
if (args.path != null && args.path !== "") {
|
||||
process.stderr.write("❌ --path applies only to the cursor target\n");
|
||||
process.exit(1);
|
||||
}
|
||||
injectHermes(args.profile ?? null);
|
||||
return;
|
||||
}
|
||||
injectHermes(args.profile ?? null);
|
||||
if (target === "cursor") {
|
||||
if (args.profile != null && args.profile !== "") {
|
||||
process.stderr.write("❌ --profile applies only to the hermes target\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const pathArg = args.path != null && args.path !== "" ? args.path : null;
|
||||
injectCursor(resolveCursorProjectDir(pathArg));
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
|
||||
process.stderr.write(" Supported targets: hermes, cursor\n");
|
||||
process.exit(1);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -203,20 +318,39 @@ const removeCommand = defineCommand({
|
||||
args: {
|
||||
target: {
|
||||
type: "positional",
|
||||
description: "Agent target: hermes",
|
||||
description: "Agent target: hermes | cursor",
|
||||
},
|
||||
profile: {
|
||||
type: "string",
|
||||
description: "Hermes profile name (default: main profile)",
|
||||
},
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Project directory for Cursor rules (default: cwd); only used with cursor",
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
if (args.target !== "hermes") {
|
||||
process.stderr.write(`❌ Unknown agent target: ${args.target}\n`);
|
||||
process.stderr.write(" Supported targets: hermes\n");
|
||||
process.exit(1);
|
||||
const target = args.target;
|
||||
if (target === "hermes") {
|
||||
if (args.path != null && args.path !== "") {
|
||||
process.stderr.write("❌ --path applies only to the cursor target\n");
|
||||
process.exit(1);
|
||||
}
|
||||
removeHermes(args.profile ?? null);
|
||||
return;
|
||||
}
|
||||
removeHermes(args.profile ?? null);
|
||||
if (target === "cursor") {
|
||||
if (args.profile != null && args.profile !== "") {
|
||||
process.stderr.write("❌ --profile applies only to the hermes target\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const pathArg = args.path != null && args.path !== "" ? args.path : null;
|
||||
removeCursor(resolveCursorProjectDir(pathArg));
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
|
||||
process.stderr.write(" Supported targets: hermes, cursor\n");
|
||||
process.exit(1);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -85,76 +85,26 @@ and optionally load this file at runtime if you keep prompts outside code.
|
||||
`;
|
||||
}
|
||||
|
||||
function senseIdToSqlTableName(id: string): string {
|
||||
return id.replaceAll("-", "_");
|
||||
}
|
||||
export function buildSenseIndexTs(_senseId: string): string {
|
||||
return `type SenseState = {
|
||||
lastRun: number | null;
|
||||
};
|
||||
|
||||
function senseIdToSchemaExportName(id: string): string {
|
||||
const parts = id.split("-");
|
||||
return parts
|
||||
.map((part, index) =>
|
||||
index === 0 ? part : part.length === 0 ? "" : part.charAt(0).toUpperCase() + part.slice(1),
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
export const initialState: SenseState = { lastRun: null };
|
||||
|
||||
export function buildSenseSchemaTs(senseId: string): string {
|
||||
const table = senseIdToSqlTableName(senseId);
|
||||
const exportName = senseIdToSchemaExportName(senseId);
|
||||
return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const ${exportName} = sqliteTable("${table}", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ts: integer("ts").notNull(),
|
||||
label: text("label").notNull(),
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildSenseIndexTs(senseId: string): string {
|
||||
const exportName = senseIdToSchemaExportName(senseId);
|
||||
return `import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
|
||||
import { ${exportName} } from "./schema.js";
|
||||
|
||||
export { ${exportName} as table } from "./schema.js";
|
||||
|
||||
type SenseResult = {
|
||||
signal: { label: string; ts: number };
|
||||
export async function compute(state: SenseState): Promise<{
|
||||
state: SenseState;
|
||||
workflow: null;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* ${senseId} — replace this stub with your sampling logic.
|
||||
* Returns non-null to emit a signal, null to stay silent.
|
||||
*/
|
||||
export async function compute(
|
||||
db: LibSQLDatabase,
|
||||
_peers: Record<string, LibSQLDatabase>,
|
||||
_options: { signal: AbortSignal },
|
||||
): Promise<SenseResult> {
|
||||
void ${exportName};
|
||||
}> {
|
||||
// TODO: implement sense logic
|
||||
return {
|
||||
signal: {
|
||||
label: "${senseId}",
|
||||
ts: Date.now(),
|
||||
},
|
||||
state: { lastRun: Date.now() },
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildSenseMigrationSql(senseId: string): string {
|
||||
const table = senseIdToSqlTableName(senseId);
|
||||
return `CREATE TABLE IF NOT EXISTS ${table} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
label TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
}
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
@@ -278,15 +228,10 @@ const createSenseCommand = defineCommand({
|
||||
}
|
||||
|
||||
mkdirSync(join(senseDir, "src"), { recursive: true });
|
||||
mkdirSync(join(senseDir, "migrations"), { recursive: true });
|
||||
writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name));
|
||||
writeFile(join(senseDir, "src", "schema.ts"), buildSenseSchemaTs(args.name));
|
||||
writeFile(join(senseDir, "migrations", "0001_init.sql"), buildSenseMigrationSql(args.name));
|
||||
|
||||
process.stdout.write("✅ Sense scaffolded:\n");
|
||||
process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "src", "schema.ts")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "migrations", "0001_init.sql")}\n`);
|
||||
|
||||
process.stdout.write("\nBuilding workspace (senses + workflows)…\n");
|
||||
try {
|
||||
|
||||
@@ -54,13 +54,11 @@ const PACKAGE_JSON = `${JSON.stringify(
|
||||
dependencies: {
|
||||
"@uncaged/nerve-core": "latest",
|
||||
"@uncaged/nerve-daemon": "latest",
|
||||
"drizzle-orm": "latest",
|
||||
zod: "^4.3.6",
|
||||
},
|
||||
devDependencies: {
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/node": "^22.0.0",
|
||||
"drizzle-kit": "latest",
|
||||
esbuild: "^0.27.0",
|
||||
typescript: "^5.7.0",
|
||||
},
|
||||
@@ -139,9 +137,8 @@ This file is created by \`nerve init\`. Read it before implementing senses or wo
|
||||
| \`nerve.yaml\` | Senses, workflows, intervals, groups |
|
||||
| \`package.json\` | Single root package — no per-sense/per-workflow packages |
|
||||
| \`scripts/build.mjs\` | Root esbuild step; output under \`dist/\` |
|
||||
| \`senses/<name>/src/index.ts\` | Sense \`compute()\` entry |
|
||||
| \`senses/<name>/src/schema.ts\` | Drizzle SQLite schema (TypeScript) |
|
||||
| \`senses/<name>/migrations/*.sql\` | SQL migrations (next to \`src/\`, not inside it) |
|
||||
| \`senses/<name>/src/index.ts\` | Sense \`compute()\` + \`initialState\` |
|
||||
| \`data/senses/<name>.json\` | Persisted sense state (written by the daemon) |
|
||||
| \`workflows/<name>/index.ts\` | Default export: \`WorkflowDefinition\` |
|
||||
| \`workflows/<name>/roles/<role>.ts\` | One TypeScript file per role |
|
||||
| \`dist/senses/<name>/index.js\` | Bundled sense (after build) |
|
||||
@@ -217,58 +214,25 @@ There is no separate npm package for skills in the default workspace. To align w
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
const CPU_INDEX_TS = `import { loadavg } from "node:os";
|
||||
|
||||
export const cpuUsage = sqliteTable("cpu_usage", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ts: integer("ts").notNull(),
|
||||
model: text("model").notNull(),
|
||||
loadPercent: real("load_percent").notNull(),
|
||||
});
|
||||
`;
|
||||
|
||||
const CPU_INDEX_TS = `import { cpus } from "node:os";
|
||||
|
||||
export { cpuUsage as table } from "./schema.js";
|
||||
|
||||
type SenseResult = {
|
||||
signal: { model: string; loadPercent: number; ts: number };
|
||||
workflow: null;
|
||||
type CpuState = {
|
||||
samples: Array<{ ts: number; value: number }>;
|
||||
};
|
||||
|
||||
export async function compute(): Promise<SenseResult> {
|
||||
const cpuList = cpus();
|
||||
export const initialState: CpuState = { samples: [] };
|
||||
|
||||
let totalIdle = 0;
|
||||
let totalTick = 0;
|
||||
for (const cpu of cpuList) {
|
||||
for (const [, time] of Object.entries(cpu.times)) {
|
||||
totalTick += time;
|
||||
}
|
||||
totalIdle += cpu.times.idle;
|
||||
}
|
||||
|
||||
const loadPercent = totalTick === 0 ? 0 : ((totalTick - totalIdle) / totalTick) * 100;
|
||||
|
||||
return {
|
||||
signal: {
|
||||
model: cpuList[0]?.model ?? "unknown",
|
||||
loadPercent: Math.round(loadPercent * 100) / 100,
|
||||
ts: Date.now(),
|
||||
},
|
||||
workflow: null,
|
||||
};
|
||||
export async function compute(state: CpuState): Promise<{
|
||||
state: CpuState;
|
||||
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 }, workflow: null };
|
||||
}
|
||||
`;
|
||||
|
||||
const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
load_percent REAL NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
@@ -419,7 +383,6 @@ async function runInitWorkspace(force: boolean, skipInstall = false): Promise<vo
|
||||
mkdirSync(join(nerveRoot, "data"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "src"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "migrations"), { recursive: true });
|
||||
|
||||
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
|
||||
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
|
||||
@@ -428,11 +391,6 @@ async function runInitWorkspace(force: boolean, skipInstall = false): Promise<vo
|
||||
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
|
||||
writeFile(join(nerveRoot, "AGENT.md"), AGENT_MD);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"), CPU_INDEX_TS);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"), CPU_SCHEMA_TS);
|
||||
writeFile(
|
||||
join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"),
|
||||
CPU_MIGRATION_SQL,
|
||||
);
|
||||
writeFile(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"), NERVE_SKILLS_MDC);
|
||||
|
||||
if (!skipInstall) {
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import {
|
||||
type SenseInfo,
|
||||
isPlainRecord,
|
||||
parseNerveConfig,
|
||||
senseTriggerLabels,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { type SenseInfo, parseNerveConfig, senseTriggerLabels } from "@uncaged/nerve-core";
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { isRemoteDaemonCli } from "../cli-global.js";
|
||||
import { resolveDaemonTransport } from "../daemon-client.js";
|
||||
import {
|
||||
defaultPreviewSql,
|
||||
formatRowsAsAlignedTable,
|
||||
listTableSqlStatements,
|
||||
openSenseDb,
|
||||
parseSenseQueryArgs,
|
||||
pickDefaultPreviewTable,
|
||||
} from "../sense-sqlite.js";
|
||||
import { getNerveRoot, isRunning } from "../workspace.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,9 +38,6 @@ export function formatSenseList(senses: SenseInfo[]): string {
|
||||
lines.push(
|
||||
` trigger schedule: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`,
|
||||
);
|
||||
const lastSignal =
|
||||
s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)";
|
||||
lines.push(` last signal: ${lastSignal}\n`);
|
||||
}
|
||||
return lines.join("");
|
||||
}
|
||||
@@ -76,7 +59,6 @@ export function sensesFromConfig(configPath: string): SenseInfo[] {
|
||||
throttle: cfg.throttle,
|
||||
timeout: cfg.timeout,
|
||||
triggers: senseTriggerLabels(name, senses),
|
||||
lastSignalTimestamp: null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -92,7 +74,7 @@ const senseListCommand = defineCommand({
|
||||
async run() {
|
||||
if (!isRemoteDaemonCli() && !isRunning()) {
|
||||
process.stderr.write(
|
||||
"⚠️ Daemon is not running — showing static config only (no last signal time).\n\n",
|
||||
"⚠️ Daemon is not running — showing static config from nerve.yaml only.\n\n",
|
||||
);
|
||||
const configPath = join(getNerveRoot(), "nerve.yaml");
|
||||
const senses = sensesFromConfig(configPath);
|
||||
@@ -154,116 +136,6 @@ const senseTriggerCommand = defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense schema <name>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const senseSchemaCommand = defineCommand({
|
||||
meta: {
|
||||
name: "schema",
|
||||
description: "Print CREATE TABLE statements from a sense SQLite database",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
|
||||
},
|
||||
json: {
|
||||
type: "boolean",
|
||||
description: "Print JSON array of CREATE TABLE SQL strings",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
let db: DatabaseSync | undefined;
|
||||
try {
|
||||
db = openSenseDb(nerveRoot, args.name);
|
||||
const statements = listTableSqlStatements(db);
|
||||
if (args.json) {
|
||||
process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`);
|
||||
} else if (statements.length === 0) {
|
||||
process.stdout.write("(no tables)\n");
|
||||
} else {
|
||||
for (const sql of statements) {
|
||||
process.stdout.write(`${sql};\n\n`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ ${msg}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense query <name> [sql...]
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const senseQueryCommand = defineCommand({
|
||||
meta: {
|
||||
name: "query",
|
||||
description:
|
||||
'Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name, or use --sql "…".',
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
|
||||
},
|
||||
json: {
|
||||
type: "boolean",
|
||||
description: "Print result rows as JSON",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args, rawArgs }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
let db: DatabaseSync | undefined;
|
||||
try {
|
||||
let parsed: { name: string; sql: string | undefined };
|
||||
try {
|
||||
parsed = parseSenseQueryArgs(rawArgs);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db = openSenseDb(nerveRoot, args.name);
|
||||
|
||||
let sql = parsed.sql?.trim();
|
||||
if (!sql) {
|
||||
const table = pickDefaultPreviewTable(db);
|
||||
if (table === null) {
|
||||
process.stderr.write("❌ No tables found in database.\n");
|
||||
process.exit(1);
|
||||
} else {
|
||||
sql = defaultPreviewSql(table);
|
||||
}
|
||||
}
|
||||
|
||||
const rawRows: unknown[] = db.prepare(sql).all();
|
||||
const rows: Record<string, unknown>[] = rawRows.filter(isPlainRecord);
|
||||
|
||||
if (args.json) {
|
||||
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(formatRowsAsAlignedTable(rows));
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ ${msg}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense (parent command)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -276,7 +148,5 @@ export const senseCommand = defineCommand({
|
||||
subCommands: {
|
||||
list: senseListCommand,
|
||||
trigger: senseTriggerCommand,
|
||||
schema: senseSchemaCommand,
|
||||
query: senseQueryCommand,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,6 +97,5 @@ export const statusCommand = defineCommand({
|
||||
process.stdout.write(
|
||||
` workers: ${workerGroups.length > 0 ? workerGroups.join(", ") : "(none)"}\n`,
|
||||
);
|
||||
process.stdout.write(" signals: (pending SignalBus persistence)\n");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { stringify } from "yaml";
|
||||
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
|
||||
import { isRemoteDaemonCli } from "../cli-global.js";
|
||||
import { resolveDaemonTransport } from "../daemon-client.js";
|
||||
import { formatRowsAsAlignedTable } from "../sense-sqlite.js";
|
||||
import { formatRowsAsAlignedTable } from "../table-format.js";
|
||||
import { loadDaemonModule } from "../workspace-daemon.js";
|
||||
import { getNerveRoot, isRunning } from "../workspace.js";
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** SQLite path for a sense under the nerve workspace root. */
|
||||
export function senseDbPath(nerveRoot: string, senseName: string): string {
|
||||
return join(nerveRoot, "data", "senses", `${senseName}.db`);
|
||||
}
|
||||
|
||||
export function assertSenseDbExists(nerveRoot: string, senseName: string): string {
|
||||
const path = senseDbPath(nerveRoot, senseName);
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`No database at ${path}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Open a sense SQLite database in readonly mode using node:sqlite. */
|
||||
export function openSenseDb(nerveRoot: string, senseName: string): DatabaseSync {
|
||||
const path = assertSenseDbExists(nerveRoot, senseName);
|
||||
return new DatabaseSync(path, { readOnly: true });
|
||||
}
|
||||
|
||||
/** `SELECT sql FROM sqlite_master WHERE type='table'` (non-null sql only). */
|
||||
export function listTableSqlStatements(db: DatabaseSync): string[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`,
|
||||
)
|
||||
.all() as { sql: string }[];
|
||||
return rows.map((r) => r.sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Table used for `nerve sense query <name>` with no SQL.
|
||||
* Prefers real data tables over `_migrations`, then lexicographic by name.
|
||||
*/
|
||||
export function pickDefaultPreviewTable(db: DatabaseSync): string | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND sql IS NOT NULL
|
||||
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
|
||||
ORDER BY
|
||||
CASE WHEN name = '_signals' THEN 0
|
||||
WHEN name = '_migrations' THEN 2
|
||||
ELSE 1 END,
|
||||
name
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get() as { name: string } | undefined;
|
||||
return row?.name ?? null;
|
||||
}
|
||||
|
||||
export function defaultPreviewSql(table: string): string {
|
||||
return `SELECT * FROM "${table.replace(/"/g, '""')}" ORDER BY rowid DESC LIMIT 10`;
|
||||
}
|
||||
|
||||
function endIndexAfterGenericDashArg(rawArgs: string[], i: number): number {
|
||||
const a = rawArgs[i];
|
||||
const eq = a.indexOf("=");
|
||||
if (eq === -1 && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) {
|
||||
return i + 1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
type SenseQueryArgStep = {
|
||||
nextIndex: number;
|
||||
flagSql: string | undefined;
|
||||
positionalToken: string | null;
|
||||
};
|
||||
|
||||
function nextSenseQueryArgStep(rawArgs: string[], i: number): SenseQueryArgStep {
|
||||
const a = rawArgs[i];
|
||||
if (a === "--json" || a === "--no-json") {
|
||||
return { nextIndex: i, flagSql: undefined, positionalToken: null };
|
||||
}
|
||||
if (a.startsWith("--sql=")) {
|
||||
return { nextIndex: i, flagSql: a.slice("--sql=".length), positionalToken: null };
|
||||
}
|
||||
if (a === "--sql") {
|
||||
if (i + 1 >= rawArgs.length || rawArgs[i + 1].startsWith("-")) {
|
||||
throw new Error("Missing value for --sql");
|
||||
}
|
||||
return { nextIndex: i + 1, flagSql: rawArgs[i + 1], positionalToken: null };
|
||||
}
|
||||
if (a.startsWith("-")) {
|
||||
return {
|
||||
nextIndex: endIndexAfterGenericDashArg(rawArgs, i),
|
||||
flagSql: undefined,
|
||||
positionalToken: null,
|
||||
};
|
||||
}
|
||||
return { nextIndex: i, flagSql: undefined, positionalToken: a };
|
||||
}
|
||||
|
||||
/** Parse sense name and optional SQL from subcommand raw argv (flags stripped). */
|
||||
export function parseSenseQueryArgs(rawArgs: string[]): { name: string; sql: string | undefined } {
|
||||
let flagSql: string | undefined;
|
||||
const pos: string[] = [];
|
||||
let i = 0;
|
||||
while (i < rawArgs.length) {
|
||||
const step = nextSenseQueryArgStep(rawArgs, i);
|
||||
if (step.flagSql !== undefined) {
|
||||
flagSql = step.flagSql;
|
||||
}
|
||||
if (step.positionalToken !== null) {
|
||||
pos.push(step.positionalToken);
|
||||
}
|
||||
i = step.nextIndex + 1;
|
||||
}
|
||||
if (pos.length < 1) {
|
||||
throw new Error("Missing sense name");
|
||||
}
|
||||
const name = pos[0];
|
||||
const positionalSql = pos.length > 1 ? pos.slice(1).join(" ") : undefined;
|
||||
const trimmedFlag = flagSql?.trim();
|
||||
const sql = trimmedFlag !== undefined && trimmedFlag.length > 0 ? trimmedFlag : positionalSql;
|
||||
return { name, sql };
|
||||
}
|
||||
|
||||
function stringifyCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (typeof value === "string") return value;
|
||||
if (Buffer.isBuffer(value)) return value.toString("hex");
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect column keys in stable order (first row keys, then any extras). */
|
||||
export function collectColumnKeys(rows: Record<string, unknown>[]): string[] {
|
||||
const keys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const k of Object.keys(row)) {
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const MAX_CELL = 64;
|
||||
|
||||
function truncate(s: string): string {
|
||||
if (s.length <= MAX_CELL) return s;
|
||||
return `${s.slice(0, MAX_CELL - 1)}…`;
|
||||
}
|
||||
|
||||
/** Plain aligned table for terminal output. */
|
||||
export function formatRowsAsAlignedTable(rows: Record<string, unknown>[]): string {
|
||||
if (rows.length === 0) {
|
||||
return "(0 rows)\n";
|
||||
}
|
||||
const cols = collectColumnKeys(rows);
|
||||
const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c]))));
|
||||
const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length)));
|
||||
const sep = widths.map((w) => "-".repeat(w)).join("-+-");
|
||||
const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | ");
|
||||
const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n");
|
||||
return `${header}\n${sep}\n${body}\n`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
function stringifyCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "bigint") return value.toString();
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (typeof value === "string") return value;
|
||||
if (Buffer.isBuffer(value)) return value.toString("hex");
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect column keys in stable order (first row keys, then any extras). */
|
||||
export function collectColumnKeys(rows: Record<string, unknown>[]): string[] {
|
||||
const keys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const k of Object.keys(row)) {
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const MAX_CELL = 64;
|
||||
|
||||
function truncate(s: string): string {
|
||||
if (s.length <= MAX_CELL) return s;
|
||||
return `${s.slice(0, MAX_CELL - 1)}…`;
|
||||
}
|
||||
|
||||
/** Plain aligned table for terminal output. */
|
||||
export function formatRowsAsAlignedTable(rows: Record<string, unknown>[]): string {
|
||||
if (rows.length === 0) {
|
||||
return "(0 rows)\n";
|
||||
}
|
||||
const cols = collectColumnKeys(rows);
|
||||
const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c]))));
|
||||
const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length)));
|
||||
const sep = widths.map((w) => "-".repeat(w)).join("-+-");
|
||||
const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | ");
|
||||
const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n");
|
||||
return `${header}\n${sep}\n${body}\n`;
|
||||
}
|
||||
+16
-21
@@ -4,18 +4,18 @@ Shared types and configuration parser for the [nerve](../../README.md) observati
|
||||
|
||||
## What's Inside
|
||||
|
||||
- **Type definitions** — `Signal`, `SenseConfig`, `SenseInfo`, `SenseReflexConfig`, `ReflexConfig` (sense-only), `WorkflowConfig`, `NerveConfig`, and related types
|
||||
- **Config parser** — `parseNerveConfig(yaml)` validates and parses `nerve.yaml` into `NerveConfig` (rejects reflex entries that declare a `workflow` key; reflexes only schedule senses)
|
||||
- **Sense → workflow routing** — `parseSenseWorkflowDirective`, `routeSenseComputeOutput`, and types `ParsedSenseWorkflowDirective`, `SenseComputeRoute`
|
||||
- **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)
|
||||
- **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`, `SenseResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS`
|
||||
- **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)
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { parseNerveConfig, ok, err } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig, Signal, Result } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig, Result } from "@uncaged/nerve-core";
|
||||
|
||||
const result: Result<NerveConfig> = parseNerveConfig(yamlString);
|
||||
if (result.ok) {
|
||||
@@ -23,29 +23,24 @@ if (result.ok) {
|
||||
}
|
||||
```
|
||||
|
||||
### Sense return → signal vs workflow
|
||||
### Workflow trigger validation
|
||||
|
||||
```typescript
|
||||
import { parseSenseWorkflowDirective, routeSenseComputeOutput } from "@uncaged/nerve-core";
|
||||
import { parseWorkflowTrigger } from "@uncaged/nerve-core";
|
||||
|
||||
const directive = parseSenseWorkflowDirective("my-workflow|8|Hello from sense");
|
||||
if (directive.ok) {
|
||||
console.log(directive.value.workflowName, directive.value.maxRounds, directive.value.prompt);
|
||||
}
|
||||
|
||||
const route = routeSenseComputeOutput({
|
||||
metric: 42,
|
||||
workflow: "my-workflow|8|Run now",
|
||||
const directive = parseWorkflowTrigger({
|
||||
name: "my-workflow",
|
||||
maxRounds: 8,
|
||||
prompt: "Hello from sense",
|
||||
dryRun: false,
|
||||
});
|
||||
if (route.kind === "launch") {
|
||||
// engine starts workflow; no Signal to the bus for this return
|
||||
console.log(route.launch);
|
||||
} else {
|
||||
// normal signal with payload
|
||||
console.log(route.payload);
|
||||
if (directive.ok) {
|
||||
console.log(directive.value.name, directive.value.maxRounds, directive.value.prompt);
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Config fields like `throttle`, `timeout`, and `interval` accept human-readable durations:
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "1.0.0-beta.23-c10d10c",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -34,7 +34,6 @@ describe("parseNerveConfig", () => {
|
||||
throttle: 5000,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: 30_000,
|
||||
on: [],
|
||||
});
|
||||
@@ -43,7 +42,6 @@ describe("parseNerveConfig", () => {
|
||||
throttle: null,
|
||||
timeout: 10_000,
|
||||
gracePeriod: 3000,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: ["high_usage"],
|
||||
});
|
||||
@@ -83,25 +81,11 @@ senses:
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses optional retention as a positive integer", () => {
|
||||
const yaml = `
|
||||
senses:
|
||||
cpu:
|
||||
group: system
|
||||
retention: 5000
|
||||
`;
|
||||
const result = parseNerveConfig(yaml);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.senses.cpu.retention).toBe(5000);
|
||||
});
|
||||
|
||||
it("accepts all valid duration suffixes (s, m, h)", () => {
|
||||
const yaml = `
|
||||
senses:
|
||||
@@ -343,45 +327,6 @@ api:
|
||||
expect(result.error.message).toMatch(/senses/);
|
||||
});
|
||||
|
||||
it("returns error when retention is zero", () => {
|
||||
const yaml = `
|
||||
senses:
|
||||
cpu:
|
||||
group: system
|
||||
retention: 0
|
||||
`;
|
||||
const result = parseNerveConfig(yaml);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toMatch(/retention.*positive integer/);
|
||||
});
|
||||
|
||||
it("returns error when retention is not an integer", () => {
|
||||
const yaml = `
|
||||
senses:
|
||||
cpu:
|
||||
group: system
|
||||
retention: 1.5
|
||||
`;
|
||||
const result = parseNerveConfig(yaml);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toMatch(/retention.*positive integer/);
|
||||
});
|
||||
|
||||
it("returns error when retention is not a number", () => {
|
||||
const yaml = `
|
||||
senses:
|
||||
cpu:
|
||||
group: system
|
||||
retention: "5000"
|
||||
`;
|
||||
const result = parseNerveConfig(yaml);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toMatch(/retention.*positive integer/);
|
||||
});
|
||||
|
||||
it("returns error for invalid throttle format", () => {
|
||||
const yaml = `
|
||||
senses:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseWorkflowTrigger, routeSenseComputeOutput } from "../sense.js";
|
||||
import { parseWorkflowTrigger } from "../sense.js";
|
||||
|
||||
describe("parseWorkflowTrigger", () => {
|
||||
it("accepts a valid trigger object", () => {
|
||||
@@ -57,60 +57,3 @@ describe("parseWorkflowTrigger", () => {
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeSenseComputeOutput", () => {
|
||||
it("wraps non-record values as signal-only", () => {
|
||||
const r = routeSenseComputeOutput(99);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value).toEqual({ signal: 99, workflow: null });
|
||||
});
|
||||
|
||||
it("wraps plain objects without signal key as signal-only", () => {
|
||||
const r = routeSenseComputeOutput({ count: 2 });
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value).toEqual({ signal: { count: 2 }, workflow: null });
|
||||
});
|
||||
|
||||
it("parses explicit signal with null workflow", () => {
|
||||
const r = routeSenseComputeOutput({ signal: { a: 1 }, workflow: null });
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value).toEqual({ signal: { a: 1 }, workflow: null });
|
||||
});
|
||||
|
||||
it("parses explicit signal with workflow trigger", () => {
|
||||
const r = routeSenseComputeOutput({
|
||||
signal: { x: true },
|
||||
workflow: { name: "wf", maxRounds: 2, prompt: "p", dryRun: false },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.signal).toEqual({ x: true });
|
||||
expect(r.value.workflow).toEqual({
|
||||
name: "wf",
|
||||
maxRounds: 2,
|
||||
prompt: "p",
|
||||
dryRun: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing workflow key to null", () => {
|
||||
const r = routeSenseComputeOutput({ signal: 7 });
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value).toEqual({ signal: 7, workflow: null });
|
||||
});
|
||||
|
||||
it("degrades to signal-only when workflow object is invalid", () => {
|
||||
const r = routeSenseComputeOutput({
|
||||
signal: { v: 1 },
|
||||
workflow: { name: "w", maxRounds: 0, prompt: "", dryRun: false },
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.signal).toEqual({ v: 1 });
|
||||
expect(r.value.workflow).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,9 +21,3 @@ export class ExtractError extends Error {
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent adapter ids referenced by tooling / docs (RFC-003).
|
||||
* Workflows import adapter packages directly; echo may be used in tests via a small factory.
|
||||
*/
|
||||
export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const;
|
||||
|
||||
@@ -8,11 +8,9 @@ export type SenseConfig = {
|
||||
throttle: number | null;
|
||||
timeout: number | null;
|
||||
gracePeriod: number | null;
|
||||
/** Max rows to retain in `_signals`; older rows are pruned periodically after inserts. */
|
||||
retention: number;
|
||||
/** Polling interval (ms). When set, the sense is triggered periodically. */
|
||||
interval: number | null;
|
||||
/** Other sense names whose signals trigger this sense. */
|
||||
/** Other sense names whose successful computes schedule this sense (kernel reverse-index). */
|
||||
on: string[];
|
||||
};
|
||||
|
||||
@@ -62,12 +60,6 @@ export type WorkflowTrigger = {
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sense `compute()` return: silence, or a signal payload with an optional workflow to start.
|
||||
* `workflow: null` means signal only; signal is always emitted first when non-null.
|
||||
*/
|
||||
export type ComputeResult<T> = null | { signal: T; workflow: WorkflowTrigger | null };
|
||||
|
||||
export type NerveConfig = {
|
||||
/** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */
|
||||
maxRounds: number;
|
||||
@@ -83,23 +75,10 @@ export type KnowledgeConfig = {
|
||||
exclude: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
/** Default max rows kept in each sense's `_signals` SQLite table (see `retention` on `SenseConfig`). */
|
||||
export const DEFAULT_SENSE_SIGNAL_RETENTION = 10_000;
|
||||
|
||||
function isValidGroupName(value: string): boolean {
|
||||
return /^[a-zA-Z0-9_-]+$/.test(value);
|
||||
}
|
||||
|
||||
function parseRetentionField(name: string, field: unknown): Result<number> {
|
||||
if (field === undefined || field === null) {
|
||||
return ok(DEFAULT_SENSE_SIGNAL_RETENTION);
|
||||
}
|
||||
if (typeof field !== "number" || !Number.isInteger(field) || field < 1) {
|
||||
return err(new Error(`senses.${name}.retention: must be a positive integer`));
|
||||
}
|
||||
return ok(field);
|
||||
}
|
||||
|
||||
function parseDurationField(field: unknown, label: string): Result<number | null> {
|
||||
if (field === undefined || field === null) return ok(null);
|
||||
if (typeof field !== "string") {
|
||||
@@ -142,9 +121,6 @@ function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
|
||||
const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`);
|
||||
if (!graceResult.ok) return graceResult;
|
||||
|
||||
const retentionResult = parseRetentionField(name, obj.retention);
|
||||
if (!retentionResult.ok) return retentionResult;
|
||||
|
||||
const intervalResult = parseDurationField(obj.interval, `senses.${name}.interval`);
|
||||
if (!intervalResult.ok) return intervalResult;
|
||||
|
||||
@@ -164,7 +140,6 @@ function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
|
||||
throttle: throttleResult.value,
|
||||
timeout: timeoutResult.value,
|
||||
gracePeriod: graceResult.value,
|
||||
retention: retentionResult.value,
|
||||
interval: intervalResult.value,
|
||||
on,
|
||||
});
|
||||
|
||||
@@ -186,8 +186,7 @@ export function isSenseInfo(value: unknown): value is SenseInfo {
|
||||
(value.throttle === null || typeof value.throttle === "number") &&
|
||||
(value.timeout === null || typeof value.timeout === "number") &&
|
||||
Array.isArray(value.triggers) &&
|
||||
value.triggers.every((t: unknown) => typeof t === "string") &&
|
||||
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
|
||||
value.triggers.every((t: unknown) => typeof t === "string")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { DEFAULT_SENSE_SIGNAL_RETENTION } from "./config.js";
|
||||
export type {
|
||||
SenseConfig,
|
||||
DropOverflowConfig,
|
||||
@@ -9,11 +8,10 @@ export type {
|
||||
ExtractConfig,
|
||||
NerveConfig,
|
||||
WorkflowTrigger,
|
||||
ComputeResult,
|
||||
} from "./config.js";
|
||||
export type { Signal, SenseInfo } from "./sense.js";
|
||||
export type { SenseInfo } from "./sense.js";
|
||||
export type { SenseComputeFn, SenseModule } from "./sense.js";
|
||||
export { labelSenseTrigger, senseTriggerLabels } from "./sense.js";
|
||||
export { senseTriggerLabels } from "./sense.js";
|
||||
export type {
|
||||
WorkflowMessage,
|
||||
RoleResult,
|
||||
@@ -29,7 +27,6 @@ export type {
|
||||
WorkflowDefinition,
|
||||
} from "./workflow.js";
|
||||
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
|
||||
export { parseDurationStringToMs } from "./util.js";
|
||||
export type { Schema, ExtractFn } from "./agent.js";
|
||||
export { ExtractError } from "./agent.js";
|
||||
export type { Result } from "./util.js";
|
||||
@@ -46,10 +43,8 @@ export { parseNerveConfig } from "./config.js";
|
||||
export type { KnowledgeConfig } from "./config.js";
|
||||
export { parseKnowledgeYaml } from "./config.js";
|
||||
export { isPlainRecord } from "./util.js";
|
||||
export { KNOWN_AGENT_ADAPTER_IDS } from "./agent.js";
|
||||
|
||||
export type { RoutedSenseOutput } from "./sense.js";
|
||||
export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense.js";
|
||||
export { parseWorkflowTrigger } from "./sense.js";
|
||||
|
||||
export { isSenseInfo, isWorkflowStatus } from "./daemon.js";
|
||||
export type {
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import type { ComputeResult, SenseConfig, WorkflowTrigger } from "./config.js";
|
||||
import type { SenseConfig, WorkflowTrigger } from "./config.js";
|
||||
import { type Result, err, isPlainRecord, ok } from "./util.js";
|
||||
|
||||
export type Signal = {
|
||||
id: number;
|
||||
senseId: string;
|
||||
payload: unknown;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
|
||||
export type SenseInfo = {
|
||||
name: string;
|
||||
@@ -17,8 +8,7 @@ export type SenseInfo = {
|
||||
throttle: number | null;
|
||||
timeout: number | null;
|
||||
/** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */
|
||||
triggers: string[];
|
||||
lastSignalTimestamp: number | null;
|
||||
triggers: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -26,25 +16,18 @@ export type SenseInfo = {
|
||||
* `compute` export.
|
||||
*
|
||||
* Pure: no DB, no peers.
|
||||
* Return `null` to stay silent, or `{ signal, workflow }` to emit a Signal
|
||||
* (and optionally trigger a Workflow).
|
||||
* The runtime handles persistence via `db.insert(table).values(result.signal)`.
|
||||
* Returns the next sense state and an optional workflow to start (`workflow: null` means no workflow).
|
||||
*/
|
||||
export type SenseComputeFn<T = unknown> = () => Promise<ComputeResult<T>>;
|
||||
export type SenseComputeFn<S = unknown> = (
|
||||
state: S,
|
||||
) => Promise<{ state: S; workflow: WorkflowTrigger | null }>;
|
||||
|
||||
/**
|
||||
* The full shape a sense module (`src/index.ts`) must export.
|
||||
* `compute` provides the data; `table` tells the runtime where to persist it.
|
||||
*/
|
||||
export type SenseModule<T = unknown> = {
|
||||
compute: SenseComputeFn<T>;
|
||||
table: SQLiteTable;
|
||||
};
|
||||
|
||||
/** Normalized non-null compute output for the kernel (unknown signal payload). */
|
||||
export type RoutedSenseOutput = {
|
||||
signal: unknown;
|
||||
workflow: WorkflowTrigger | null;
|
||||
export type SenseModule<S = unknown> = {
|
||||
compute: SenseComputeFn<S>;
|
||||
initialState: S;
|
||||
};
|
||||
|
||||
function formatIntervalMs(ms: number): string {
|
||||
@@ -111,23 +94,3 @@ export function parseWorkflowTrigger(value: unknown): Result<WorkflowTrigger> {
|
||||
}
|
||||
return ok({ name: nameRaw.trim(), maxRounds, prompt, dryRun });
|
||||
}
|
||||
|
||||
/**
|
||||
* Interprets a Sense compute non-null return value for the engine.
|
||||
* - Explicit `{ signal, workflow }` (workflow may be null): validates `workflow` when non-null.
|
||||
* - Any other value: treated as `{ signal: payload, workflow: null }` (shorthand).
|
||||
*/
|
||||
export function routeSenseComputeOutput(payload: unknown): Result<RoutedSenseOutput> {
|
||||
if (isPlainRecord(payload) && Object.hasOwn(payload, "signal")) {
|
||||
const wfRaw = Object.hasOwn(payload, "workflow") ? payload.workflow : null;
|
||||
if (wfRaw === null) {
|
||||
return ok({ signal: payload.signal, workflow: null });
|
||||
}
|
||||
const parsed = parseWorkflowTrigger(wfRaw);
|
||||
if (!parsed.ok) {
|
||||
return ok({ signal: payload.signal, workflow: null });
|
||||
}
|
||||
return ok({ signal: payload.signal, workflow: parsed.value });
|
||||
}
|
||||
return ok({ signal: payload, workflow: null });
|
||||
}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
# @uncaged/nerve-daemon
|
||||
|
||||
The observation engine runtime for [nerve](../../README.md) — runs senses, routes signals, runs the sense scheduler, and manages workflows.
|
||||
The observation engine runtime for [nerve](../../README.md) — runs senses, persists JSON state, runs the sense scheduler, and manages workflows.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Module | Source (indicative) | Responsibility |
|
||||
|--------|---------------------|----------------|
|
||||
| **Kernel** | `kernel.ts` | Orchestrator — worker pool, signal bus, sense scheduler, workflow manager, optional file watcher and daemon IPC, config reload hooks |
|
||||
| **Kernel** | `kernel.ts` | Orchestrator — worker pool, sense scheduler, workflow manager, optional file watcher and daemon IPC, config reload hooks |
|
||||
| **Worker pool** | `worker-pool.ts` | Fork and supervise one child process per sense group; restart/shutdown; crash cleanup hooks for scheduler state |
|
||||
| **Kernel sense groups** | `kernel-sense-groups.ts` | Derive sense groups from config; list senses per group for scheduling |
|
||||
| **Sense runtime** | sense worker + Drizzle | Per-sense SQLite (`node:sqlite`), migrations, peer DB reads |
|
||||
| **Sense worker** | `sense-worker.ts` (fork target) | Child process entry — runs `compute()` per sense in a group |
|
||||
| **Signal bus** | `signal-bus.ts` | In-memory pub/sub for sense signals |
|
||||
| **Sense scheduler** | `sense-scheduler.ts` | Interval + `on` subscriptions, throttle/coalesce |
|
||||
| **Sense runtime** | `sense-runtime.ts` + sense worker | Loads user modules (`compute`, `initialState`), reads/writes `data/senses/<name>.json` |
|
||||
| **Sense worker** | `sense-worker.ts` (fork target) | Child process entry — runs `compute(state)` per sense in a group |
|
||||
| **Sense scheduler** | `sense-scheduler.ts` | Interval + `on` subscriptions (reverse-index by upstream sense), throttle/coalesce |
|
||||
| **Workflow manager** | `workflow-manager.ts` | One worker per workflow name, concurrency (drop/queue), queue caps |
|
||||
| **Workflow worker** | `workflow-worker.ts` | Child process — runs RFC-002 threads (`start-thread`, `resume-thread` IPC) |
|
||||
| **IPC (parent ↔ workers)** | `ipc.ts` | Typed messages for sense and workflow workers (includes `resume-thread` for recovery) |
|
||||
| **Log / workflow persistence** | via `@uncaged/nerve-store` | Structured logs, `workflow_runs`, thread messages (used for recovery) |
|
||||
| **Blob store** | `@uncaged/nerve-store` | CAS under `data/blobs/` — sense workers construct `createBlobStore(join(nerveRoot, "data", "blobs"))` for artifact writes |
|
||||
| **Blob store** | `@uncaged/nerve-store` | CAS under `data/blobs/` — workflows use blob storage for artifacts as configured |
|
||||
| **File watcher** | `file-watcher.ts` | Watches workspace paths for config / sense / workflow file changes |
|
||||
| **Kernel file watch** | `kernel-file-watch.ts` | Maps watcher events to `reloadConfig`, sense group restart, workflow `drainAndRespawn` |
|
||||
| **Daemon IPC** | `daemon-ipc.ts` | Unix socket server — parses `@uncaged/nerve-core` `DaemonIpcRequest`, dispatches trigger-workflow / trigger-sense / list-senses |
|
||||
@@ -35,9 +34,9 @@ Hot reload (`drainAndRespawn`) uses a controlled drain: in-flight runs may be ma
|
||||
## Key Design Decisions
|
||||
|
||||
- **One worker process per sense group** — isolation between groups, shared compute within a group
|
||||
- **`node:sqlite` (DatabaseSync)** — zero native addons, WAL mode, built into Node.js ≥ 22.5
|
||||
- **Sense state as JSON** — `data/senses/<name>.json`, updated after each successful compute in the worker
|
||||
- **Throttle + coalesce** — if compute is in-flight, at most one pending trigger is queued (no unbounded accumulation)
|
||||
- **Log ≠ Signal** — logs are queryable data assets but cannot trigger the sense scheduler or workflows (prevents feedback loops)
|
||||
- **Log ≠ Sense trigger** — logs are queryable data assets but cannot schedule sense computes or workflows (prevents feedback loops)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -85,7 +84,7 @@ await kernel.stop();
|
||||
pnpm add @uncaged/nerve-daemon
|
||||
```
|
||||
|
||||
Requires Node.js ≥ 22.5 (for `node:sqlite`).
|
||||
Requires Node.js ≥ 22.5 (for `node:sqlite` in the log store and related persistence).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/nerve-store": "workspace:*",
|
||||
"drizzle-orm": "1.0.0-beta.23-c10d10c",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -28,6 +28,11 @@ function makeMockChild(pid = 1): MockChild {
|
||||
child.connected = true;
|
||||
child.exitCode = null;
|
||||
child.pid = pid;
|
||||
setImmediate(() => {
|
||||
if (child.connected) {
|
||||
child.emit("message", { type: "ready" });
|
||||
}
|
||||
});
|
||||
child.send = vi.fn((msg: unknown) => {
|
||||
if (
|
||||
msg !== null &&
|
||||
@@ -132,6 +137,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
|
||||
mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mgr.activeCount("my-wf")).toBe(2);
|
||||
|
||||
// Simulate unexpected exit (not shutdown)
|
||||
@@ -159,6 +165,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mgr.activeCount("my-wf")).toBe(2);
|
||||
|
||||
const child = mockChildren[0];
|
||||
@@ -183,6 +190,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockChildren).toHaveLength(1);
|
||||
|
||||
const child = mockChildren[0];
|
||||
@@ -216,6 +224,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const firstChild = mockChildren[0];
|
||||
firstChild.exitCode = 1;
|
||||
firstChild.connected = false;
|
||||
@@ -260,6 +269,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
|
||||
// Start one thread to fill the concurrency slot (so queued run stays queued on respawn)
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const firstChild = mockChildren[0];
|
||||
firstChild.exitCode = 1;
|
||||
firstChild.connected = false;
|
||||
@@ -285,6 +295,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const child = mockChildren[0];
|
||||
const startCall = (child.send as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
@@ -322,6 +333,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
|
||||
const launch = { prompt: "build-docker for myrepo", maxRounds: 10, dryRun: false };
|
||||
mgr.startWorkflow("my-wf", launch);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const startedCall = logStore.upsertWorkflowRun.mock.calls.find(
|
||||
(args: any[]) => (args[0] as { type: string }).type === "started",
|
||||
@@ -357,6 +369,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
|
||||
// Start one thread to fill the concurrency slot
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const firstChild = mockChildren[0];
|
||||
|
||||
// Crash once → respawn → crash again → second respawn
|
||||
@@ -398,6 +411,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const firstChild = mockChildren[0];
|
||||
firstChild.exitCode = 1;
|
||||
firstChild.connected = false;
|
||||
@@ -428,6 +442,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("crash-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Crash the worker 6 times in rapid succession (within CRASH_WINDOW_MS = 60s)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
|
||||
@@ -241,7 +241,6 @@ describe("daemon-ipc — list-senses", () => {
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
triggers: ["every 30s"],
|
||||
lastSignalTimestamp: 1000,
|
||||
},
|
||||
{
|
||||
name: "disk-usage",
|
||||
@@ -249,7 +248,6 @@ describe("daemon-ipc — list-senses", () => {
|
||||
throttle: 30000,
|
||||
timeout: null,
|
||||
triggers: [],
|
||||
lastSignalTimestamp: null,
|
||||
},
|
||||
];
|
||||
const listSenses = vi.fn(() => sensesData);
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("createFileWatcher", () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
writeFileSync(
|
||||
join(root, "senses", "cpu-usage", "index.js"),
|
||||
"export async function compute() { return { signal: 42, workflow: null }; }",
|
||||
"export const initialState = {}; export async function compute(state) { return { state, workflow: null }; }",
|
||||
);
|
||||
|
||||
await waitFor(() => changes.length > 0, 3000);
|
||||
|
||||
@@ -31,6 +31,11 @@ process.on("message", (msg) => {
|
||||
writeFileSync(markerFile, "crashed", "utf8");
|
||||
process.exit(1);
|
||||
}
|
||||
process.send({ type: "signal", sense: msg.sense, payload: 42 });
|
||||
process.send({
|
||||
type: "compute-result",
|
||||
sense: msg.sense,
|
||||
state: 42,
|
||||
workflow: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Behaviour:
|
||||
* - Sends { type: "ready" } on startup
|
||||
* - On { type: "compute", sense } → sends back { type: "signal", sense, payload: 42 }
|
||||
* - On { type: "compute", sense } → sends back compute-result with state + workflow:null
|
||||
* - On { type: "shutdown" } → exits cleanly with code 0
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,11 @@ process.on("message", (msg) => {
|
||||
}
|
||||
|
||||
if (msg.type === "compute" && typeof msg.sense === "string") {
|
||||
process.send({ type: "signal", sense: msg.sense, payload: 42 });
|
||||
process.send({
|
||||
type: "compute-result",
|
||||
sense: msg.sense,
|
||||
state: 42,
|
||||
workflow: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,7 +18,12 @@ process.on("message", (msg) => {
|
||||
if (msg.type === "compute" && typeof msg.sense === "string") {
|
||||
// Intentionally slow — will be killed by grace period
|
||||
setTimeout(() => {
|
||||
process.send({ type: "signal", sense: msg.sense, payload: "late" });
|
||||
process.send({
|
||||
type: "compute-result",
|
||||
sense: msg.sense,
|
||||
state: "late",
|
||||
workflow: null,
|
||||
});
|
||||
}, 10_000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,6 +33,11 @@ function makeMockChild(pid = 1): MockChild {
|
||||
child.connected = true;
|
||||
child.exitCode = null;
|
||||
child.pid = pid;
|
||||
setImmediate(() => {
|
||||
if (child.connected) {
|
||||
child.emit("message", { type: "ready" });
|
||||
}
|
||||
});
|
||||
child.send = vi.fn((msg: unknown) => {
|
||||
if (
|
||||
msg !== null &&
|
||||
@@ -114,6 +119,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockChildren).toHaveLength(1);
|
||||
|
||||
// Remove workflow from config before drain completes
|
||||
@@ -134,6 +140,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mgr.activeCount("my-wf")).toBe(2);
|
||||
|
||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||
@@ -165,6 +172,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockChildren).toHaveLength(1);
|
||||
|
||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||
@@ -181,6 +189,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockChildren).toHaveLength(1);
|
||||
|
||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||
@@ -198,6 +207,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -223,6 +233,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "first", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -230,6 +241,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
||||
|
||||
// Start a new thread on the fresh worker
|
||||
mgr.startWorkflow("my-wf", { prompt: "second", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const newChild = mockChildren[1];
|
||||
const startCalls = (newChild.send as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
@@ -257,12 +269,13 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not send shutdown while a thread is still active", () => {
|
||||
it("does not send shutdown while a thread is still active", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const child = mockChildren[0];
|
||||
|
||||
mgr.drainWhenIdle("my-wf");
|
||||
@@ -282,6 +295,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const child = mockChildren[0];
|
||||
const runId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { runId: string };
|
||||
|
||||
@@ -311,6 +325,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "a", maxRounds: 10, dryRun: false });
|
||||
mgr.startWorkflow("my-wf", { prompt: "b", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const child = mockChildren[0];
|
||||
const sendMock = child.send as ReturnType<typeof vi.fn>;
|
||||
const runIdA = (sendMock.mock.calls[0][0] as { runId: string }).runId;
|
||||
@@ -355,6 +370,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const child = mockChildren[0];
|
||||
const runId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { runId: string };
|
||||
|
||||
@@ -388,6 +404,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const child = mockChildren[0];
|
||||
const runId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { runId: string };
|
||||
|
||||
@@ -414,6 +431,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-wf", { prompt: "once", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const firstChild = mockChildren[0];
|
||||
const runId = (firstChild.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as {
|
||||
runId: string;
|
||||
@@ -471,6 +489,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
|
||||
|
||||
// Trigger a workflow thread so a worker is spawned
|
||||
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Manually call drainAndRespawn (simulating what kernel does on workflow file change)
|
||||
const drainPromise = kernel.workflowManager.drainAndRespawn("my-wf", 1000);
|
||||
@@ -511,6 +530,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
|
||||
maxRounds: 10,
|
||||
dryRun: false,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockChildren).toHaveLength(1);
|
||||
|
||||
// Reload config without old-wf
|
||||
@@ -551,6 +571,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
|
||||
});
|
||||
|
||||
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
const workersBefore = mockChildren.length;
|
||||
|
||||
// Reload with updated concurrency — should NOT spawn a new workflow worker
|
||||
@@ -573,6 +594,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
|
||||
// Can now start up to 5 concurrent threads (previously only 1)
|
||||
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
expect(kernel.workflowManager.activeCount("my-wf")).toBe(3);
|
||||
|
||||
const stopPromise = kernel.stop();
|
||||
|
||||
@@ -11,7 +11,6 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { Signal } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -30,7 +29,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -82,7 +80,6 @@ describe("kernel integration — real child processes", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -91,7 +88,6 @@ describe("kernel integration — real child processes", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -100,7 +96,6 @@ describe("kernel integration — real child processes", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -116,31 +111,32 @@ describe("kernel integration — real child processes", () => {
|
||||
expect(kernel.senseCount).toBe(3);
|
||||
});
|
||||
|
||||
it("workers start and respond to compute messages with signals", async () => {
|
||||
it("workers start and respond to compute messages with compute-result", async () => {
|
||||
const config = makeConfig();
|
||||
kernel = createKernel(config, nerveRoot, {
|
||||
workerScript: MOCK_WORKER,
|
||||
});
|
||||
|
||||
// Wait for all workers to be ready (event-based, not fixed delay)
|
||||
await kernel.ready;
|
||||
|
||||
// Subscribe to the bus before triggering compute
|
||||
const received: Signal[] = [];
|
||||
const unsub = kernel.bus.subscribe((signal) => {
|
||||
received.push(signal);
|
||||
});
|
||||
|
||||
// Trigger a compute for "cpu-usage" through the kernel's triggerCompute
|
||||
kernel.triggerCompute("cpu-usage");
|
||||
|
||||
// Poll until a signal arrives on the bus (event-driven, no fixed delay)
|
||||
await pollUntil(() => received.length > 0, 10_000);
|
||||
await pollUntil(
|
||||
() =>
|
||||
kernel!.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "cpu-usage",
|
||||
}).length > 0,
|
||||
10_000,
|
||||
);
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
|
||||
|
||||
unsub();
|
||||
const rows = kernel.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "cpu-usage",
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
}, 15_000);
|
||||
|
||||
it("graceful shutdown: stop() resolves after all workers exit", async () => {
|
||||
@@ -151,7 +147,6 @@ describe("kernel integration — real child processes", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -160,7 +155,6 @@ describe("kernel integration — real child processes", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -180,31 +174,32 @@ describe("kernel integration — real child processes", () => {
|
||||
await expect(stopPromise).resolves.toBeUndefined();
|
||||
}, 10_000);
|
||||
|
||||
it("compute round-trip: worker receives compute and sends signal back through bus", async () => {
|
||||
it("compute round-trip: worker receives compute and kernel logs compute-complete", async () => {
|
||||
const config = makeConfig();
|
||||
kernel = createKernel(config, nerveRoot, {
|
||||
workerScript: MOCK_WORKER,
|
||||
});
|
||||
|
||||
// Wait for all workers to be ready (event-based, not fixed delay)
|
||||
await kernel.ready;
|
||||
|
||||
const received: Signal[] = [];
|
||||
const unsub = kernel.bus.subscribe((signal) => {
|
||||
received.push(signal);
|
||||
});
|
||||
|
||||
// Trigger compute via the kernel — the kernel sends IPC to the worker,
|
||||
// the mock worker responds with a signal message, and the kernel routes it to the bus.
|
||||
kernel.triggerCompute("cpu-usage");
|
||||
|
||||
// Poll for the signal on the bus (no fixed delay)
|
||||
await pollUntil(() => received.length > 0, 10_000);
|
||||
await pollUntil(
|
||||
() =>
|
||||
kernel!.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "cpu-usage",
|
||||
}).length > 0,
|
||||
10_000,
|
||||
);
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
|
||||
|
||||
unsub();
|
||||
const rows = kernel.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "cpu-usage",
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
}, 15_000);
|
||||
|
||||
it("crash recovery: kernel respawns worker after unexpected exit and new worker is functional", async () => {
|
||||
@@ -234,23 +229,24 @@ describe("kernel integration — real child processes", () => {
|
||||
expect(newPid).not.toBeNull();
|
||||
expect(newPid).not.toBe(originalPid);
|
||||
|
||||
// Wait a bit for the new worker to send its "ready" message and be fully up.
|
||||
// Poll until the new worker responds to a compute message on the bus.
|
||||
const postRespawnSignals: Signal[] = [];
|
||||
const unsub = kernel.bus.subscribe((signal) => {
|
||||
postRespawnSignals.push(signal);
|
||||
});
|
||||
|
||||
// Trigger compute through the kernel to the new worker
|
||||
kernel.triggerCompute("cpu-usage");
|
||||
|
||||
// Poll for the signal — verifies the new worker is fully functional
|
||||
await pollUntil(() => postRespawnSignals.length > 0, 15_000);
|
||||
await pollUntil(
|
||||
() =>
|
||||
kernel!.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "cpu-usage",
|
||||
}).length > 0,
|
||||
15_000,
|
||||
);
|
||||
|
||||
expect(postRespawnSignals).toHaveLength(1);
|
||||
expect(postRespawnSignals[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
|
||||
|
||||
unsub();
|
||||
const rows = kernel.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "cpu-usage",
|
||||
});
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Kernel should still stop gracefully after respawn
|
||||
await kernel.stop();
|
||||
|
||||
@@ -86,7 +86,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -125,7 +124,6 @@ describe("kernel — getHealth", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -134,7 +132,6 @@ describe("kernel — getHealth", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -143,7 +140,6 @@ describe("kernel — getHealth", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -246,7 +242,6 @@ describe("kernel — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -255,7 +250,6 @@ describe("kernel — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -283,7 +277,6 @@ describe("kernel — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -292,7 +285,6 @@ describe("kernel — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -318,7 +310,6 @@ describe("kernel — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -352,7 +343,6 @@ describe("kernel — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -361,7 +351,6 @@ describe("kernel — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
|
||||
@@ -101,7 +101,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -153,7 +152,6 @@ describe("kernel.triggerSense()", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -162,7 +160,6 @@ describe("kernel.triggerSense()", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -199,7 +196,6 @@ describe("kernel.triggerSense()", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -208,7 +204,6 @@ describe("kernel.triggerSense()", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Integration tests for Kernel + WorkflowManager integration.
|
||||
*
|
||||
* Verifies that sense signals trigger workflow runs when Sense compute routes
|
||||
* to 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.
|
||||
@@ -53,7 +53,12 @@ function makeMockChild(pid = 1): MockChild {
|
||||
// Sense IPC: reply to compute so scheduler completes (onComputeComplete).
|
||||
if (m.type === "compute" && typeof m.sense === "string") {
|
||||
setImmediate(() => {
|
||||
child.emit("message", { type: "signal", sense: m.sense, payload: 42 });
|
||||
child.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: m.sense,
|
||||
state: 42,
|
||||
workflow: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -117,7 +122,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -159,7 +163,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -174,26 +177,23 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Simulate a sense worker sending a signal with workflow launch payload
|
||||
// The kernel's handleWorkerMessage processes "signal" type messages
|
||||
// and uses routeSenseComputeOutput to detect workflow launches
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "signal",
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
payload: {
|
||||
signal: { reason: "test" },
|
||||
workflow: {
|
||||
name: "my-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "run this workflow",
|
||||
dryRun: false,
|
||||
},
|
||||
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(
|
||||
@@ -219,7 +219,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -234,24 +233,23 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Simulate sense worker returning a signal plus workflow launch
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "signal",
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
payload: {
|
||||
signal: { level: "critical" },
|
||||
workflow: {
|
||||
name: "alert-workflow",
|
||||
maxRounds: 5,
|
||||
prompt: "handle critical alert",
|
||||
dryRun: false,
|
||||
},
|
||||
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][])
|
||||
@@ -276,7 +274,7 @@ describe("kernel + workflowManager integration", () => {
|
||||
await stopPromise;
|
||||
});
|
||||
|
||||
it("logs sense signal before workflow-launch when both are 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" } },
|
||||
@@ -292,35 +290,35 @@ describe("kernel + workflowManager integration", () => {
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "signal",
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
payload: {
|
||||
signal: { seq: 1 },
|
||||
workflow: {
|
||||
name: "order-wf",
|
||||
maxRounds: 2,
|
||||
prompt: "p",
|
||||
dryRun: true,
|
||||
},
|
||||
state: { seq: 1 },
|
||||
workflow: {
|
||||
name: "order-wf",
|
||||
maxRounds: 2,
|
||||
prompt: "p",
|
||||
dryRun: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const senseEntries = logStore.append.mock.calls
|
||||
.map((c) => c[0] as { source: string; type: string; refId: string | null })
|
||||
.filter((e) => e.source === "sense" && e.refId === "cpu-usage");
|
||||
const typeOrder = senseEntries.map((e) => e.type);
|
||||
const sigAt = typeOrder.indexOf("signal");
|
||||
const completeAt = typeOrder.indexOf("compute-complete");
|
||||
const launchAt = typeOrder.indexOf("workflow-launch");
|
||||
expect(sigAt).toBeGreaterThanOrEqual(0);
|
||||
expect(launchAt).toBeGreaterThan(sigAt);
|
||||
expect(completeAt).toBeGreaterThanOrEqual(0);
|
||||
expect(launchAt).toBeGreaterThan(completeAt);
|
||||
|
||||
const stopPromise = kernel.stop();
|
||||
await vi.runAllTimersAsync();
|
||||
await stopPromise;
|
||||
});
|
||||
|
||||
it("does not trigger workflow when signal senseId is not in 'on' list", async () => {
|
||||
it("does not trigger workflow when compute-result has workflow null", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
senses: {
|
||||
@@ -329,7 +327,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -338,7 +335,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -353,13 +349,13 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Emit a regular signal (shorthand payload) — should NOT trigger any workflow
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "signal",
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
payload: 50,
|
||||
state: 50,
|
||||
workflow: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,7 +386,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -405,24 +400,23 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Simulate sense compute returning a signal plus workflow launch
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "signal",
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
payload: {
|
||||
signal: { note: "log" },
|
||||
workflow: {
|
||||
name: "log-test-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "test prompt",
|
||||
dryRun: false,
|
||||
},
|
||||
state: { note: "log" },
|
||||
workflow: {
|
||||
name: "log-test-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "test prompt",
|
||||
dryRun: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: "workflow", type: "started" }),
|
||||
expect.objectContaining({ workflow: "log-test-workflow", status: "started" }),
|
||||
@@ -444,7 +438,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -468,7 +461,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -480,24 +472,23 @@ describe("kernel + workflowManager integration", () => {
|
||||
};
|
||||
kernel.reloadConfig(newConfig);
|
||||
|
||||
// Simulate sense compute returning a signal plus workflow for the new workflow
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "signal",
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
payload: {
|
||||
signal: { phase: "reload" },
|
||||
workflow: {
|
||||
name: "new-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "reload test",
|
||||
dryRun: false,
|
||||
},
|
||||
state: { phase: "reload" },
|
||||
workflow: {
|
||||
name: "new-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "reload test",
|
||||
dryRun: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const startThreadCall = mockChildren
|
||||
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
|
||||
.find(
|
||||
@@ -524,7 +515,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -547,7 +537,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -564,24 +553,23 @@ describe("kernel + workflowManager integration", () => {
|
||||
(c.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
}
|
||||
|
||||
// Simulate sense compute trying to launch the removed workflow — it should not start
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "signal",
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
payload: {
|
||||
signal: { stale: true },
|
||||
workflow: {
|
||||
name: "old-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "should not work",
|
||||
dryRun: false,
|
||||
},
|
||||
state: { stale: true },
|
||||
workflow: {
|
||||
name: "old-workflow",
|
||||
maxRounds: 10,
|
||||
prompt: "should not work",
|
||||
dryRun: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const startThreadCall = mockChildren
|
||||
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
|
||||
.find(
|
||||
@@ -609,7 +597,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -624,24 +611,23 @@ describe("kernel + workflowManager integration", () => {
|
||||
await flushSenseWorkerForkMicrotasks(kernel);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Trigger a workflow via sense compute return value
|
||||
const workerPool = mockChildren[0];
|
||||
if (workerPool) {
|
||||
workerPool.emit("message", {
|
||||
type: "signal",
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
payload: {
|
||||
signal: { shutdownCase: true },
|
||||
workflow: {
|
||||
name: "shutdown-test",
|
||||
maxRounds: 10,
|
||||
prompt: "test",
|
||||
dryRun: false,
|
||||
},
|
||||
state: { shutdownCase: true },
|
||||
workflow: {
|
||||
name: "shutdown-test",
|
||||
maxRounds: 10,
|
||||
prompt: "test",
|
||||
dryRun: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const stopPromise = kernel.stop();
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(stopPromise).resolves.toBeUndefined();
|
||||
@@ -675,7 +661,6 @@ describe("kernel + workflowManager integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
|
||||
@@ -37,7 +37,12 @@ function makeMockChild(pid = 1): MockChild {
|
||||
}
|
||||
if (m.type === "compute" && typeof m.sense === "string") {
|
||||
setImmediate(() => {
|
||||
child.emit("message", { type: "signal", sense: m.sense, payload: 42 });
|
||||
child.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: m.sense,
|
||||
state: 42,
|
||||
workflow: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -81,7 +86,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -112,7 +116,7 @@ describe("kernel — message routing", () => {
|
||||
rmSync(nerveRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("routes signal message to bus without throwing", async () => {
|
||||
it("routes compute-result message without throwing", async () => {
|
||||
const config = makeConfig({
|
||||
senses: {
|
||||
"cpu-usage": {
|
||||
@@ -120,7 +124,6 @@ describe("kernel — message routing", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -133,14 +136,19 @@ describe("kernel — message routing", () => {
|
||||
const child = mockChildren[0];
|
||||
|
||||
expect(() => {
|
||||
child.emit("message", { type: "signal", sense: "cpu-usage", payload: 42 });
|
||||
child.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: 42,
|
||||
workflow: null,
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
await kernel.stop();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
it("persists emitted signals as sense/signal log entries", async () => {
|
||||
it("persists compute-complete log entries for sense IPC", async () => {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "nerve-kernel-sig-"));
|
||||
const logStore = createLogStore(join(tmpDir, "logs.db"));
|
||||
try {
|
||||
@@ -151,7 +159,6 @@ describe("kernel — message routing", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -160,10 +167,19 @@ describe("kernel — message routing", () => {
|
||||
const kernel = createKernel(config, tmpDir, { logStore });
|
||||
await vi.runAllTimersAsync();
|
||||
const child = mockChildren[0];
|
||||
child.emit("message", { type: "signal", sense: "cpu-usage", payload: 123 });
|
||||
const rows = logStore.query({ source: "sense", type: "signal", refId: "cpu-usage" });
|
||||
child.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: "cpu-usage",
|
||||
state: 123,
|
||||
workflow: null,
|
||||
});
|
||||
const rows = logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "cpu-usage",
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].payload).toBe(JSON.stringify(123));
|
||||
expect(rows[0].payload).toBeNull();
|
||||
await kernel.stop();
|
||||
await vi.runAllTimersAsync();
|
||||
} finally {
|
||||
@@ -180,7 +196,6 @@ describe("kernel — message routing", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -209,7 +224,6 @@ describe("kernel — message routing", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -237,7 +251,6 @@ describe("kernel — message routing", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -278,7 +291,6 @@ describe("kernel — groupForSense mapping", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -287,7 +299,6 @@ describe("kernel — groupForSense mapping", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -296,7 +307,6 @@ describe("kernel — groupForSense mapping", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -322,7 +332,6 @@ describe("kernel — groupForSense mapping", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: 500,
|
||||
on: [],
|
||||
},
|
||||
|
||||
@@ -3,11 +3,10 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { NerveConfig, Signal } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import { createLogStore } from "@uncaged/nerve-store";
|
||||
import type { LogStore } from "@uncaged/nerve-store";
|
||||
import { createSenseScheduler } from "../sense-scheduler.js";
|
||||
import { createSignalBus } from "../signal-bus.js";
|
||||
|
||||
describe("LogStore + SenseScheduler integration", () => {
|
||||
let tmpDir: string;
|
||||
@@ -31,7 +30,6 @@ describe("LogStore + SenseScheduler integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: ["cpu-usage"],
|
||||
},
|
||||
@@ -41,14 +39,12 @@ describe("LogStore + SenseScheduler integration", () => {
|
||||
extract: null,
|
||||
api: { port: null, token: null, host: "127.0.0.1" },
|
||||
};
|
||||
const bus = createSignalBus();
|
||||
const triggered: string[] = [];
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name), {
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name), {
|
||||
logStore,
|
||||
});
|
||||
|
||||
const signal: Signal = { id: 1, senseId: "cpu-usage", payload: 42, timestamp: Date.now() };
|
||||
bus.emit(signal);
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
|
||||
const logs = logStore.query({ source: "sense_scheduler", type: "run_start" });
|
||||
expect(logs).toHaveLength(1);
|
||||
@@ -68,7 +64,6 @@ describe("LogStore + SenseScheduler integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: 1000,
|
||||
on: [],
|
||||
},
|
||||
@@ -78,11 +73,9 @@ describe("LogStore + SenseScheduler integration", () => {
|
||||
extract: null,
|
||||
api: { port: null, token: null, host: "127.0.0.1" },
|
||||
};
|
||||
const bus = createSignalBus();
|
||||
const ref: { scheduler: ReturnType<typeof createSenseScheduler> | null } = { scheduler: null };
|
||||
const scheduler = createSenseScheduler(
|
||||
config,
|
||||
bus,
|
||||
(name) => {
|
||||
ref.scheduler?.onComputeComplete(name);
|
||||
},
|
||||
@@ -108,7 +101,6 @@ describe("LogStore + SenseScheduler integration", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: ["cpu-usage"],
|
||||
},
|
||||
@@ -118,11 +110,9 @@ describe("LogStore + SenseScheduler integration", () => {
|
||||
extract: null,
|
||||
api: { port: null, token: null, host: "127.0.0.1" },
|
||||
};
|
||||
const bus = createSignalBus();
|
||||
const triggered: string[] = [];
|
||||
const scheduler = createSenseScheduler(
|
||||
config,
|
||||
bus,
|
||||
(name) => {
|
||||
triggered.push(name);
|
||||
scheduler.onComputeComplete(name);
|
||||
@@ -138,8 +128,6 @@ describe("LogStore + SenseScheduler integration", () => {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Writing to the log store should NOT trigger any sense compute.
|
||||
// Only bus.emit(signal) triggers scheduled senses.
|
||||
expect(triggered).toHaveLength(0);
|
||||
|
||||
scheduler.stop();
|
||||
|
||||
@@ -7,7 +7,6 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { Signal } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -27,7 +26,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -97,13 +95,16 @@ describe("phase6 — restartGroup", () => {
|
||||
expect(newPid).not.toBeNull();
|
||||
expect(newPid).not.toBe(oldPid);
|
||||
|
||||
// Verify new worker is functional
|
||||
const received: Signal[] = [];
|
||||
const unsub = kernel.bus.subscribe((s) => received.push(s));
|
||||
kernel.triggerCompute("cpu-usage");
|
||||
await pollUntil(() => received.length > 0, 10_000);
|
||||
expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
|
||||
unsub();
|
||||
await pollUntil(
|
||||
() =>
|
||||
kernel!.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "cpu-usage",
|
||||
}).length > 0,
|
||||
10_000,
|
||||
);
|
||||
}, 35_000);
|
||||
|
||||
it("restartGroup on nonexistent group does nothing", async () => {
|
||||
@@ -154,7 +155,6 @@ describe("phase6 — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -163,7 +163,6 @@ describe("phase6 — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -191,7 +190,6 @@ describe("phase6 — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -200,7 +198,6 @@ describe("phase6 — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -224,7 +221,6 @@ describe("phase6 — reloadConfig", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -269,7 +265,6 @@ describe("phase6 — error isolation", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -278,7 +273,6 @@ describe("phase6 — error isolation", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -294,19 +288,27 @@ describe("phase6 — error isolation", () => {
|
||||
});
|
||||
await kernel.ready;
|
||||
|
||||
// Both senses go through the same worker (mock-worker responds to all compute with signal)
|
||||
const received: Signal[] = [];
|
||||
const unsub = kernel.bus.subscribe((s) => received.push(s));
|
||||
|
||||
kernel.triggerCompute("good-sense");
|
||||
await pollUntil(() => received.length > 0, 10_000);
|
||||
expect(received[0]).toMatchObject({ senseId: "good-sense" });
|
||||
await pollUntil(
|
||||
() =>
|
||||
kernel!.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "good-sense",
|
||||
}).length > 0,
|
||||
10_000,
|
||||
);
|
||||
|
||||
kernel.triggerCompute("bad-sense");
|
||||
await pollUntil(() => received.length > 1, 10_000);
|
||||
expect(received[1]).toMatchObject({ senseId: "bad-sense" });
|
||||
|
||||
unsub();
|
||||
await pollUntil(
|
||||
() =>
|
||||
kernel!.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "bad-sense",
|
||||
}).length > 0,
|
||||
10_000,
|
||||
);
|
||||
}, 10_000);
|
||||
|
||||
it("error worker sends error messages, kernel still running", async () => {
|
||||
@@ -366,7 +368,6 @@ describe("phase6 — getHealth", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -375,7 +376,6 @@ describe("phase6 — getHealth", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -384,7 +384,6 @@ describe("phase6 — getHealth", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -420,7 +419,6 @@ describe("phase6 — getHealth", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -429,7 +427,6 @@ describe("phase6 — getHealth", () => {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -489,13 +486,16 @@ describe("phase6 — auto-respawn on worker crash", () => {
|
||||
expect(newPid).not.toBeNull();
|
||||
expect(newPid).not.toBe(originalPid);
|
||||
|
||||
// Verify new worker responds
|
||||
const received: Signal[] = [];
|
||||
const unsub = kernel.bus.subscribe((s) => received.push(s));
|
||||
kernel.triggerCompute("cpu-usage");
|
||||
await pollUntil(() => received.length > 0, 10_000);
|
||||
expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
|
||||
unsub();
|
||||
await pollUntil(
|
||||
() =>
|
||||
kernel!.logStore.query({
|
||||
source: "sense",
|
||||
type: "compute-complete",
|
||||
refId: "cpu-usage",
|
||||
}).length > 0,
|
||||
10_000,
|
||||
);
|
||||
|
||||
await kernel.stop();
|
||||
kernel = null;
|
||||
|
||||
@@ -1,281 +1,118 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { drizzle } from "drizzle-orm/node-sqlite";
|
||||
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
|
||||
import type { SenseComputeFn } from "@uncaged/nerve-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseParentMessage } from "../ipc.js";
|
||||
import { executeCompute, openSenseDb, runMigrations } from "../sense-runtime.js";
|
||||
import type { DrizzleDB, SenseRuntime } from "../sense-runtime.js";
|
||||
import { executeCompute, readState, writeState } from "../sense-runtime.js";
|
||||
import type { SenseRuntime } from "../sense-runtime.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INIT_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS samples (
|
||||
ts INTEGER PRIMARY KEY,
|
||||
value REAL NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
function makeTempMigrationsDir(sql: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nerve-test-"));
|
||||
writeFileSync(join(dir, "0001_init.sql"), sql);
|
||||
return dir;
|
||||
function makeTempStatePath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nerve-state-"));
|
||||
return join(dir, "sense.json");
|
||||
}
|
||||
|
||||
function makeTempMigrationsDirEmpty(): string {
|
||||
return mkdtempSync(join(tmpdir(), "nerve-test-empty-"));
|
||||
}
|
||||
|
||||
function makeTempDbPath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nerve-db-"));
|
||||
return join(dir, "test.db");
|
||||
}
|
||||
|
||||
const samples = sqliteTable("samples", {
|
||||
ts: integer("ts").primaryKey(),
|
||||
value: real("value").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runMigrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("runMigrations", () => {
|
||||
it("creates table via SQL migration file", () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
|
||||
const result = runMigrations(sqlite, migrationsDir);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const row = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='samples'")
|
||||
.get();
|
||||
expect(row).toBeDefined();
|
||||
|
||||
sqlite.close();
|
||||
describe("readState / writeState", () => {
|
||||
it("writeState creates parent dirs and persists JSON", () => {
|
||||
const base = mkdtempSync(join(tmpdir(), "nerve-write-"));
|
||||
const path = join(base, "nested", "a.json");
|
||||
writeState(path, { n: 1 });
|
||||
const raw = readFileSync(path, "utf8");
|
||||
expect(JSON.parse(raw)).toEqual({ n: 1 });
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runs multiple migrations in lexicographic order", () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const dir = mkdtempSync(join(tmpdir(), "nerve-multi-"));
|
||||
|
||||
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
|
||||
writeFileSync(join(dir, "0002_add_col.sql"), "ALTER TABLE samples ADD COLUMN label TEXT;");
|
||||
|
||||
const result = runMigrations(sqlite, dir);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const info = sqlite.prepare("PRAGMA table_info(samples)").all() as Array<{ name: string }>;
|
||||
const cols = info.map((r) => r.name);
|
||||
expect(cols).toContain("label");
|
||||
|
||||
sqlite.close();
|
||||
it("readState returns initialState when file is missing", () => {
|
||||
const path = join(mkdtempSync(join(tmpdir(), "nerve-missing-")), "none.json");
|
||||
expect(readState(path, { x: 0 })).toEqual({ x: 0 });
|
||||
});
|
||||
|
||||
it("returns ok when migrations directory is empty", () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const dir = makeTempMigrationsDirEmpty();
|
||||
const result = runMigrations(sqlite, dir);
|
||||
expect(result.ok).toBe(true);
|
||||
sqlite.close();
|
||||
it("readState returns parsed JSON when file exists", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nerve-read-"));
|
||||
const path = join(dir, "s.json");
|
||||
writeFileSync(path, JSON.stringify({ count: 3 }), "utf8");
|
||||
expect(readState(path, { count: 0 })).toEqual({ count: 3 });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns err when migrations directory does not exist", () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const result = runMigrations(sqlite, "/nonexistent/path/migrations");
|
||||
expect(result.ok).toBe(false);
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("returns err when a migration SQL is invalid", () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const dir = mkdtempSync(join(tmpdir(), "nerve-bad-sql-"));
|
||||
writeFileSync(join(dir, "0001_bad.sql"), "NOT VALID SQL !!!;");
|
||||
const result = runMigrations(sqlite, dir);
|
||||
expect(result.ok).toBe(false);
|
||||
sqlite.close();
|
||||
it("readState returns initialState on invalid JSON", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nerve-badjson-"));
|
||||
const path = join(dir, "bad.json");
|
||||
writeFileSync(path, "not json {{{", "utf8");
|
||||
expect(readState(path, { fallback: true })).toEqual({ fallback: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// openSenseDb
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("openSenseDb", () => {
|
||||
it("creates the db file and runs migrations", () => {
|
||||
const dbPath = makeTempDbPath();
|
||||
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
|
||||
|
||||
const result = openSenseDb(dbPath, migrationsDir);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (!result.ok) return;
|
||||
const { sqlite } = result.value;
|
||||
const row = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='samples'")
|
||||
.get();
|
||||
expect(row).toBeDefined();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("returns err when migrations dir is missing", () => {
|
||||
const dbPath = makeTempDbPath();
|
||||
const result = openSenseDb(dbPath, "/nonexistent/migrations");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("prunes _signals to retention after every 100 inserts", () => {
|
||||
const dbPath = makeTempDbPath();
|
||||
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
|
||||
const result = openSenseDb(dbPath, migrationsDir, 5);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const { sqlite, persistSignal } = result.value;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
persistSignal({ n: i });
|
||||
}
|
||||
|
||||
const count = sqlite.prepare("SELECT COUNT(*) AS c FROM _signals").get() as { c: number };
|
||||
expect(count.c).toBe(5);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// executeCompute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("executeCompute", () => {
|
||||
function makeRuntime(
|
||||
computeFn: SenseRuntime["compute"],
|
||||
sqlite?: DatabaseSync,
|
||||
): { runtime: SenseRuntime; sqlite: DatabaseSync } {
|
||||
const db_sqlite = sqlite ?? new DatabaseSync(":memory:");
|
||||
if (!sqlite) db_sqlite.exec(INIT_SQL);
|
||||
const db = drizzle({ client: db_sqlite }) as DrizzleDB;
|
||||
compute: SenseComputeFn<{ n: number }>,
|
||||
state: { n: number },
|
||||
statePath?: string,
|
||||
): SenseRuntime {
|
||||
return {
|
||||
runtime: {
|
||||
name: "test-sense",
|
||||
db,
|
||||
compute: computeFn,
|
||||
table: samples,
|
||||
persistSignal: () => {},
|
||||
},
|
||||
sqlite: db_sqlite,
|
||||
name: "test-sense",
|
||||
compute: compute as SenseComputeFn,
|
||||
state,
|
||||
statePath: statePath ?? makeTempStatePath(),
|
||||
};
|
||||
}
|
||||
|
||||
it("returns non-null and inserts into table when compute returns data", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(async () => ({
|
||||
signal: { ts: 1000, value: 0.5 },
|
||||
workflow: null,
|
||||
}));
|
||||
it("passes state into compute and persists returned state", async () => {
|
||||
const path = makeTempStatePath();
|
||||
const runtime = makeRuntime(
|
||||
async (s) => ({ state: { n: s.n + 1 }, workflow: null }),
|
||||
{ n: 0 },
|
||||
path,
|
||||
);
|
||||
|
||||
const result = await executeCompute(runtime);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toEqual({ signal: { ts: 1000, value: 0.5 }, workflow: null });
|
||||
|
||||
const rows = sqlite.prepare("SELECT * FROM samples").all();
|
||||
expect(rows).toHaveLength(1);
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("returns null and does not insert when compute returns null", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(async () => null);
|
||||
|
||||
const result = await executeCompute(runtime);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeNull();
|
||||
|
||||
const rows = sqlite.prepare("SELECT * FROM samples").all();
|
||||
expect(rows).toHaveLength(0);
|
||||
sqlite.close();
|
||||
expect(result.value).toEqual({ state: { n: 1 }, workflow: null });
|
||||
expect(runtime.state).toEqual({ n: 1 });
|
||||
expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
it("returns err when compute throws", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(async () => {
|
||||
throw new Error("something went wrong");
|
||||
});
|
||||
const runtime = makeRuntime(
|
||||
async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
{ n: 0 },
|
||||
);
|
||||
|
||||
const result = await executeCompute(runtime);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toContain("something went wrong");
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("inserts correctly into the sense db from openSenseDb", async () => {
|
||||
const dbPath = makeTempDbPath();
|
||||
const migrationsDir = makeTempMigrationsDir(INIT_SQL);
|
||||
const dbResult = openSenseDb(dbPath, migrationsDir);
|
||||
expect(dbResult.ok).toBe(true);
|
||||
if (!dbResult.ok) return;
|
||||
|
||||
mkdirSync(join(dbPath, "..", "migrations"), { recursive: true });
|
||||
|
||||
const { sqlite: dbSqlite, db } = dbResult.value;
|
||||
const runtime: SenseRuntime = {
|
||||
name: "cpu-usage",
|
||||
db,
|
||||
compute: async () => ({ signal: { ts: 1000, value: 1.23 }, workflow: null }),
|
||||
table: samples,
|
||||
persistSignal: () => {},
|
||||
};
|
||||
|
||||
const result = await executeCompute(runtime);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const rows = dbSqlite.prepare("SELECT * FROM samples").all() as Array<{
|
||||
ts: number;
|
||||
value: number;
|
||||
}>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ts).toBe(1000);
|
||||
expect(rows[0].value).toBe(1.23);
|
||||
dbSqlite.close();
|
||||
expect(result.error.message).toContain("boom");
|
||||
});
|
||||
|
||||
it("returns err when compute exceeds timeoutMs", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(
|
||||
() => new Promise<null>((resolve) => setTimeout(() => resolve(null), 5_000)),
|
||||
const runtime = makeRuntime(
|
||||
async (s) =>
|
||||
new Promise((resolve) => setTimeout(() => resolve({ state: s, workflow: null }), 5_000)),
|
||||
{ n: 0 },
|
||||
);
|
||||
|
||||
const result = await executeCompute(runtime, 50);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toMatch(/timed out/i);
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("completes within timeout when compute is fast", async () => {
|
||||
const { runtime, sqlite } = makeRuntime(async () => ({
|
||||
signal: { ts: 1, value: 42 },
|
||||
workflow: null,
|
||||
}));
|
||||
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;
|
||||
expect(result.value).toEqual({ signal: { ts: 1, value: 42 }, workflow: null });
|
||||
sqlite.close();
|
||||
expect(result.value.state).toEqual({ n: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseParentMessage (IPC validation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseParentMessage", () => {
|
||||
describe("parseParentMessage (IPC validation)", () => {
|
||||
it("accepts a valid compute message", () => {
|
||||
const result = parseParentMessage({ type: "compute", sense: "cpu" });
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -310,47 +147,3 @@ describe("parseParentMessage", () => {
|
||||
expect(result.error.message).toMatch(/unknown/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runMigrations – journal (idempotency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("runMigrations journal", () => {
|
||||
it("does not re-run an already-applied migration", () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const dir = mkdtempSync(join(tmpdir(), "nerve-journal-"));
|
||||
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
|
||||
|
||||
const first = runMigrations(sqlite, dir);
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
sqlite.exec("INSERT INTO samples (ts, value) VALUES (1, 1.0)");
|
||||
|
||||
const nonIdempotentSql = "CREATE TABLE samples2 (id INTEGER PRIMARY KEY)";
|
||||
writeFileSync(join(dir, "0002_samples2.sql"), nonIdempotentSql);
|
||||
|
||||
const second = runMigrations(sqlite, dir);
|
||||
expect(second.ok).toBe(true);
|
||||
|
||||
const third = runMigrations(sqlite, dir);
|
||||
expect(third.ok).toBe(true);
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("tracks migrations in _migrations table", () => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const dir = mkdtempSync(join(tmpdir(), "nerve-journal2-"));
|
||||
writeFileSync(join(dir, "0001_init.sql"), INIT_SQL);
|
||||
|
||||
runMigrations(sqlite, dir);
|
||||
|
||||
const rows = sqlite.prepare("SELECT name FROM _migrations ORDER BY name").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe("0001_init.sql");
|
||||
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { NerveConfig, Signal } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import { createSenseScheduler } from "../sense-scheduler.js";
|
||||
import { createSignalBus } from "../signal-bus.js";
|
||||
|
||||
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
return {
|
||||
@@ -12,7 +11,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -25,10 +23,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSignal(senseId: string, payload: unknown = 1): Signal {
|
||||
return { id: 1, senseId, payload, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
describe("SenseScheduler — throttle + pending deferred trigger", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -47,27 +41,21 @@ describe("SenseScheduler — throttle + pending deferred trigger", () => {
|
||||
throttle: 2000,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: ["cpu-usage"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
// First trigger fires immediately (outside throttle window)
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onComputeComplete("cpu-usage");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
// Second trigger arrives 500ms later — still within throttle window (2000ms)
|
||||
vi.advanceTimersByTime(500);
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
// Should not fire yet (throttled)
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
// Advance past the throttle window end; deferred trigger should now fire
|
||||
vi.advanceTimersByTime(1600);
|
||||
expect(triggered.length).toBe(2);
|
||||
|
||||
@@ -83,30 +71,25 @@ describe("SenseScheduler — throttle + pending deferred trigger", () => {
|
||||
throttle: 2000,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: ["cpu-usage"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
// First trigger fires
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onComputeComplete("cpu-usage");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
// Multiple triggers within throttle window — should not stack
|
||||
vi.advanceTimersByTime(300);
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
vi.advanceTimersByTime(300);
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
vi.advanceTimersByTime(300);
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
// Advance past window — exactly one deferred trigger fires
|
||||
vi.advanceTimersByTime(1200);
|
||||
scheduler.onComputeComplete("cpu-usage");
|
||||
expect(triggered.length).toBe(2);
|
||||
@@ -123,28 +106,23 @@ describe("SenseScheduler — throttle + pending deferred trigger", () => {
|
||||
throttle: 2000,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: ["cpu-usage"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onComputeComplete("cpu-usage");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
// Trigger during throttle window — schedules deferred
|
||||
vi.advanceTimersByTime(500);
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
// Stop before window ends
|
||||
scheduler.stop();
|
||||
|
||||
// Advance past window — deferred timer should have been cleared
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(triggered.length).toBe(1);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { NerveConfig, Signal } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import { createSenseScheduler } from "../sense-scheduler.js";
|
||||
import { createSignalBus } from "../signal-bus.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
return {
|
||||
@@ -16,7 +11,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -25,7 +19,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -34,7 +27,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
@@ -47,14 +39,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function makeSignal(senseId: string, payload: unknown = 1): Signal {
|
||||
return { id: 1, senseId, payload, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SenseScheduler — interval schedule", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -72,14 +56,11 @@ describe("SenseScheduler — interval schedule", () => {
|
||||
"cpu-usage": { ...base.senses["cpu-usage"], interval: 1000, on: [] },
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
// Use a ref so the triggerFn can call back into the scheduler
|
||||
const ref: { scheduler: ReturnType<typeof createSenseScheduler> | null } = {
|
||||
scheduler: null,
|
||||
};
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => {
|
||||
const scheduler = createSenseScheduler(config, (name) => {
|
||||
triggered.push(name);
|
||||
// Immediately complete compute so the scheduler is not blocked by in-flight state
|
||||
ref.scheduler?.onComputeComplete(name);
|
||||
});
|
||||
ref.scheduler = scheduler;
|
||||
@@ -101,11 +82,10 @@ describe("SenseScheduler — interval schedule", () => {
|
||||
"cpu-usage": { ...base.senses["cpu-usage"], interval: 500, on: [] },
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const ref: { scheduler: ReturnType<typeof createSenseScheduler> | null } = {
|
||||
scheduler: null,
|
||||
};
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => {
|
||||
const scheduler = createSenseScheduler(config, (name) => {
|
||||
triggered.push(name);
|
||||
ref.scheduler?.onComputeComplete(name);
|
||||
});
|
||||
@@ -128,10 +108,8 @@ describe("SenseScheduler — interval schedule", () => {
|
||||
"cpu-usage": { ...base.senses["cpu-usage"], interval: 1000, on: [] },
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
// Only advance 500ms — should be 0 triggers (not catching up)
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(triggered.length).toBe(0);
|
||||
|
||||
@@ -140,7 +118,7 @@ describe("SenseScheduler — interval schedule", () => {
|
||||
});
|
||||
|
||||
describe("SenseScheduler — event (on) schedule", () => {
|
||||
it("triggers target sense when watched sense emits a signal", () => {
|
||||
it("triggers target sense when watched sense completes", () => {
|
||||
const triggered: string[] = [];
|
||||
const base = makeConfig();
|
||||
const config = makeConfig({
|
||||
@@ -153,12 +131,11 @@ describe("SenseScheduler — event (on) schedule", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onComputeComplete("system-health");
|
||||
bus.emit(makeSignal("disk-usage"));
|
||||
scheduler.onSenseCompleted("disk-usage");
|
||||
scheduler.onComputeComplete("system-health");
|
||||
|
||||
expect(triggered.length).toBe(2);
|
||||
@@ -167,7 +144,7 @@ describe("SenseScheduler — event (on) schedule", () => {
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
it("does not trigger for signals from non-watched senses", () => {
|
||||
it("does not trigger for completions of non-watched senses", () => {
|
||||
const triggered: string[] = [];
|
||||
const base = makeConfig();
|
||||
const config = makeConfig({
|
||||
@@ -176,10 +153,9 @@ describe("SenseScheduler — event (on) schedule", () => {
|
||||
"system-health": { ...base.senses["system-health"], interval: null, on: ["cpu-usage"] },
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
bus.emit(makeSignal("disk-usage"));
|
||||
scheduler.onSenseCompleted("disk-usage");
|
||||
|
||||
expect(triggered.length).toBe(0);
|
||||
|
||||
@@ -195,11 +171,10 @@ describe("SenseScheduler — event (on) schedule", () => {
|
||||
"system-health": { ...base.senses["system-health"], interval: null, on: ["cpu-usage"] },
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
scheduler.stop();
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
|
||||
expect(triggered.length).toBe(0);
|
||||
});
|
||||
@@ -222,20 +197,17 @@ describe("SenseScheduler — throttle", () => {
|
||||
throttle: 2000,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: ["cpu-usage"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onComputeComplete("cpu-usage");
|
||||
|
||||
// Immediately trigger again — within throttle window
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
@@ -251,20 +223,18 @@ describe("SenseScheduler — throttle", () => {
|
||||
throttle: 1000,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
retention: 10_000,
|
||||
interval: null,
|
||||
on: ["cpu-usage"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onComputeComplete("cpu-usage");
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onComputeComplete("cpu-usage");
|
||||
|
||||
expect(triggered.length).toBe(2);
|
||||
@@ -283,24 +253,19 @@ describe("SenseScheduler — merge/coalesce", () => {
|
||||
"system-health": { ...base.senses["system-health"], interval: null, on: ["cpu-usage"] },
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
// First trigger starts compute
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
// Three more arrive while first is in-flight — all should coalesce to one pending
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
// Complete first compute → pending drains as exactly one more run
|
||||
scheduler.onComputeComplete("system-health");
|
||||
expect(triggered.length).toBe(2);
|
||||
|
||||
// Complete second compute → no more pending
|
||||
scheduler.onComputeComplete("system-health");
|
||||
expect(triggered.length).toBe(2);
|
||||
|
||||
@@ -316,13 +281,11 @@ describe("SenseScheduler — merge/coalesce", () => {
|
||||
"system-health": { ...base.senses["system-health"], interval: null, on: ["cpu-usage"] },
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
// Complete with no pending
|
||||
scheduler.onComputeComplete("system-health");
|
||||
expect(triggered.length).toBe(1);
|
||||
|
||||
@@ -351,14 +314,11 @@ describe("SenseScheduler — interval + on combined", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const bus = createSignalBus();
|
||||
const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name));
|
||||
const scheduler = createSenseScheduler(config, (name) => triggered.push(name));
|
||||
|
||||
// Event trigger
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
scheduler.onSenseCompleted("cpu-usage");
|
||||
scheduler.onComputeComplete("system-health");
|
||||
|
||||
// Interval trigger
|
||||
vi.advanceTimersByTime(1000);
|
||||
scheduler.onComputeComplete("system-health");
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Signal } from "@uncaged/nerve-core";
|
||||
import { createSignalBus } from "../signal-bus.js";
|
||||
|
||||
function makeSignal(senseId: string, payload: unknown = 1): Signal {
|
||||
return { id: 1, senseId, payload, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
describe("createSignalBus", () => {
|
||||
it("delivers emitted signal to a subscriber", () => {
|
||||
const bus = createSignalBus();
|
||||
const received: Signal[] = [];
|
||||
bus.subscribe((s) => received.push(s));
|
||||
|
||||
const sig = makeSignal("cpu-usage", 42);
|
||||
bus.emit(sig);
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toBe(sig);
|
||||
});
|
||||
|
||||
it("delivers to multiple subscribers", () => {
|
||||
const bus = createSignalBus();
|
||||
const a: Signal[] = [];
|
||||
const b: Signal[] = [];
|
||||
bus.subscribe((s) => a.push(s));
|
||||
bus.subscribe((s) => b.push(s));
|
||||
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
|
||||
expect(a).toHaveLength(1);
|
||||
expect(b).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("unsubscribe stops delivery", () => {
|
||||
const bus = createSignalBus();
|
||||
const received: Signal[] = [];
|
||||
const unsub = bus.subscribe((s) => received.push(s));
|
||||
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
unsub();
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("remaining subscribers still receive after one unsubscribes", () => {
|
||||
const bus = createSignalBus();
|
||||
const a: Signal[] = [];
|
||||
const b: Signal[] = [];
|
||||
const unsubA = bus.subscribe((s) => a.push(s));
|
||||
bus.subscribe((s) => b.push(s));
|
||||
|
||||
unsubA();
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
|
||||
expect(a).toHaveLength(0);
|
||||
expect(b).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emit with no subscribers does nothing", () => {
|
||||
const bus = createSignalBus();
|
||||
expect(() => bus.emit(makeSignal("cpu-usage"))).not.toThrow();
|
||||
});
|
||||
|
||||
it("dispatch is synchronous", () => {
|
||||
const bus = createSignalBus();
|
||||
const order: string[] = [];
|
||||
bus.subscribe(() => order.push("handler"));
|
||||
order.push("before");
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
order.push("after");
|
||||
expect(order).toEqual(["before", "handler", "after"]);
|
||||
});
|
||||
|
||||
it("handler exceptions don't prevent other handlers from running", () => {
|
||||
const bus = createSignalBus();
|
||||
const received: Signal[] = [];
|
||||
bus.subscribe(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
bus.subscribe((s) => received.push(s));
|
||||
|
||||
expect(() => bus.emit(makeSignal("cpu-usage"))).toThrow("boom");
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("same handler can be subscribed once and fires once per emit", () => {
|
||||
const bus = createSignalBus();
|
||||
const handler = vi.fn();
|
||||
bus.subscribe(handler);
|
||||
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
bus.emit(makeSignal("cpu-usage"));
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -80,8 +80,18 @@ describe("createSenseWorkerPool", () => {
|
||||
await startWorkerWithReady(pool, "g1");
|
||||
expect(mockChildren).toHaveLength(1);
|
||||
const child = mockChildren[0];
|
||||
child.emit("message", { type: "signal", sense: "s", payload: 1 });
|
||||
expect(onWorkerMessage).toHaveBeenCalledWith({ type: "signal", sense: "s", payload: 1 });
|
||||
child.emit("message", {
|
||||
type: "compute-result",
|
||||
sense: "s",
|
||||
state: 1,
|
||||
workflow: null,
|
||||
});
|
||||
expect(onWorkerMessage).toHaveBeenCalledWith({
|
||||
type: "compute-result",
|
||||
sense: "s",
|
||||
state: 1,
|
||||
workflow: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("sendCompute delivers to the worker for that group", async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ function baseConfig(script: string) {
|
||||
onMessage: vi.fn(),
|
||||
onReady: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
onCrashLimitReached: null,
|
||||
respawn: {
|
||||
enabled: true,
|
||||
maxCrashes: 6,
|
||||
@@ -84,7 +85,7 @@ describe("createWorkerRuntime", () => {
|
||||
const rt = track(createWorkerRuntime(baseConfig(echoWorkerPath)));
|
||||
await rt.start("k");
|
||||
expect(rt.has("k")).toBe(true);
|
||||
await rt.evict("k");
|
||||
await rt.evict("k", null);
|
||||
expect(rt.has("k")).toBe(false);
|
||||
await rt.shutdown();
|
||||
});
|
||||
@@ -94,7 +95,7 @@ describe("createWorkerRuntime", () => {
|
||||
await rt.start("k");
|
||||
const before = rt.pid("k");
|
||||
expect(before).not.toBeNull();
|
||||
await rt.drain("k");
|
||||
await rt.drain("k", null);
|
||||
const after = rt.pid("k");
|
||||
expect(after).not.toBeNull();
|
||||
expect(after).not.toBe(before);
|
||||
|
||||
@@ -26,6 +26,11 @@ function makeMockChild(pid = 1): MockChild {
|
||||
child.connected = true;
|
||||
child.exitCode = null;
|
||||
child.pid = pid;
|
||||
setImmediate(() => {
|
||||
if (child.connected) {
|
||||
child.emit("message", { type: "ready" });
|
||||
}
|
||||
});
|
||||
child.send = vi.fn((msg: unknown) => {
|
||||
if (
|
||||
msg !== null &&
|
||||
@@ -110,7 +115,7 @@ describe("WorkflowManager", () => {
|
||||
});
|
||||
|
||||
describe("startWorkflow under concurrency limit dispatches thread", () => {
|
||||
it("forks a worker and sends start-thread when active < concurrency", () => {
|
||||
it("forks a worker and sends start-thread when active < concurrency", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
"my-workflow": { concurrency: 2, overflow: "drop" },
|
||||
@@ -118,6 +123,7 @@ describe("WorkflowManager", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockChildren).toHaveLength(1);
|
||||
expect(mockChildren[0].send).toHaveBeenCalledWith(
|
||||
@@ -126,7 +132,7 @@ describe("WorkflowManager", () => {
|
||||
expect(mgr.activeCount("my-workflow")).toBe(1);
|
||||
});
|
||||
|
||||
it("reuses the same worker for a second thread under the limit", () => {
|
||||
it("reuses the same worker for a second thread under the limit", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
"my-workflow": { concurrency: 3, overflow: "drop" },
|
||||
@@ -135,6 +141,7 @@ describe("WorkflowManager", () => {
|
||||
|
||||
mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10, dryRun: false });
|
||||
mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Only one forked child — worker is reused
|
||||
expect(mockChildren).toHaveLength(1);
|
||||
@@ -142,7 +149,7 @@ describe("WorkflowManager", () => {
|
||||
expect(mgr.activeCount("my-workflow")).toBe(2);
|
||||
});
|
||||
|
||||
it("logs a 'started' event for each dispatched thread", () => {
|
||||
it("logs a 'started' event for each dispatched thread", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
"my-workflow": { concurrency: 2, overflow: "drop" },
|
||||
@@ -150,6 +157,7 @@ describe("WorkflowManager", () => {
|
||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||
|
||||
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ source: "workflow", type: "started" }),
|
||||
@@ -159,7 +167,7 @@ describe("WorkflowManager", () => {
|
||||
});
|
||||
|
||||
describe("startWorkflow at limit with drop overflow drops the request", () => {
|
||||
it("does NOT send start-thread when at concurrency limit with overflow=drop", () => {
|
||||
it("does NOT send start-thread when at concurrency limit with overflow=drop", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
"drop-wf": { concurrency: 1, overflow: "drop" },
|
||||
@@ -169,6 +177,7 @@ describe("WorkflowManager", () => {
|
||||
mgr.startWorkflow("drop-wf", { prompt: "first", maxRounds: 10, dryRun: false });
|
||||
// now at limit — second call should be dropped
|
||||
mgr.startWorkflow("drop-wf", { prompt: "second", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mgr.activeCount("drop-wf")).toBe(1);
|
||||
expect(mgr.queueLength("drop-wf")).toBe(0);
|
||||
@@ -254,7 +263,7 @@ describe("WorkflowManager", () => {
|
||||
});
|
||||
|
||||
describe("completing a thread dequeues the next one", () => {
|
||||
it("dispatches the next queued thread when the active thread sends completed", () => {
|
||||
it("dispatches the next queued thread when the active thread sends completed", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
"queue-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
|
||||
@@ -263,6 +272,7 @@ describe("WorkflowManager", () => {
|
||||
|
||||
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
|
||||
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mgr.activeCount("queue-wf")).toBe(1);
|
||||
expect(mgr.queueLength("queue-wf")).toBe(1);
|
||||
@@ -289,7 +299,7 @@ describe("WorkflowManager", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches next queued thread when active thread sends failed", () => {
|
||||
it("dispatches next queued thread when active thread sends failed", async () => {
|
||||
const logStore = makeLogStore();
|
||||
const config = makeConfig({
|
||||
"queue-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
|
||||
@@ -298,6 +308,7 @@ describe("WorkflowManager", () => {
|
||||
|
||||
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
|
||||
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const child = mockChildren[0];
|
||||
const firstRunId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0].runId as string;
|
||||
@@ -325,6 +336,7 @@ describe("WorkflowManager", () => {
|
||||
|
||||
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Two distinct workers should have been forked
|
||||
expect(mockChildren).toHaveLength(2);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
/**
|
||||
* Echo adapter (`type: "echo"`) — returns the assembled prompt unchanged.
|
||||
* Used for tests and dry-run wiring before real adapters exist.
|
||||
*/
|
||||
export function createEchoAgent(_config: AgentConfig): AgentFn {
|
||||
return async (_ctx: ThreadContext, prompt: string) => prompt;
|
||||
}
|
||||
@@ -191,7 +191,6 @@
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Triggers</th>
|
||||
<th>Last signal</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -343,7 +342,7 @@
|
||||
if (!list.length) {
|
||||
var tr = document.createElement('tr');
|
||||
var td = document.createElement('td');
|
||||
td.colSpan = 5;
|
||||
td.colSpan = 4;
|
||||
td.style.color = 'var(--muted)';
|
||||
td.textContent = 'No senses registered.';
|
||||
tr.appendChild(td);
|
||||
@@ -353,12 +352,10 @@
|
||||
list.forEach(function (s) {
|
||||
var tr = document.createElement('tr');
|
||||
var triggers = (s.triggers && s.triggers.length) ? s.triggers.join('; ') : '—';
|
||||
var last = s.lastSignalTimestamp != null ? fmtTime(s.lastSignalTimestamp) : '—';
|
||||
tr.innerHTML =
|
||||
'<td>' + escapeHtml(s.name) + '</td>' +
|
||||
'<td>' + escapeHtml(String(s.group || '—')) + '</td>' +
|
||||
'<td>' + escapeHtml(triggers) + '</td>' +
|
||||
'<td>' + escapeHtml(last) + '</td>' +
|
||||
'<td></td>';
|
||||
var tdBtn = tr.querySelector('td:last-child');
|
||||
var b = document.createElement('button');
|
||||
|
||||
@@ -4,7 +4,7 @@ export type {
|
||||
HealthRequestMessage,
|
||||
HealthResponseMessage,
|
||||
ParentToWorkerMessage,
|
||||
SignalMessage,
|
||||
ComputeResultMessage,
|
||||
ErrorMessage,
|
||||
ReadyMessage,
|
||||
WorkerToParentMessage,
|
||||
@@ -15,16 +15,7 @@ export type {
|
||||
ThreadWorkflowMessageMessage,
|
||||
} from "./ipc.js";
|
||||
|
||||
export type { SignalBus, SignalHandler, Unsubscribe } from "./signal-bus.js";
|
||||
|
||||
export type { DrizzleDB, SenseRuntime } from "./sense-runtime.js";
|
||||
|
||||
export {
|
||||
runMigrations,
|
||||
openSenseDb,
|
||||
loadSenseModule,
|
||||
executeCompute,
|
||||
} from "./sense-runtime.js";
|
||||
export { loadSenseModule, executeCompute, readState, writeState } from "./sense-runtime.js";
|
||||
|
||||
export { createKernel } from "./kernel.js";
|
||||
export type { Kernel, KernelOptions, KernelHealth } from "./kernel.js";
|
||||
@@ -56,5 +47,3 @@ export type {
|
||||
|
||||
export { createWorkflowManager } from "./workflow-manager.js";
|
||||
export type { WorkflowManager } from "./workflow-manager.js";
|
||||
|
||||
export { createEchoAgent } from "./agent-adapters/echo.js";
|
||||
|
||||
+31
-14
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { Result, WorkflowTrigger } from "@uncaged/nerve-core";
|
||||
import { err, isPlainRecord, ok } 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 = {
|
||||
@@ -65,11 +65,12 @@ export type ParentToWorkerMessage =
|
||||
| ResumeThreadMessage
|
||||
| KillThreadMessage;
|
||||
|
||||
/** Worker → Parent: compute produced a signal */
|
||||
export type SignalMessage = {
|
||||
type: "signal";
|
||||
/** Worker → Parent: sense compute finished (state persisted in worker; workflow optional). */
|
||||
export type ComputeResultMessage = {
|
||||
type: "compute-result";
|
||||
sense: string;
|
||||
payload: unknown;
|
||||
state: unknown;
|
||||
workflow: WorkflowTrigger | null;
|
||||
};
|
||||
|
||||
/** Worker → Parent: sense compute result includes a workflow to start */
|
||||
@@ -140,7 +141,7 @@ export type ThreadWorkflowMessageMessage = {
|
||||
|
||||
/** Union of all messages a worker sends to the parent */
|
||||
export type WorkerToParentMessage =
|
||||
| SignalMessage
|
||||
| ComputeResultMessage
|
||||
| ErrorMessage
|
||||
| ReadyMessage
|
||||
| HealthResponseMessage
|
||||
@@ -247,17 +248,33 @@ export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage>
|
||||
}
|
||||
}
|
||||
|
||||
function parseSignalMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||
function parseComputeResultMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||
if (typeof obj.sense !== "string") {
|
||||
return err(new Error("Worker 'signal' message missing string 'sense' field"));
|
||||
return err(new Error("Worker 'compute-result' message missing string 'sense' field"));
|
||||
}
|
||||
if (!("payload" in obj)) {
|
||||
return err(new Error("Worker 'signal' message missing 'payload' field"));
|
||||
if (!("state" in obj)) {
|
||||
return err(new Error("Worker 'compute-result' message missing 'state' field"));
|
||||
}
|
||||
if (!("workflow" in obj)) {
|
||||
return err(new Error("Worker 'compute-result' message missing 'workflow' field"));
|
||||
}
|
||||
const wfRaw = obj.workflow;
|
||||
if (wfRaw !== null && !isPlainRecord(wfRaw)) {
|
||||
return err(new Error("Worker 'compute-result' workflow must be an object or null"));
|
||||
}
|
||||
let workflow: WorkflowTrigger | null;
|
||||
if (wfRaw === null) {
|
||||
workflow = null;
|
||||
} else {
|
||||
const parsed = parseWorkflowTrigger(wfRaw);
|
||||
if (!parsed.ok) return err(parsed.error);
|
||||
workflow = parsed.value;
|
||||
}
|
||||
return ok({
|
||||
type: "signal",
|
||||
type: "compute-result",
|
||||
sense: obj.sense,
|
||||
payload: obj.payload,
|
||||
state: obj.state,
|
||||
workflow,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -341,7 +358,7 @@ function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkerToPar
|
||||
}
|
||||
|
||||
const WORKER_MSG_TYPES = new Set([
|
||||
"signal",
|
||||
"compute-result",
|
||||
"error",
|
||||
"ready",
|
||||
"health-response",
|
||||
@@ -428,7 +445,7 @@ 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 (obj.type === "signal") return parseSignalMsg(obj);
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Kernel — ties sense workers, signal bus, sense scheduler, workflow manager,
|
||||
* Kernel — ties sense workers, sense scheduler, workflow manager,
|
||||
* optional file watcher, and daemon IPC.
|
||||
*/
|
||||
|
||||
@@ -12,10 +12,9 @@ import {
|
||||
type HealthInfo,
|
||||
type NerveConfig,
|
||||
type SenseInfo,
|
||||
type Signal,
|
||||
type WorkflowTrigger,
|
||||
senseTriggerLabels,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { routeSenseComputeOutput } from "@uncaged/nerve-core";
|
||||
|
||||
import { createLogStore } from "@uncaged/nerve-store";
|
||||
import type { LogStore } from "@uncaged/nerve-store";
|
||||
@@ -36,8 +35,6 @@ import {
|
||||
} from "./kernel-sense-groups.js";
|
||||
import { createSenseScheduler } from "./sense-scheduler.js";
|
||||
import type { SenseScheduler } from "./sense-scheduler.js";
|
||||
import { createSignalBus } from "./signal-bus.js";
|
||||
import type { SignalBus } from "./signal-bus.js";
|
||||
import { createSenseWorkerPool, resolveWorkerScript } from "./worker-pool.js";
|
||||
import { createWorkflowManager } from "./workflow-manager.js";
|
||||
import type { WorkflowManager } from "./workflow-manager.js";
|
||||
@@ -55,7 +52,6 @@ export type Kernel = {
|
||||
stop: () => Promise<void>;
|
||||
groups: Set<string>;
|
||||
senseCount: number;
|
||||
bus: SignalBus;
|
||||
logStore: LogStore;
|
||||
workflowManager: WorkflowManager;
|
||||
ready: Promise<void>;
|
||||
@@ -110,7 +106,6 @@ export function createKernel(
|
||||
nerveRoot: string,
|
||||
options: KernelOptions = defaultKernelOptions(),
|
||||
): Kernel {
|
||||
const bus: SignalBus = createSignalBus();
|
||||
const workerScript = options.workerScript ?? resolveWorkerScript();
|
||||
const startTime = Date.now();
|
||||
const startedAtIso = new Date(startTime).toISOString();
|
||||
@@ -127,12 +122,6 @@ export function createKernel(
|
||||
|
||||
let config = initialConfig;
|
||||
|
||||
let _signalIdCounter = 0;
|
||||
function nextSignalId(): number {
|
||||
_signalIdCounter += 1;
|
||||
return _signalIdCounter;
|
||||
}
|
||||
|
||||
const workflowManager = createWorkflowManager(nerveRoot, config, logStore);
|
||||
|
||||
const groups = new Set<string>();
|
||||
@@ -156,38 +145,14 @@ export function createKernel(
|
||||
}
|
||||
}
|
||||
|
||||
function handleSenseWorkerSignal(senseName: string, payload: unknown): void {
|
||||
const routeResult = routeSenseComputeOutput(payload);
|
||||
if (!routeResult.ok) {
|
||||
process.stderr.write(
|
||||
`[kernel] sense "${senseName}" invalid compute payload: ${routeResult.error.message}\n`,
|
||||
);
|
||||
logStore.append({
|
||||
source: "sense",
|
||||
type: "error",
|
||||
refId: senseName,
|
||||
payload: JSON.stringify({ error: routeResult.error.message }),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
scheduler.onComputeComplete(senseName);
|
||||
return;
|
||||
}
|
||||
const { signal: signalPayload, workflow } = routeResult.value;
|
||||
|
||||
const signal: Signal = {
|
||||
id: nextSignalId(),
|
||||
senseId: senseName,
|
||||
payload: signalPayload,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
function handleComputeResult(senseName: string, workflow: WorkflowTrigger | null): void {
|
||||
logStore.append({
|
||||
source: "sense",
|
||||
type: "signal",
|
||||
type: "compute-complete",
|
||||
refId: senseName,
|
||||
payload: JSON.stringify(signalPayload),
|
||||
timestamp: signal.timestamp,
|
||||
payload: null,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
bus.emit(signal);
|
||||
|
||||
if (workflow !== null) {
|
||||
workflowManager.startWorkflow(workflow.name, {
|
||||
@@ -204,6 +169,7 @@ export function createKernel(
|
||||
});
|
||||
}
|
||||
scheduler.onComputeComplete(senseName);
|
||||
scheduler.onSenseCompleted(senseName);
|
||||
}
|
||||
|
||||
function handleWorkerMessage(raw: unknown): void {
|
||||
@@ -235,8 +201,8 @@ export function createKernel(
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "signal") {
|
||||
handleSenseWorkerSignal(msg.sense, msg.payload);
|
||||
if (msg.type === "compute-result") {
|
||||
handleComputeResult(msg.sense, msg.workflow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +236,7 @@ export function createKernel(
|
||||
senseWorkerPool.sendCompute(group, senseName);
|
||||
}
|
||||
|
||||
scheduler = createSenseScheduler(config, bus, triggerFn, {
|
||||
scheduler = createSenseScheduler(config, triggerFn, {
|
||||
logStore,
|
||||
});
|
||||
|
||||
@@ -306,7 +272,7 @@ export function createKernel(
|
||||
const oldWorkflows = config.workflows;
|
||||
config = newConfig;
|
||||
scheduler.stop();
|
||||
scheduler = createSenseScheduler(config, bus, triggerFn, {
|
||||
scheduler = createSenseScheduler(config, triggerFn, {
|
||||
logStore,
|
||||
});
|
||||
workflowManager.updateConfig(newConfig);
|
||||
@@ -388,22 +354,13 @@ export function createKernel(
|
||||
workflowManager,
|
||||
triggerSense,
|
||||
listSenses(): SenseInfo[] {
|
||||
return Object.entries(config.senses).map(([name, senseConfig]) => {
|
||||
const entries = logStore.query({
|
||||
source: "sense",
|
||||
type: "signal",
|
||||
refId: name,
|
||||
});
|
||||
const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
|
||||
return {
|
||||
name,
|
||||
group: senseConfig.group,
|
||||
throttle: senseConfig.throttle,
|
||||
timeout: senseConfig.timeout,
|
||||
triggers: senseTriggerLabels(name, config.senses),
|
||||
lastSignalTimestamp: lastEntry !== null ? lastEntry.timestamp : null,
|
||||
};
|
||||
});
|
||||
return Object.entries(config.senses).map(([name, senseConfig]) => ({
|
||||
name,
|
||||
group: senseConfig.group,
|
||||
throttle: senseConfig.throttle,
|
||||
timeout: senseConfig.timeout,
|
||||
triggers: senseTriggerLabels(name, config.senses),
|
||||
}));
|
||||
},
|
||||
getHealthInfo: getDaemonHealth,
|
||||
getDefaultMaxRounds: () => config.maxRounds,
|
||||
@@ -468,7 +425,6 @@ export function createKernel(
|
||||
stop,
|
||||
groups,
|
||||
senseCount,
|
||||
bus,
|
||||
logStore,
|
||||
workflowManager,
|
||||
ready,
|
||||
|
||||
@@ -1,172 +1,44 @@
|
||||
import { mkdirSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { drizzle } from "drizzle-orm/node-sqlite";
|
||||
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import type { ComputeResult, Result, SenseComputeFn } from "@uncaged/nerve-core";
|
||||
import { DEFAULT_SENSE_SIGNAL_RETENTION, err, isPlainRecord, ok } from "@uncaged/nerve-core";
|
||||
|
||||
/** A Drizzle DB instance (schema-generic) */
|
||||
export type DrizzleDB = NodeSQLiteDatabase<Record<string, never>>;
|
||||
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 */
|
||||
export type SenseRuntime = {
|
||||
name: string;
|
||||
db: DrizzleDB;
|
||||
compute: SenseComputeFn;
|
||||
table: SQLiteTable;
|
||||
persistSignal: (payload: unknown) => void;
|
||||
state: unknown;
|
||||
statePath: string;
|
||||
};
|
||||
|
||||
function ensureMigrationsTable(sqlite: DatabaseSync): Result<void> {
|
||||
export function readState(statePath: string, initialState: unknown): unknown {
|
||||
try {
|
||||
sqlite.exec(
|
||||
`CREATE TABLE IF NOT EXISTS _migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)`,
|
||||
);
|
||||
return ok(undefined);
|
||||
if (!existsSync(statePath)) return initialState;
|
||||
const raw = readFileSync(statePath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return err(new Error(`Failed to create _migrations table: ${msg}`));
|
||||
process.stderr.write(`[sense-runtime] warning: failed to read state from "${statePath}": ${msg} — using initialState\n`);
|
||||
return initialState;
|
||||
}
|
||||
}
|
||||
|
||||
function listMigrationFiles(migrationsDir: string): Result<string[]> {
|
||||
try {
|
||||
const files = readdirSync(migrationsDir)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
return ok(files);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return err(new Error(`Failed to read migrations directory "${migrationsDir}": ${msg}`));
|
||||
}
|
||||
}
|
||||
|
||||
function applyMigrationFile(sqlite: DatabaseSync, file: string, filePath: string): Result<void> {
|
||||
let sql: string;
|
||||
try {
|
||||
sql = readFileSync(filePath, "utf8");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return err(new Error(`Failed to read migration file "${filePath}": ${msg}`));
|
||||
}
|
||||
|
||||
const insertJournal = sqlite.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)");
|
||||
sqlite.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
sqlite.exec(sql);
|
||||
insertJournal.run(file, Date.now());
|
||||
sqlite.exec("COMMIT");
|
||||
return ok(undefined);
|
||||
} catch (e) {
|
||||
try {
|
||||
sqlite.exec("ROLLBACK");
|
||||
} catch {
|
||||
// ignore secondary errors during rollback
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return err(new Error(`Migration "${file}" failed: ${msg}`));
|
||||
}
|
||||
export function writeState(statePath: string, state: unknown): void {
|
||||
const dir = dirname(statePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tmp = join(dir, `.${Date.now()}.tmp`);
|
||||
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
||||
renameSync(tmp, statePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all *.sql migration files in the given directory against a
|
||||
* `node:sqlite` DatabaseSync, in lexicographic order.
|
||||
* Tracks applied migrations in _migrations table to avoid re-running.
|
||||
*/
|
||||
export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Result<void> {
|
||||
const tableResult = ensureMigrationsTable(sqlite);
|
||||
if (!tableResult.ok) return tableResult;
|
||||
|
||||
const filesResult = listMigrationFiles(migrationsDir);
|
||||
if (!filesResult.ok) return filesResult;
|
||||
|
||||
const migrationRows = sqlite.prepare("SELECT name FROM _migrations").all();
|
||||
const applied = new Set<string>(
|
||||
migrationRows
|
||||
.filter((r): r is { name: string } => isPlainRecord(r) && typeof r.name === "string")
|
||||
.map((r) => r.name),
|
||||
);
|
||||
|
||||
for (const file of filesResult.value) {
|
||||
if (applied.has(file)) continue;
|
||||
const result = applyMigrationFile(sqlite, file, join(migrationsDir, file));
|
||||
if (!result.ok) return result;
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */
|
||||
const SIGNAL_INSERTS_PER_PRUNE = 100;
|
||||
|
||||
/**
|
||||
* Open (or create) the SQLite file at `dbPath`, run all migrations in
|
||||
* `migrationsDir`, and wrap with Drizzle ORM.
|
||||
*/
|
||||
export function openSenseDb(
|
||||
dbPath: string,
|
||||
migrationsDir: string,
|
||||
retention: number = DEFAULT_SENSE_SIGNAL_RETENTION,
|
||||
): Result<{ sqlite: DatabaseSync; db: DrizzleDB; persistSignal: (payload: unknown) => void }> {
|
||||
let sqlite: DatabaseSync;
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
sqlite = new DatabaseSync(dbPath);
|
||||
sqlite.exec("PRAGMA journal_mode=WAL");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return err(new Error(`Failed to open database "${dbPath}": ${msg}`));
|
||||
}
|
||||
|
||||
const migResult = runMigrations(sqlite, migrationsDir);
|
||||
if (!migResult.ok) return migResult;
|
||||
|
||||
// Auto-create _signals table for signal persistence (all senses get this)
|
||||
sqlite.exec(
|
||||
`CREATE TABLE IF NOT EXISTS _signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
payload TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
const insertStmt = sqlite.prepare("INSERT INTO _signals (payload, timestamp) VALUES (?, ?)");
|
||||
const pruneStmt = sqlite.prepare(
|
||||
"DELETE FROM _signals WHERE id <= (SELECT id FROM _signals ORDER BY id DESC LIMIT 1 OFFSET ?)",
|
||||
);
|
||||
|
||||
let insertsSincePrune = 0;
|
||||
|
||||
function persistSignal(payload: unknown): void {
|
||||
const json = JSON.stringify(payload);
|
||||
insertStmt.run(json, Date.now());
|
||||
insertsSincePrune += 1;
|
||||
if (insertsSincePrune >= SIGNAL_INSERTS_PER_PRUNE) {
|
||||
insertsSincePrune = 0;
|
||||
pruneStmt.run(retention);
|
||||
}
|
||||
}
|
||||
|
||||
// Drizzle infers a schema-specific DB type; senses are schema-agnostic at this layer.
|
||||
const db = drizzle({ client: sqlite }) as DrizzleDB;
|
||||
return ok({ sqlite, db, persistSignal });
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically import the compute function and table from a sense's index.ts/js.
|
||||
* The module must export a named `compute` function and a named `table` (SQLiteTable).
|
||||
* Dynamically import `compute` and `initialState` from a sense's index.ts/js.
|
||||
* The module must export named `compute` and `initialState`.
|
||||
*/
|
||||
export async function loadSenseModule(
|
||||
senseIndexPath: string,
|
||||
): Promise<Result<{ compute: SenseComputeFn; table: SQLiteTable }>> {
|
||||
): Promise<Result<{ compute: SenseComputeFn; initialState: unknown }>> {
|
||||
let mod: unknown;
|
||||
|
||||
try {
|
||||
@@ -183,33 +55,24 @@ export async function loadSenseModule(
|
||||
);
|
||||
}
|
||||
|
||||
if (!("table" in mod) || mod.table === null || typeof mod.table !== "object") {
|
||||
return err(
|
||||
new Error(
|
||||
`Sense module "${senseIndexPath}" must export a named "table" (drizzle SQLiteTable)`,
|
||||
),
|
||||
);
|
||||
if (!("initialState" in mod)) {
|
||||
return err(new Error(`Sense module "${senseIndexPath}" must export a named "initialState"`));
|
||||
}
|
||||
|
||||
return ok({
|
||||
compute: mod.compute as SenseComputeFn,
|
||||
table: mod.table as SQLiteTable,
|
||||
initialState: mod.initialState,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a sense's compute function with an optional soft timeout.
|
||||
* If timeoutMs is provided and compute takes longer, the AbortSignal is
|
||||
* triggered and an error Result is returned.
|
||||
* When compute returns non-null, `result.signal` is persisted to the sense's
|
||||
* table via `db.insert(table).values(result.signal)` and `persistSignal` is
|
||||
* called with `result.signal`. Returns the full `ComputeResult` so callers
|
||||
* can inspect the `workflow` field.
|
||||
* Execute a sense's compute with current runtime state and an optional soft timeout.
|
||||
* On success, persists `result.state` to `runtime.statePath` and updates `runtime.state`.
|
||||
*/
|
||||
export async function executeCompute(
|
||||
runtime: SenseRuntime,
|
||||
timeoutMs?: number,
|
||||
): Promise<Result<ComputeResult<unknown>>> {
|
||||
): Promise<Result<{ state: unknown; workflow: WorkflowTrigger | null }>> {
|
||||
const controller = new AbortController();
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -224,16 +87,13 @@ export async function executeCompute(
|
||||
: null;
|
||||
|
||||
try {
|
||||
const computePromise = runtime.compute();
|
||||
const computePromise = runtime.compute(runtime.state);
|
||||
const result = timeoutPromise
|
||||
? await Promise.race([computePromise, timeoutPromise])
|
||||
: await computePromise;
|
||||
|
||||
if (result !== null) {
|
||||
// Cast required: DrizzleDB is schema-agnostic; the sense module guarantees shape compatibility.
|
||||
await runtime.db.insert(runtime.table).values(result.signal as Record<string, unknown>);
|
||||
runtime.persistSignal(result.signal);
|
||||
}
|
||||
writeState(runtime.statePath, result.state);
|
||||
runtime.state = result.state;
|
||||
|
||||
return ok(result);
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Supports:
|
||||
* - interval: periodic setInterval-based triggering
|
||||
* - on[]: event-driven triggering via the signal bus
|
||||
* - on[]: event-driven triggering when watched senses complete a compute cycle
|
||||
* - throttle: skip triggers that arrive too soon after the last compute
|
||||
* - merge/coalesce: if compute is in-flight, record one pending trigger;
|
||||
* run it once after the current compute completes (no unbounded queue)
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import type { LogStore } from "@uncaged/nerve-store";
|
||||
import type { SignalBus, Unsubscribe } from "./signal-bus.js";
|
||||
|
||||
/** Sends a compute message to the worker responsible for the given sense. */
|
||||
export type TriggerFn = (senseName: string) => void;
|
||||
@@ -28,6 +27,8 @@ type SenseState = {
|
||||
export type SenseScheduler = {
|
||||
/** Notify scheduler that a compute cycle finished. Drains the pending flag. */
|
||||
onComputeComplete: (senseName: string) => void;
|
||||
/** Notify scheduler that a sense completed so `on[]` subscribers may run. */
|
||||
onSenseCompleted: (senseName: string) => void;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
@@ -43,21 +44,29 @@ export type SenseSchedulerOptions = {
|
||||
* Create and start a sense scheduler.
|
||||
*
|
||||
* @param config Full NerveConfig (senses for schedule + throttle).
|
||||
* @param bus SignalBus to subscribe for event-driven triggers.
|
||||
* @param triggerFn Called with the sense name when a compute should be dispatched.
|
||||
* @param opts Optional: logStore for structured logging.
|
||||
* @returns SenseScheduler with stop() and onComputeComplete() methods.
|
||||
* @returns SenseScheduler with stop(), onComputeComplete(), and onSenseCompleted().
|
||||
*/
|
||||
export function createSenseScheduler(
|
||||
config: NerveConfig,
|
||||
bus: SignalBus,
|
||||
triggerFn: TriggerFn,
|
||||
opts?: SenseSchedulerOptions,
|
||||
): SenseScheduler {
|
||||
const logStore = opts?.logStore;
|
||||
const intervals: ReturnType<typeof setInterval>[] = [];
|
||||
const unsubscribers: Unsubscribe[] = [];
|
||||
const states = new Map<string, SenseState>();
|
||||
let stopped = false;
|
||||
|
||||
/** sense name → senses that list it in `on[]` */
|
||||
const onSubscribers = new Map<string, string[]>();
|
||||
for (const [senseName, sense] of Object.entries(config.senses)) {
|
||||
for (const watched of sense.on) {
|
||||
const list = onSubscribers.get(watched) ?? [];
|
||||
list.push(senseName);
|
||||
onSubscribers.set(watched, list);
|
||||
}
|
||||
}
|
||||
|
||||
function getState(senseName: string): SenseState {
|
||||
let state = states.get(senseName);
|
||||
@@ -89,6 +98,7 @@ export function createSenseScheduler(
|
||||
* If within throttle window, schedules a single deferred trigger at window end (fix #3).
|
||||
*/
|
||||
function maybeTrigger(senseName: string): void {
|
||||
if (stopped) return;
|
||||
const senseConfig = config.senses[senseName];
|
||||
const throttleMs = senseConfig?.throttle ?? null;
|
||||
const state = getState(senseName);
|
||||
@@ -118,10 +128,11 @@ export function createSenseScheduler(
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the kernel when a compute cycle completes (signal or error received).
|
||||
* Called by the kernel when a compute cycle completes (compute-result or error received).
|
||||
* Drains the pending flag if set.
|
||||
*/
|
||||
function onComputeComplete(senseName: string): void {
|
||||
if (stopped) return;
|
||||
const state = states.get(senseName);
|
||||
if (state === undefined) return;
|
||||
|
||||
@@ -152,8 +163,17 @@ export function createSenseScheduler(
|
||||
dispatchCompute(senseName);
|
||||
}
|
||||
|
||||
function onSenseCompleted(senseName: string): void {
|
||||
if (stopped) return;
|
||||
const subscribers = onSubscribers.get(senseName);
|
||||
if (subscribers === undefined) return;
|
||||
for (const sub of subscribers) {
|
||||
maybeTrigger(sub);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [senseName, sense] of Object.entries(config.senses)) {
|
||||
const { interval, on } = sense;
|
||||
const { interval } = sense;
|
||||
|
||||
if (interval !== null) {
|
||||
const id = setInterval(() => {
|
||||
@@ -161,25 +181,13 @@ export function createSenseScheduler(
|
||||
}, interval);
|
||||
intervals.push(id);
|
||||
}
|
||||
|
||||
if (on.length > 0) {
|
||||
const watchedSenses = new Set(on);
|
||||
const unsub = bus.subscribe((signal) => {
|
||||
if (watchedSenses.has(signal.senseId)) {
|
||||
maybeTrigger(senseName);
|
||||
}
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
stopped = true;
|
||||
for (const id of intervals) {
|
||||
clearInterval(id);
|
||||
}
|
||||
for (const unsub of unsubscribers) {
|
||||
unsub();
|
||||
}
|
||||
for (const state of states.values()) {
|
||||
if (state.deferredTimer !== null) {
|
||||
clearTimeout(state.deferredTimer);
|
||||
@@ -187,8 +195,7 @@ export function createSenseScheduler(
|
||||
}
|
||||
}
|
||||
intervals.length = 0;
|
||||
unsubscribers.length = 0;
|
||||
}
|
||||
|
||||
return { onComputeComplete, stop };
|
||||
return { onComputeComplete, onSenseCompleted, stop };
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
*
|
||||
* Layout assumptions (nerve user config at `~/.uncaged-nerve/`):
|
||||
* dist/senses/<name>/index.js ← bundled compute (esbuild)
|
||||
* senses/<name>/migrations/ ← SQL migration files
|
||||
* data/senses/<name>.db ← SQLite data file
|
||||
* data/senses/<name>.json ← persisted sense state
|
||||
* nerve.yaml ← config
|
||||
*/
|
||||
|
||||
@@ -19,13 +18,13 @@ import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import type { NerveConfig, WorkflowTrigger } from "@uncaged/nerve-core";
|
||||
|
||||
import type { WorkerToParentMessage } from "./ipc.js";
|
||||
import { parseParentMessage } from "./ipc.js";
|
||||
import { executeCompute, loadSenseModule, openSenseDb } from "./sense-runtime.js";
|
||||
import { executeCompute, loadSenseModule, readState } from "./sense-runtime.js";
|
||||
import type { SenseRuntime } from "./sense-runtime.js";
|
||||
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
|
||||
import { ignoreSessionBroadcastSignals } from "./worker-signals.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC helpers
|
||||
@@ -41,8 +40,11 @@ function sendReady(): void {
|
||||
send({ type: "ready" });
|
||||
}
|
||||
|
||||
function sendSignal(sense: string, payload: unknown): void {
|
||||
send({ type: "signal", sense, payload });
|
||||
function sendComputeResult(
|
||||
sense: string,
|
||||
value: { state: unknown; workflow: WorkflowTrigger | null },
|
||||
): void {
|
||||
send({ type: "compute-result", sense, state: value.state, workflow: value.workflow });
|
||||
}
|
||||
|
||||
function sendError(sense: string, error: string): void {
|
||||
@@ -72,31 +74,23 @@ function readConfig(nerveRoot: string): NerveConfig {
|
||||
return configResult.value;
|
||||
}
|
||||
|
||||
async function initSense(
|
||||
nerveRoot: string,
|
||||
senseName: string,
|
||||
retention: number,
|
||||
): Promise<SenseRuntime> {
|
||||
const dbPath = join(nerveRoot, "data", "senses", `${senseName}.db`);
|
||||
const migrationsDir = join(nerveRoot, "senses", senseName, "migrations");
|
||||
async function initSense(nerveRoot: string, senseName: string): Promise<SenseRuntime> {
|
||||
const statePath = join(nerveRoot, "data", "senses", `${senseName}.json`);
|
||||
const senseIndexPath = resolve(join(nerveRoot, "dist", "senses", senseName, "index.js"));
|
||||
|
||||
const dbResult = openSenseDb(dbPath, migrationsDir, retention);
|
||||
if (!dbResult.ok) {
|
||||
throw new Error(`Failed to init DB for "${senseName}": ${dbResult.error.message}`);
|
||||
}
|
||||
|
||||
const moduleResult = await loadSenseModule(senseIndexPath);
|
||||
if (!moduleResult.ok) {
|
||||
throw new Error(`Failed to load module for "${senseName}": ${moduleResult.error.message}`);
|
||||
}
|
||||
|
||||
const { compute, initialState } = moduleResult.value;
|
||||
const state = readState(statePath, initialState);
|
||||
|
||||
return {
|
||||
name: senseName,
|
||||
db: dbResult.value.db,
|
||||
compute: moduleResult.value.compute,
|
||||
table: moduleResult.value.table,
|
||||
persistSignal: dbResult.value.persistSignal,
|
||||
compute,
|
||||
state,
|
||||
statePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,10 +143,7 @@ async function runCompute(
|
||||
return;
|
||||
}
|
||||
clearGracePeriodTimer(senseName);
|
||||
if (result.value != null) {
|
||||
// Single IPC message: kernel uses routeSenseComputeOutput(payload) for signal + optional workflow.
|
||||
sendSignal(senseName, result.value);
|
||||
}
|
||||
sendComputeResult(senseName, result.value);
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
sendError(senseName, errMsg);
|
||||
@@ -236,8 +227,7 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
|
||||
|
||||
for (const senseName of groupSenses) {
|
||||
try {
|
||||
const retention = config.senses[senseName].retention;
|
||||
const runtime = await initSense(nerveRoot, senseName, retention);
|
||||
const runtime = await initSense(nerveRoot, senseName);
|
||||
runtimes.set(senseName, runtime);
|
||||
} catch (e: unknown) {
|
||||
const eMsg = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* In-memory signal bus for routing signals between sense workers and sense-scheduler subscribers.
|
||||
* Synchronous dispatch — no persistence, no async queuing.
|
||||
*
|
||||
* If a handler throws, the error is logged and remaining handlers still run.
|
||||
* The first error is re-thrown after all handlers complete so callers can observe it.
|
||||
*/
|
||||
|
||||
import type { Signal } from "@uncaged/nerve-core";
|
||||
|
||||
export type SignalHandler = (signal: Signal) => void;
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export type SignalBus = {
|
||||
emit: (signal: Signal) => void;
|
||||
subscribe: (handler: SignalHandler) => Unsubscribe;
|
||||
};
|
||||
|
||||
export function createSignalBus(): SignalBus {
|
||||
const handlers = new Set<SignalHandler>();
|
||||
|
||||
function emit(signal: Signal): void {
|
||||
let firstError: unknown = null;
|
||||
let hasError = false;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(signal);
|
||||
} catch (e) {
|
||||
console.error("[signal-bus] handler error:", e);
|
||||
if (!hasError) {
|
||||
firstError = e;
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
throw firstError;
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(handler: SignalHandler): Unsubscribe {
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
return { emit, subscribe };
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
const STDERR_TAIL_MAX_CHARS = 16_384;
|
||||
|
||||
/**
|
||||
* Forked workers inherit the parent's process group. In foreground `nerve dev`,
|
||||
* terminal-driven SIGINT/SIGTERM is delivered to the whole group, so workers can exit
|
||||
* on the default handler before the kernel sends `{ type: "shutdown" }` over IPC.
|
||||
* Swallow these in worker processes so the parent coordinates shutdown (issue #55).
|
||||
* Only call when `process.send` is defined (fork IPC); standalone `node …-worker.js` keeps default Ctrl+C behaviour.
|
||||
*/
|
||||
export function ignoreSessionBroadcastSignals(): void {
|
||||
const swallow = (): void => {};
|
||||
process.on("SIGINT", swallow);
|
||||
process.on("SIGTERM", swallow);
|
||||
}
|
||||
|
||||
export function teeCapturedStderr(child: ChildProcess, tail: { value: string }): void {
|
||||
const stream = child.stderr;
|
||||
if (stream === null || stream === undefined) return;
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk: string | Buffer) => {
|
||||
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||
process.stderr.write(text);
|
||||
tail.value = (tail.value + text).slice(-STDERR_TAIL_MAX_CHARS);
|
||||
});
|
||||
}
|
||||
|
||||
export function formatChildExitSummary(code: number | null, signal: NodeJS.Signals | null): string {
|
||||
const codeStr = code === null || code === undefined ? "null" : String(code);
|
||||
if (signal) {
|
||||
return `code=${codeStr} signal=${signal}`;
|
||||
}
|
||||
return `code=${codeStr}`;
|
||||
}
|
||||
|
||||
export function formatCapturedStderrTail(tail: string, maxChars = 800): string {
|
||||
const trimmed = tail.trim();
|
||||
if (trimmed.length === 0) return "";
|
||||
const normalized = trimmed.replace(/\r?\n/g, "\\n");
|
||||
if (normalized.length <= maxChars) {
|
||||
return ` worker_stderr=${normalized}`;
|
||||
}
|
||||
return ` worker_stderr=…${normalized.slice(-maxChars)}`;
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { ComputeMessage } from "./ipc.js";
|
||||
import { formatCapturedStderrTail, formatChildExitSummary } from "./worker-fork-support.js";
|
||||
import { createWorkerRuntime } from "./worker-runtime.js";
|
||||
import {
|
||||
createWorkerRuntime,
|
||||
formatCapturedStderrTail,
|
||||
formatChildExitSummary,
|
||||
} from "./worker-runtime.js";
|
||||
|
||||
export function resolveWorkerScript(): string {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -18,7 +21,7 @@ export function resolveWorkerScript(): string {
|
||||
export type SenseWorkerPoolOptions = {
|
||||
nerveRoot: string;
|
||||
workerScript: string;
|
||||
/** Invoked for every IPC message from a worker (including ready / signal / error). */
|
||||
/** Invoked for every IPC message from a worker (including ready / compute-result / error). */
|
||||
onWorkerMessage: (raw: unknown) => void;
|
||||
/** Sense names in a group — reserved for scheduler-aligned cleanup (kernel passes current config). */
|
||||
sensesForGroup: (group: string) => string[];
|
||||
@@ -66,6 +69,7 @@ export function createSenseWorkerPool(options: SenseWorkerPoolOptions): SenseWor
|
||||
onReady: (_key, msg) => {
|
||||
options.onWorkerMessage(msg);
|
||||
},
|
||||
onCrashLimitReached: null,
|
||||
onExit: (group, code, signal) => {
|
||||
const sig =
|
||||
signal === null || signal === undefined || signal === ""
|
||||
@@ -102,13 +106,13 @@ export function createSenseWorkerPool(options: SenseWorkerPoolOptions): SenseWor
|
||||
return;
|
||||
}
|
||||
options.onBeforeGroupRestart(group);
|
||||
await runtime.drain(group);
|
||||
await runtime.drain(group, null);
|
||||
}
|
||||
|
||||
function evictGroup(group: string): void {
|
||||
trackedGroups.delete(group);
|
||||
evicting.add(group);
|
||||
void runtime.evict(group).finally(() => {
|
||||
void runtime.evict(group, null).finally(() => {
|
||||
evicting.delete(group);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,28 @@ import { isPlainRecord } from "@uncaged/nerve-core";
|
||||
|
||||
const STDERR_TAIL_MAX_CHARS = 2048;
|
||||
|
||||
export function formatChildExitSummary(code: number | null, signal: NodeJS.Signals | null): string {
|
||||
const codeStr = code === null || code === undefined ? "null" : String(code);
|
||||
if (signal) {
|
||||
return `code=${codeStr} signal=${signal}`;
|
||||
}
|
||||
return `code=${codeStr}`;
|
||||
}
|
||||
|
||||
export function formatCapturedStderrTail(tail: string, maxChars = 800): string {
|
||||
const trimmed = tail.trim();
|
||||
if (trimmed.length === 0) return "";
|
||||
const normalized = trimmed.replace(/\r?\n/g, "\\n");
|
||||
if (normalized.length <= maxChars) {
|
||||
return ` worker_stderr=${normalized}`;
|
||||
}
|
||||
return ` worker_stderr=…${normalized.slice(-maxChars)}`;
|
||||
}
|
||||
|
||||
export type WorkerDrainOpts = {
|
||||
shutdownTimeoutMs: number | null;
|
||||
};
|
||||
|
||||
export type WorkerRuntimeConfig<K extends string> = {
|
||||
script: string;
|
||||
argsForKey: (key: K) => string[];
|
||||
@@ -16,6 +38,8 @@ export type WorkerRuntimeConfig<K extends string> = {
|
||||
onMessage: (key: K, msg: unknown) => void;
|
||||
onReady: (key: K, msg: unknown) => void;
|
||||
onExit: (key: K, code: number | null, signal: string | null) => void;
|
||||
/** Invoked when automatic respawn is skipped because `maxCrashes` was exceeded in `windowMs`. */
|
||||
onCrashLimitReached: ((key: K) => void) | null;
|
||||
respawn: {
|
||||
enabled: boolean;
|
||||
maxCrashes: number;
|
||||
@@ -32,8 +56,8 @@ export type WorkerRuntime<K extends string> = {
|
||||
/** When the worker is already ready and IPC-connected, sends synchronously (returns true). Otherwise false — caller may fall back to `send`. */
|
||||
trySendSync: (key: K, msg: unknown) => boolean;
|
||||
start: (key: K) => Promise<void>;
|
||||
evict: (key: K) => Promise<void>;
|
||||
drain: (key: K) => Promise<void>;
|
||||
evict: (key: K, opts: WorkerDrainOpts | null) => Promise<void>;
|
||||
drain: (key: K, opts: WorkerDrainOpts | null) => Promise<void>;
|
||||
shutdown: () => Promise<void>;
|
||||
has: (key: K) => boolean;
|
||||
/** True when a child exists but IPC is disconnected (legacy pool skipped sends in this case). */
|
||||
@@ -264,7 +288,14 @@ export function createWorkerRuntime<K extends string>(
|
||||
await waitForReady(slot, config.shutdownTimeoutMs);
|
||||
}
|
||||
|
||||
async function gracefulStop(slot: WorkerSlot<K>): Promise<void> {
|
||||
function resolveShutdownTimeoutMs(opts: WorkerDrainOpts | null): number {
|
||||
if (opts !== null && opts.shutdownTimeoutMs !== null) {
|
||||
return opts.shutdownTimeoutMs;
|
||||
}
|
||||
return config.shutdownTimeoutMs;
|
||||
}
|
||||
|
||||
async function gracefulStop(slot: WorkerSlot<K>, shutdownTimeoutMs: number): Promise<void> {
|
||||
if (slot.child === null) {
|
||||
return;
|
||||
}
|
||||
@@ -276,7 +307,7 @@ export function createWorkerRuntime<K extends string>(
|
||||
} catch {
|
||||
// IPC channel may have closed between null-check and send
|
||||
}
|
||||
await waitForChildExit(child, config.shutdownTimeoutMs);
|
||||
await waitForChildExit(child, shutdownTimeoutMs);
|
||||
}
|
||||
|
||||
async function handleUnexpectedCrashRecovery(slot: WorkerSlot<K>): Promise<void> {
|
||||
@@ -295,6 +326,9 @@ export function createWorkerRuntime<K extends string>(
|
||||
console.error(
|
||||
`[WorkerRuntime] worker "${String(slot.key)}" exceeded crash limit (${String(config.respawn.maxCrashes)} in ${String(config.respawn.windowMs)}ms); not respawning`,
|
||||
);
|
||||
if (config.onCrashLimitReached !== null) {
|
||||
config.onCrashLimitReached(slot.key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -303,7 +337,7 @@ export function createWorkerRuntime<K extends string>(
|
||||
}
|
||||
|
||||
async function shutdownWorker(slot: WorkerSlot<K>): Promise<void> {
|
||||
await gracefulStop(slot);
|
||||
await gracefulStop(slot, config.shutdownTimeoutMs);
|
||||
workers.delete(slot.key);
|
||||
}
|
||||
|
||||
@@ -348,22 +382,24 @@ export function createWorkerRuntime<K extends string>(
|
||||
});
|
||||
},
|
||||
|
||||
evict: async (key: K) => {
|
||||
evict: async (key: K, opts: WorkerDrainOpts | null) => {
|
||||
const slot = getOrCreateSlot(key);
|
||||
const shutdownMs = resolveShutdownTimeoutMs(opts);
|
||||
await enqueueOp(slot, async () => {
|
||||
await gracefulStop(slot);
|
||||
await gracefulStop(slot, shutdownMs);
|
||||
workers.delete(key);
|
||||
});
|
||||
},
|
||||
|
||||
drain: async (key: K) => {
|
||||
drain: async (key: K, opts: WorkerDrainOpts | null) => {
|
||||
const slot = getOrCreateSlot(key);
|
||||
const shutdownMs = resolveShutdownTimeoutMs(opts);
|
||||
await enqueueOp(slot, async () => {
|
||||
if (slot.child === null) {
|
||||
await forkAndWaitReady(slot);
|
||||
return;
|
||||
}
|
||||
await gracefulStop(slot);
|
||||
await gracefulStop(slot, shutdownMs);
|
||||
await forkAndWaitReady(slot);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Worker-process signal handling (fork IPC children only).
|
||||
* Worker entrypoints import this module — not worker-runtime.ts (parent/kernel code).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Forked workers inherit the parent's process group. In foreground `nerve dev`,
|
||||
* terminal-driven SIGINT/SIGTERM is delivered to the whole group, so workers can exit
|
||||
* on the default handler before the kernel sends `{ type: "shutdown" }` over IPC.
|
||||
* Swallow these in worker processes so the parent coordinates shutdown (issue #55).
|
||||
* Only call when `process.send` is defined (fork IPC); standalone `node …-worker.js` keeps default Ctrl+C behaviour.
|
||||
*/
|
||||
export function ignoreSessionBroadcastSignals(): void {
|
||||
const swallow = (): void => {};
|
||||
process.on("SIGINT", swallow);
|
||||
process.on("SIGTERM", swallow);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Pure helpers and IPC branching for workflow-manager (keeps workflow-manager.ts lean).
|
||||
*/
|
||||
|
||||
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 { WorkerToParentMessage } from "./ipc.js";
|
||||
|
||||
export type PendingThread = {
|
||||
runId: string;
|
||||
prompt: string;
|
||||
maxRounds: number;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowState = {
|
||||
active: Set<string>;
|
||||
queue: PendingThread[];
|
||||
};
|
||||
|
||||
/** Matches legacy manager: 6 crashes within 60s stops respawn (was `length > 5`). */
|
||||
export const WORKFLOW_WORKER_RESPAWN = {
|
||||
enabled: true,
|
||||
maxCrashes: 6,
|
||||
windowMs: 60_000,
|
||||
delayMs: 0,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const WORKER_SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||
|
||||
export const DEFAULT_MAX_QUEUE = 100;
|
||||
|
||||
export function readLaunchFromTriggerPayload(
|
||||
raw: unknown,
|
||||
engineDefaultMaxRounds: number,
|
||||
): { prompt: string; maxRounds: number; dryRun: boolean } {
|
||||
if (isPlainRecord(raw)) {
|
||||
const o = raw;
|
||||
if (typeof o.prompt === "string" && typeof o.maxRounds === "number") {
|
||||
const dryRun = typeof o.dryRun === "boolean" ? o.dryRun : false;
|
||||
return { prompt: o.prompt, maxRounds: o.maxRounds, dryRun };
|
||||
}
|
||||
}
|
||||
return { prompt: "", maxRounds: engineDefaultMaxRounds, dryRun: false };
|
||||
}
|
||||
|
||||
export function ensureThreadMessagesWithStart(
|
||||
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>,
|
||||
threadId: string,
|
||||
fallbackPrompt: string,
|
||||
fallbackMaxRounds: number,
|
||||
): WorkflowMessage[] {
|
||||
const mapped: WorkflowMessage[] = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
meta: m.meta,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
if (mapped.length > 0 && mapped[0].role === START) {
|
||||
return mapped;
|
||||
}
|
||||
const start: WorkflowMessage = {
|
||||
role: START,
|
||||
content: fallbackPrompt,
|
||||
meta: { maxRounds: fallbackMaxRounds, threadId },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
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",
|
||||
queued: "queued",
|
||||
completed: "completed",
|
||||
failed: "failed",
|
||||
crashed: "crashed",
|
||||
dropped: "dropped",
|
||||
interrupted: "interrupted",
|
||||
killed: "killed",
|
||||
};
|
||||
return map[eventType] ?? null;
|
||||
}
|
||||
|
||||
export function extractExitCodeFromPayload(payload: unknown): number | null {
|
||||
if (isPlainRecord(payload) && typeof payload.exitCode === "number") {
|
||||
return payload.exitCode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function appendWorkflowRunLog(
|
||||
logStore: LogStore,
|
||||
workflowName: string,
|
||||
runId: string,
|
||||
eventType: string,
|
||||
payload: unknown | undefined,
|
||||
exitCode: number | null,
|
||||
): void {
|
||||
const timestamp = Date.now();
|
||||
const serialised = payload !== undefined ? JSON.stringify(payload) : null;
|
||||
const status = mapWorkflowRunStatus(eventType);
|
||||
|
||||
if (status !== null) {
|
||||
logStore.upsertWorkflowRun(
|
||||
{
|
||||
source: "workflow",
|
||||
type: eventType,
|
||||
refId: runId,
|
||||
payload: serialised,
|
||||
timestamp,
|
||||
},
|
||||
{ runId, workflow: workflowName, status, timestamp, exitCode },
|
||||
);
|
||||
} else {
|
||||
logStore.append({
|
||||
source: "workflow",
|
||||
type: eventType,
|
||||
refId: runId,
|
||||
payload: serialised,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function recoverQueuedRun(
|
||||
workflowName: string,
|
||||
runId: string,
|
||||
state: WorkflowState,
|
||||
logStore: LogStore,
|
||||
engineMaxRounds: number,
|
||||
): void {
|
||||
if (state.queue.some((q) => q.runId === runId)) return;
|
||||
const launch = readLaunchFromTriggerPayload(logStore.getTriggerPayload(runId), engineMaxRounds);
|
||||
state.queue.push({
|
||||
runId,
|
||||
prompt: launch.prompt,
|
||||
maxRounds: launch.maxRounds,
|
||||
dryRun: launch.dryRun,
|
||||
});
|
||||
process.stderr.write(
|
||||
`[workflow-manager] crash-recovery: re-queued thread "${runId}" for "${workflowName}"\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export function recoverStartedRun(
|
||||
workflowName: string,
|
||||
runId: string,
|
||||
state: WorkflowState,
|
||||
logStore: LogStore,
|
||||
engineMaxRounds: number,
|
||||
sendResume: (wf: string, msg: ResumeThreadMessage) => void,
|
||||
): void {
|
||||
if (state.active.has(runId)) return;
|
||||
const rawMessages = logStore.getThreadMessages(runId);
|
||||
const launch = readLaunchFromTriggerPayload(logStore.getTriggerPayload(runId), engineMaxRounds);
|
||||
const messages = ensureThreadMessagesWithStart(
|
||||
rawMessages,
|
||||
runId,
|
||||
launch.prompt,
|
||||
launch.maxRounds,
|
||||
);
|
||||
state.active.add(runId);
|
||||
const msg: ResumeThreadMessage = {
|
||||
type: "resume-thread",
|
||||
runId,
|
||||
messages,
|
||||
maxRounds: launch.maxRounds,
|
||||
dryRun: launch.dryRun,
|
||||
};
|
||||
sendResume(workflowName, msg);
|
||||
process.stderr.write(
|
||||
`[workflow-manager] crash-recovery: resuming thread "${runId}" for "${workflowName}" (${String(messages.length)} messages)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export function recoverThreadsFromStore(
|
||||
workflowName: string,
|
||||
logStore: LogStore,
|
||||
engineMaxRounds: number,
|
||||
getOrCreateState: (name: string) => WorkflowState,
|
||||
sendResume: (wf: string, msg: ResumeThreadMessage) => void,
|
||||
): void {
|
||||
const activeRuns = logStore.getActiveWorkflowRuns(workflowName);
|
||||
const state = getOrCreateState(workflowName);
|
||||
|
||||
for (const run of activeRuns) {
|
||||
if (run.status === "queued") {
|
||||
recoverQueuedRun(workflowName, run.runId, state, logStore, engineMaxRounds);
|
||||
} else if (run.status === "started") {
|
||||
recoverStartedRun(workflowName, run.runId, state, logStore, engineMaxRounds, sendResume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkflowManagerMessageDeps = {
|
||||
logStore: LogStore;
|
||||
handleThreadEvent: (workflowName: string, msg: ThreadEventMessage) => void;
|
||||
onWorkflowRoleError: (
|
||||
workflowName: string,
|
||||
runId: string,
|
||||
error: string,
|
||||
exitCode: number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function dispatchWorkflowWorkerMessage(
|
||||
workflowName: string,
|
||||
msg: WorkerToParentMessage,
|
||||
deps: WorkflowManagerMessageDeps,
|
||||
): void {
|
||||
if (msg.type === "thread-event") {
|
||||
deps.handleThreadEvent(workflowName, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "thread-workflow-message") {
|
||||
deps.logStore.append({
|
||||
source: "workflow",
|
||||
type: "thread_workflow_message",
|
||||
refId: msg.runId,
|
||||
payload: JSON.stringify(msg.message),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "workflow-error") {
|
||||
process.stderr.write(
|
||||
`[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,33 +6,27 @@
|
||||
* Concurrency and overflow (drop/queue) are enforced here in the parent process.
|
||||
*/
|
||||
|
||||
import { fork } from "node:child_process";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { NerveConfig, WorkflowConfig, WorkflowStatus } from "@uncaged/nerve-core";
|
||||
|
||||
import type {
|
||||
NerveConfig,
|
||||
WorkflowConfig,
|
||||
WorkflowMessage,
|
||||
WorkflowStatus,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { START, isPlainRecord } from "@uncaged/nerve-core";
|
||||
|
||||
import type { LogStore, WorkflowRunStatus } from "@uncaged/nerve-store";
|
||||
import type {
|
||||
KillThreadMessage,
|
||||
ResumeThreadMessage,
|
||||
ShutdownMessage,
|
||||
StartThreadMessage,
|
||||
ThreadEventMessage,
|
||||
} from "./ipc.js";
|
||||
import type { LogStore } from "@uncaged/nerve-store";
|
||||
import type { KillThreadMessage, StartThreadMessage, ThreadEventMessage } from "./ipc.js";
|
||||
import { parseWorkerMessage } from "./ipc.js";
|
||||
import {
|
||||
createWorkerRuntime,
|
||||
formatCapturedStderrTail,
|
||||
formatChildExitSummary,
|
||||
teeCapturedStderr,
|
||||
} from "./worker-fork-support.js";
|
||||
} from "./worker-runtime.js";
|
||||
import {
|
||||
DEFAULT_MAX_QUEUE,
|
||||
WORKER_SHUTDOWN_TIMEOUT_MS,
|
||||
WORKFLOW_WORKER_RESPAWN,
|
||||
type WorkflowState,
|
||||
appendWorkflowRunLog,
|
||||
dispatchWorkflowWorkerMessage,
|
||||
extractExitCodeFromPayload,
|
||||
recoverThreadsFromStore,
|
||||
resolveWorkflowWorkerScript,
|
||||
} from "./workflow-manager-support.js";
|
||||
|
||||
export type WorkflowLaunchParams = {
|
||||
prompt: string;
|
||||
@@ -74,169 +68,109 @@ export type WorkflowManager = {
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type PendingThread = {
|
||||
runId: string;
|
||||
prompt: string;
|
||||
maxRounds: number;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
type WorkflowState = {
|
||||
active: Set<string>;
|
||||
queue: PendingThread[];
|
||||
};
|
||||
|
||||
type WorkerEntry = {
|
||||
workflowName: string;
|
||||
process: ChildProcess;
|
||||
stopping: boolean;
|
||||
/** When set, the worker is draining before a hot-reload respawn. */
|
||||
draining: boolean;
|
||||
stderrTail: { value: string };
|
||||
};
|
||||
|
||||
// Crash respawn backoff: track crash timestamps per workflow.
|
||||
const MAX_CRASHES_IN_WINDOW = 5;
|
||||
const CRASH_WINDOW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const WORKER_SHUTDOWN_TIMEOUT_MS = 10_000;
|
||||
|
||||
const DEFAULT_MAX_QUEUE = 100;
|
||||
|
||||
function readLaunchFromTriggerPayload(
|
||||
raw: unknown,
|
||||
engineDefaultMaxRounds: number,
|
||||
): { prompt: string; maxRounds: number; dryRun: boolean } {
|
||||
if (isPlainRecord(raw)) {
|
||||
const o = raw;
|
||||
if (typeof o.prompt === "string" && typeof o.maxRounds === "number") {
|
||||
const dryRun = typeof o.dryRun === "boolean" ? o.dryRun : false;
|
||||
return { prompt: o.prompt, maxRounds: o.maxRounds, dryRun };
|
||||
}
|
||||
}
|
||||
return { prompt: "", maxRounds: engineDefaultMaxRounds, dryRun: false };
|
||||
}
|
||||
|
||||
function ensureThreadMessagesWithStart(
|
||||
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>,
|
||||
threadId: string,
|
||||
fallbackPrompt: string,
|
||||
fallbackMaxRounds: number,
|
||||
): WorkflowMessage[] {
|
||||
const mapped: WorkflowMessage[] = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
meta: m.meta,
|
||||
timestamp: m.timestamp,
|
||||
}));
|
||||
if (mapped.length > 0 && mapped[0].role === START) {
|
||||
return mapped;
|
||||
}
|
||||
const start: WorkflowMessage = {
|
||||
role: START,
|
||||
content: fallbackPrompt,
|
||||
meta: { maxRounds: fallbackMaxRounds, threadId },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
return [start, ...mapped];
|
||||
}
|
||||
|
||||
function resolveWorkerScript(): string {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dir = dirname(__filename);
|
||||
return join(__dir, "workflow-worker.js");
|
||||
}
|
||||
|
||||
function spawnWorkflowWorker(
|
||||
nerveRoot: string,
|
||||
workflowName: string,
|
||||
workerScript: string,
|
||||
stderrTail: { value: string },
|
||||
): ChildProcess {
|
||||
const child = fork(workerScript, ["--workflow", workflowName, "--root", nerveRoot], {
|
||||
stdio: ["ignore", "inherit", "pipe", "ipc"],
|
||||
});
|
||||
teeCapturedStderr(child, stderrTail);
|
||||
// Prevent unhandled EPIPE when writing to a child whose IPC channel closed
|
||||
child.on("error", (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code !== "EPIPE") {
|
||||
console.error("[worker] error:", err.message);
|
||||
}
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
function sendStartThread(worker: ChildProcess, msg: StartThreadMessage): void {
|
||||
if (worker.connected === false) return;
|
||||
try {
|
||||
worker.send(msg);
|
||||
} catch {
|
||||
// IPC channel closed between connected check and send
|
||||
}
|
||||
}
|
||||
|
||||
function sendShutdown(worker: ChildProcess, entry: WorkerEntry): void {
|
||||
entry.stopping = true;
|
||||
if (worker.connected === false) return;
|
||||
const msg: ShutdownMessage = { type: "shutdown" };
|
||||
try {
|
||||
worker.send(msg);
|
||||
} catch {
|
||||
// IPC channel closed between connected check and send
|
||||
}
|
||||
}
|
||||
|
||||
function sendResumeThread(worker: ChildProcess, msg: ResumeThreadMessage): void {
|
||||
if (worker.connected === false) return;
|
||||
try {
|
||||
worker.send(msg);
|
||||
} catch {
|
||||
// IPC channel closed between connected check and send
|
||||
}
|
||||
}
|
||||
|
||||
function sendKillThread(worker: ChildProcess, runId: string): void {
|
||||
if (worker.connected === false) return;
|
||||
const msg: KillThreadMessage = { type: "kill-thread", runId };
|
||||
try {
|
||||
worker.send(msg);
|
||||
} catch {
|
||||
// IPC channel closed between connected check and send
|
||||
}
|
||||
}
|
||||
|
||||
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createWorkflowManager(
|
||||
nerveRoot: string,
|
||||
initialConfig: NerveConfig,
|
||||
logStore: LogStore,
|
||||
): WorkflowManager {
|
||||
const workerScript = resolveWorkerScript();
|
||||
const workerScript = resolveWorkflowWorkerScript();
|
||||
|
||||
/**
|
||||
* Default drain timeout must be at least WORKER_SHUTDOWN_TIMEOUT_MS so the worker
|
||||
* has enough time to finish in-flight threads before the parent force-kills it.
|
||||
*/
|
||||
const DEFAULT_DRAIN_TIMEOUT_MS = Math.max(30_000, WORKER_SHUTDOWN_TIMEOUT_MS + 5_000);
|
||||
|
||||
const states = new Map<string, WorkflowState>();
|
||||
const workers = new Map<string, WorkerEntry>();
|
||||
const crashTimestamps = new Map<string, number[]>();
|
||||
const trackedWorkflows = new Set<string>();
|
||||
const hotReloadEvicting = new Set<string>();
|
||||
const crashRecoveryPending = new Set<string>();
|
||||
const crashLimitBlocked = new Set<string>();
|
||||
let stopped = false;
|
||||
let config = initialConfig;
|
||||
const pendingDrains = new Set<string>();
|
||||
|
||||
function logWorkflowEvent(
|
||||
workflowName: string,
|
||||
runId: string,
|
||||
eventType: string,
|
||||
payload: unknown | null = null,
|
||||
exitCode: number | null = null,
|
||||
): void {
|
||||
appendWorkflowRunLog(logStore, workflowName, runId, eventType, payload, exitCode);
|
||||
}
|
||||
|
||||
const runtime = createWorkerRuntime<string>({
|
||||
script: workerScript,
|
||||
argsForKey: (workflowName) => ["--workflow", workflowName, "--root", nerveRoot],
|
||||
forwardStderr: true,
|
||||
onMessage: (workflowName, raw) => {
|
||||
handleWorkerMessage(workflowName, raw);
|
||||
},
|
||||
onReady: (workflowName, _msg) => {
|
||||
if (crashRecoveryPending.has(workflowName)) {
|
||||
crashRecoveryPending.delete(workflowName);
|
||||
recoverThreadsFromStore(
|
||||
workflowName,
|
||||
logStore,
|
||||
config.maxRounds,
|
||||
getOrCreateState,
|
||||
(wf, msg) => {
|
||||
sendToWorker(wf, msg);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
onExit: (workflowName, code, signalStr) => {
|
||||
const sig =
|
||||
signalStr === null || signalStr === undefined || signalStr === ""
|
||||
? null
|
||||
: (signalStr as NodeJS.Signals);
|
||||
|
||||
if (hotReloadEvicting.has(workflowName)) {
|
||||
hotReloadEvicting.delete(workflowName);
|
||||
markActiveRunsInterrupted(workflowName);
|
||||
if (!stopped && workflowConfig(workflowName) !== null) {
|
||||
process.stderr.write(
|
||||
`[workflow-manager] worker for "${workflowName}" drained, respawning\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopped) {
|
||||
const state = states.get(workflowName);
|
||||
if (state !== undefined) {
|
||||
state.active.clear();
|
||||
}
|
||||
crashRecoveryPending.delete(workflowName);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = formatChildExitSummary(code, sig);
|
||||
const stderrExtra = formatCapturedStderrTail(runtime.stderrTail(workflowName));
|
||||
process.stderr.write(
|
||||
`[workflow-manager] worker for "${workflowName}" exited (${summary})${stderrExtra}\n`,
|
||||
);
|
||||
|
||||
cleanupAfterUnexpectedWorkerExit(workflowName);
|
||||
crashRecoveryPending.add(workflowName);
|
||||
},
|
||||
onCrashLimitReached: (workflowName) => {
|
||||
crashRecoveryPending.delete(workflowName);
|
||||
trackedWorkflows.delete(workflowName);
|
||||
crashLimitBlocked.add(workflowName);
|
||||
process.stderr.write(
|
||||
`[workflow-manager] worker for "${workflowName}" exceeded crash limit (${String(WORKFLOW_WORKER_RESPAWN.maxCrashes)} in ${String(WORKFLOW_WORKER_RESPAWN.windowMs)}ms) — stopping respawn\n`,
|
||||
);
|
||||
},
|
||||
respawn: {
|
||||
...WORKFLOW_WORKER_RESPAWN,
|
||||
allowRespawn: (_wf) => !stopped,
|
||||
},
|
||||
shutdownTimeoutMs: DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
function getOrCreateState(workflowName: string): WorkflowState {
|
||||
let state = states.get(workflowName);
|
||||
if (state === undefined) {
|
||||
@@ -250,60 +184,38 @@ export function createWorkflowManager(
|
||||
return config.workflows[workflowName] ?? null;
|
||||
}
|
||||
|
||||
function toWorkflowRunStatus(eventType: string): WorkflowRunStatus | null {
|
||||
const map: Record<string, WorkflowRunStatus> = {
|
||||
started: "started",
|
||||
queued: "queued",
|
||||
completed: "completed",
|
||||
failed: "failed",
|
||||
crashed: "crashed",
|
||||
dropped: "dropped",
|
||||
interrupted: "interrupted",
|
||||
killed: "killed",
|
||||
};
|
||||
return map[eventType] ?? null;
|
||||
}
|
||||
|
||||
function extractExitCode(payload: unknown): number | null {
|
||||
if (isPlainRecord(payload) && typeof payload.exitCode === "number") {
|
||||
return payload.exitCode;
|
||||
/** IPC send — matches legacy pool: no-op when IPC is disconnected; cold-start via WorkerRuntime.send. */
|
||||
function sendToWorker(workflowName: string, msg: unknown): void {
|
||||
if (crashLimitBlocked.has(workflowName)) {
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function logWorkflowEvent(
|
||||
workflowName: string,
|
||||
runId: string,
|
||||
eventType: string,
|
||||
payload?: unknown,
|
||||
exitCode: number | null = null,
|
||||
): void {
|
||||
const timestamp = Date.now();
|
||||
const serialised = payload !== undefined ? JSON.stringify(payload) : null;
|
||||
const status = toWorkflowRunStatus(eventType);
|
||||
|
||||
if (status !== null) {
|
||||
logStore.upsertWorkflowRun(
|
||||
{
|
||||
source: "workflow",
|
||||
type: eventType,
|
||||
refId: runId,
|
||||
payload: serialised,
|
||||
timestamp,
|
||||
},
|
||||
{ runId, workflow: workflowName, status, timestamp, exitCode },
|
||||
);
|
||||
} else {
|
||||
logStore.append({
|
||||
source: "workflow",
|
||||
type: eventType,
|
||||
refId: runId,
|
||||
payload: serialised,
|
||||
timestamp,
|
||||
trackedWorkflows.add(workflowName);
|
||||
if (runtime.hasDisconnectedChild(workflowName)) {
|
||||
return;
|
||||
}
|
||||
if (!runtime.trySendSync(workflowName, msg)) {
|
||||
void runtime.send(workflowName, msg).catch(() => {
|
||||
// IPC channel closed — mark any thread from this message as failed
|
||||
if (isStartThreadMsg(msg)) {
|
||||
const state = states.get(workflowName);
|
||||
if (state?.active.has(msg.runId)) {
|
||||
state.active.delete(msg.runId);
|
||||
logWorkflowEvent(workflowName, msg.runId, "failed", { error: "IPC channel closed" }, 1);
|
||||
dequeueNext(workflowName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isStartThreadMsg(msg: unknown): msg is StartThreadMessage {
|
||||
return (
|
||||
msg !== null &&
|
||||
typeof msg === "object" &&
|
||||
(msg as Record<string, unknown>).type === "start-thread"
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchThread(
|
||||
workflowName: string,
|
||||
runId: string,
|
||||
@@ -314,7 +226,6 @@ export function createWorkflowManager(
|
||||
const state = getOrCreateState(workflowName);
|
||||
state.active.add(runId);
|
||||
|
||||
const worker = getOrSpawnWorker(workflowName);
|
||||
const msg: StartThreadMessage = {
|
||||
type: "start-thread",
|
||||
runId,
|
||||
@@ -323,7 +234,7 @@ export function createWorkflowManager(
|
||||
maxRounds,
|
||||
dryRun,
|
||||
};
|
||||
sendStartThread(worker.process, msg);
|
||||
sendToWorker(workflowName, msg);
|
||||
logWorkflowEvent(workflowName, runId, "started", { prompt, maxRounds, dryRun });
|
||||
}
|
||||
|
||||
@@ -367,92 +278,20 @@ export function createWorkflowManager(
|
||||
if (msg.eventType === "completed" || msg.eventType === "failed" || msg.eventType === "killed") {
|
||||
state.active.delete(msg.runId);
|
||||
dequeueNext(workflowName);
|
||||
const exitCode = extractExitCode(msg.payload);
|
||||
const exitCode = extractExitCodeFromPayload(msg.payload);
|
||||
logWorkflowEvent(workflowName, msg.runId, msg.eventType, msg.payload, exitCode);
|
||||
maybeDeferredHotReloadDrain(workflowName);
|
||||
}
|
||||
}
|
||||
|
||||
function recoverQueuedRun(workflowName: string, runId: string, state: WorkflowState): void {
|
||||
if (state.queue.some((q) => q.runId === runId)) return;
|
||||
const launch = readLaunchFromTriggerPayload(
|
||||
logStore.getTriggerPayload(runId),
|
||||
config.maxRounds,
|
||||
);
|
||||
state.queue.push({
|
||||
runId,
|
||||
prompt: launch.prompt,
|
||||
maxRounds: launch.maxRounds,
|
||||
dryRun: launch.dryRun,
|
||||
});
|
||||
process.stderr.write(
|
||||
`[workflow-manager] crash-recovery: re-queued thread "${runId}" for "${workflowName}"\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function recoverStartedRun(
|
||||
workflowName: string,
|
||||
runId: string,
|
||||
state: WorkflowState,
|
||||
worker: WorkerEntry,
|
||||
): void {
|
||||
if (state.active.has(runId)) return;
|
||||
const rawMessages = logStore.getThreadMessages(runId);
|
||||
const launch = readLaunchFromTriggerPayload(
|
||||
logStore.getTriggerPayload(runId),
|
||||
config.maxRounds,
|
||||
);
|
||||
const messages = ensureThreadMessagesWithStart(
|
||||
rawMessages,
|
||||
runId,
|
||||
launch.prompt,
|
||||
launch.maxRounds,
|
||||
);
|
||||
state.active.add(runId);
|
||||
const msg: ResumeThreadMessage = {
|
||||
type: "resume-thread",
|
||||
runId,
|
||||
messages,
|
||||
maxRounds: launch.maxRounds,
|
||||
dryRun: launch.dryRun,
|
||||
};
|
||||
sendResumeThread(worker.process, msg);
|
||||
process.stderr.write(
|
||||
`[workflow-manager] crash-recovery: resuming thread "${runId}" for "${workflowName}" (${messages.length} messages)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function recoverThreadsForWorker(workflowName: string, worker: WorkerEntry): void {
|
||||
const activeRuns = logStore.getActiveWorkflowRuns(workflowName);
|
||||
const state = getOrCreateState(workflowName);
|
||||
|
||||
for (const run of activeRuns) {
|
||||
if (run.status === "queued") {
|
||||
recoverQueuedRun(workflowName, run.runId, state);
|
||||
} else if (run.status === "started") {
|
||||
recoverStartedRun(workflowName, run.runId, state, worker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recordCrashAndCheckLimit(workflowName: string): boolean {
|
||||
const now = Date.now();
|
||||
const timestamps = (crashTimestamps.get(workflowName) ?? []).filter(
|
||||
(t) => now - t < CRASH_WINDOW_MS,
|
||||
);
|
||||
timestamps.push(now);
|
||||
crashTimestamps.set(workflowName, timestamps);
|
||||
return timestamps.length > MAX_CRASHES_IN_WINDOW;
|
||||
}
|
||||
|
||||
function handleWorkerCrash(workflowName: string): void {
|
||||
function cleanupAfterUnexpectedWorkerExit(workflowName: string): void {
|
||||
const state = states.get(workflowName);
|
||||
if (state === undefined) return;
|
||||
|
||||
const crashedCount = state.active.size;
|
||||
if (crashedCount > 0) {
|
||||
process.stderr.write(
|
||||
`[workflow-manager] worker for "${workflowName}" crashed with ${crashedCount} active thread(s)\n`,
|
||||
`[workflow-manager] worker for "${workflowName}" crashed with ${String(crashedCount)} active thread(s)\n`,
|
||||
);
|
||||
for (const runId of state.active) {
|
||||
logWorkflowEvent(workflowName, runId, "crashed", undefined, 255);
|
||||
@@ -460,26 +299,13 @@ export function createWorkflowManager(
|
||||
}
|
||||
|
||||
state.active.clear();
|
||||
workers.delete(workflowName);
|
||||
pendingDrains.delete(workflowName);
|
||||
|
||||
if (stopped || workflowConfig(workflowName) === null) return;
|
||||
|
||||
if (recordCrashAndCheckLimit(workflowName)) {
|
||||
const count = crashTimestamps.get(workflowName)?.length ?? 0;
|
||||
if (!stopped && !crashLimitBlocked.has(workflowName) && workflowConfig(workflowName) !== null) {
|
||||
process.stderr.write(
|
||||
`[workflow-manager] worker for "${workflowName}" crashed ${count} times in ${CRASH_WINDOW_MS}ms — stopping respawn\n`,
|
||||
`[workflow-manager] respawning worker for "${workflowName}" after crash\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
`[workflow-manager] respawning worker for "${workflowName}" after crash\n`,
|
||||
);
|
||||
const newWorker = getOrSpawnWorker(workflowName);
|
||||
setImmediate(() => {
|
||||
recoverThreadsForWorker(workflowName, newWorker);
|
||||
});
|
||||
}
|
||||
|
||||
function handleWorkerMessage(workflowName: string, raw: unknown): void {
|
||||
@@ -490,43 +316,19 @@ export function createWorkflowManager(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const msg = result.value;
|
||||
|
||||
if (msg.type === "thread-event") {
|
||||
handleThreadEvent(workflowName, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "thread-workflow-message") {
|
||||
logStore.append({
|
||||
source: "workflow",
|
||||
type: "thread_workflow_message",
|
||||
refId: msg.runId,
|
||||
payload: JSON.stringify(msg.message),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "workflow-error") {
|
||||
process.stderr.write(
|
||||
`[workflow-manager] workflow-error for runId "${msg.runId}" in "${workflowName}": ${msg.error}\n`,
|
||||
);
|
||||
const state = states.get(workflowName);
|
||||
if (state !== undefined) {
|
||||
state.active.delete(msg.runId);
|
||||
dequeueNext(workflowName);
|
||||
}
|
||||
logWorkflowEvent(workflowName, msg.runId, "failed", { error: msg.error }, msg.exitCode);
|
||||
maybeDeferredHotReloadDrain(workflowName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
process.stderr.write(
|
||||
`[workflow-manager] error from "${workflowName}" worker: ${msg.error}\n`,
|
||||
);
|
||||
}
|
||||
dispatchWorkflowWorkerMessage(workflowName, result.value, {
|
||||
logStore,
|
||||
handleThreadEvent,
|
||||
onWorkflowRoleError: (wf, runId, error, exitCode) => {
|
||||
const state = states.get(wf);
|
||||
if (state !== undefined) {
|
||||
state.active.delete(runId);
|
||||
dequeueNext(wf);
|
||||
}
|
||||
logWorkflowEvent(wf, runId, "failed", { error }, exitCode);
|
||||
maybeDeferredHotReloadDrain(wf);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function markActiveRunsInterrupted(workflowName: string): void {
|
||||
@@ -538,67 +340,6 @@ export function createWorkflowManager(
|
||||
state.active.clear();
|
||||
}
|
||||
|
||||
function handleWorkerExit(
|
||||
workflowName: string,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): void {
|
||||
const entry = workers.get(workflowName);
|
||||
if (entry?.draining) {
|
||||
workers.delete(workflowName);
|
||||
markActiveRunsInterrupted(workflowName);
|
||||
if (!stopped && workflowConfig(workflowName) !== null) {
|
||||
process.stderr.write(
|
||||
`[workflow-manager] worker for "${workflowName}" drained, respawning\n`,
|
||||
);
|
||||
getOrSpawnWorker(workflowName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (entry?.stopping) {
|
||||
workers.delete(workflowName);
|
||||
const state = states.get(workflowName);
|
||||
if (state !== undefined) {
|
||||
state.active.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const summary = formatChildExitSummary(code, signal);
|
||||
const stderrExtra = entry !== undefined ? formatCapturedStderrTail(entry.stderrTail.value) : "";
|
||||
process.stderr.write(
|
||||
`[workflow-manager] worker for "${workflowName}" exited (${summary})${stderrExtra}\n`,
|
||||
);
|
||||
handleWorkerCrash(workflowName);
|
||||
}
|
||||
|
||||
function getOrSpawnWorker(workflowName: string): WorkerEntry {
|
||||
const existing = workers.get(workflowName);
|
||||
if (existing !== undefined && existing.process.exitCode === null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const stderrTail = { value: "" };
|
||||
const child = spawnWorkflowWorker(nerveRoot, workflowName, workerScript, stderrTail);
|
||||
|
||||
child.on("message", (raw: unknown) => {
|
||||
handleWorkerMessage(workflowName, raw);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
handleWorkerExit(workflowName, code, signal ?? null);
|
||||
});
|
||||
|
||||
const entry: WorkerEntry = {
|
||||
workflowName,
|
||||
process: child,
|
||||
stopping: false,
|
||||
draining: false,
|
||||
stderrTail,
|
||||
};
|
||||
workers.set(workflowName, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function killThread(runId: string): boolean {
|
||||
for (const [workflowName, state] of states) {
|
||||
const queueIdx = state.queue.findIndex((q) => q.runId === runId);
|
||||
@@ -609,10 +350,8 @@ export function createWorkflowManager(
|
||||
}
|
||||
|
||||
if (state.active.has(runId)) {
|
||||
const workerEntry = workers.get(workflowName);
|
||||
if (workerEntry !== undefined) {
|
||||
sendKillThread(workerEntry.process, runId);
|
||||
}
|
||||
const msg: KillThreadMessage = { type: "kill-thread", runId };
|
||||
sendToWorker(workflowName, msg);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -663,7 +402,7 @@ export function createWorkflowManager(
|
||||
state.queue.push({ runId, prompt, maxRounds, dryRun });
|
||||
logWorkflowEvent(workflowName, runId, "queued");
|
||||
process.stderr.write(
|
||||
`[workflow-manager] queued thread for "${workflowName}" runId "${runId}" (queue length: ${state.queue.length})\n`,
|
||||
`[workflow-manager] queued thread for "${workflowName}" runId "${runId}" (queue length: ${String(state.queue.length)})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -707,35 +446,29 @@ export function createWorkflowManager(
|
||||
config = newConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default drain timeout must be at least WORKER_SHUTDOWN_TIMEOUT_MS so the worker
|
||||
* has enough time to finish in-flight threads before the parent force-kills it.
|
||||
*/
|
||||
const DEFAULT_DRAIN_TIMEOUT_MS = Math.max(30_000, WORKER_SHUTDOWN_TIMEOUT_MS + 5_000);
|
||||
|
||||
async function drainAndRespawn(
|
||||
workflowName: string,
|
||||
drainTimeoutMs: number = DEFAULT_DRAIN_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const entry = workers.get(workflowName);
|
||||
if (entry === undefined) {
|
||||
// No active worker — nothing to drain
|
||||
if (!trackedWorkflows.has(workflowName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.draining = true;
|
||||
// Send shutdown without setting stopping=true (so the exit handler uses the draining branch)
|
||||
if (entry.process.connected) {
|
||||
const msg: ShutdownMessage = { type: "shutdown" };
|
||||
try {
|
||||
entry.process.send(msg);
|
||||
} catch {
|
||||
// IPC closed
|
||||
const shutdownMs = Math.max(drainTimeoutMs, WORKER_SHUTDOWN_TIMEOUT_MS);
|
||||
hotReloadEvicting.add(workflowName);
|
||||
try {
|
||||
await runtime.evict(workflowName, { shutdownTimeoutMs: shutdownMs });
|
||||
trackedWorkflows.delete(workflowName);
|
||||
|
||||
if (!stopped && workflowConfig(workflowName) !== null) {
|
||||
trackedWorkflows.add(workflowName);
|
||||
await runtime.start(workflowName);
|
||||
}
|
||||
} finally {
|
||||
hotReloadEvicting.delete(workflowName);
|
||||
}
|
||||
await waitForExit(entry.process, drainTimeoutMs);
|
||||
// The exit handler (draining branch) will respawn the worker automatically
|
||||
}
|
||||
|
||||
function drainWhenIdle(workflowName: string): void {
|
||||
const state = states.get(workflowName);
|
||||
const hasActiveRuns = state !== undefined && state.active.size > 0;
|
||||
@@ -761,20 +494,17 @@ export function createWorkflowManager(
|
||||
|
||||
pendingDrains.add(workflowName);
|
||||
process.stderr.write(
|
||||
`[workflow-manager] deferring hot-reload for "${workflowName}" until ${state.active.size} active run(s) complete\n`,
|
||||
`[workflow-manager] deferring hot-reload for "${workflowName}" until ${String(state.active.size)} active run(s) complete\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
stopped = true;
|
||||
pendingDrains.clear();
|
||||
const exitPromises: Promise<void>[] = [];
|
||||
for (const entry of workers.values()) {
|
||||
sendShutdown(entry.process, entry);
|
||||
exitPromises.push(waitForExit(entry.process, 5000));
|
||||
}
|
||||
await Promise.all(exitPromises);
|
||||
workers.clear();
|
||||
hotReloadEvicting.clear();
|
||||
crashRecoveryPending.clear();
|
||||
await runtime.shutdown();
|
||||
trackedWorkflows.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
WorkerToParentMessage,
|
||||
} from "./ipc.js";
|
||||
import { parseParentMessage } from "./ipc.js";
|
||||
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
|
||||
import { ignoreSessionBroadcastSignals } from "./worker-signals.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC helpers
|
||||
|
||||
@@ -17,6 +17,8 @@ Also look at existing senses in the \`senses/\` directory for patterns.
|
||||
|
||||
Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown:
|
||||
|
||||
**Naming:** Sense names describe **what is observed or derived** (often noun-style compounds, e.g. \`linux-system-health\`, \`git-workspace-status\`). That differs from **workflow** names, which must be **verb-first** action phrases (\`extract-knowledge\`, \`develop-sense\`).
|
||||
|
||||
## Sense Design
|
||||
### Name — kebab-case
|
||||
### Fields — name, type (integer/real/text), description
|
||||
|
||||
@@ -51,4 +51,28 @@ describe("createLlmAdapter", () => {
|
||||
{ role: "user", content: "trigger text" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws on non-ok fetch response", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "Internal Server Error",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
|
||||
await expect(adapter(makeCtx("t1", "hi"), "sys")).rejects.toThrow("llm:");
|
||||
});
|
||||
|
||||
it("throws on fetch network failure", async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
|
||||
await expect(adapter(makeCtx("t1", "hi"), "sys")).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,13 +26,11 @@ export {
|
||||
} from "./role-decorators.js";
|
||||
export {
|
||||
nerveCommandEnv,
|
||||
spawnSafe,
|
||||
type SpawnEnv,
|
||||
type SpawnError,
|
||||
type SpawnResult,
|
||||
type SpawnSafeOptions,
|
||||
} from "@uncaged/nerve-core";
|
||||
export type { LlmError, LlmProvider } from "./shared/llm-extract.js";
|
||||
export { isDryRun } from "./role-types.js";
|
||||
export type { LlmMessage, MetaExtractConfig } from "./role-types.js";
|
||||
export type { LlmChatError } from "./shared/llm-chat.js";
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import type { SpawnEnv, StartStep, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { SpawnEnv, ThreadContext } from "@uncaged/nerve-core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { LlmProvider } from "./shared/llm-extract.js";
|
||||
|
||||
/**
|
||||
* @deprecated `dryRun` has been removed from `StartStep.meta` (RFC-005).
|
||||
* Use adapter/role-level `dryRun` config instead.
|
||||
*/
|
||||
export function isDryRun(_start: StartStep): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export type CliPromptFn = (ctx: ThreadContext) => Promise<string>;
|
||||
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
|
||||
@@ -41,7 +41,7 @@ export function readNerveYaml(options: ReadNerveYamlOptions): Result<string, Ner
|
||||
* Shared context for workflow agents: how Nerve fits together and common CLI verbs.
|
||||
*/
|
||||
export const nerveAgentContext = `
|
||||
Nerve observes the world through **Senses** (each has its own SQLite DB and a \`compute()\` function).
|
||||
**Reflexes** (YAML) schedule sense runs or start **Workflows** on intervals or signals.
|
||||
The \`nerve\` CLI manages config, triggers, and queries; keep paths and commands aligned with the host nerve.yaml and senses directory.
|
||||
Nerve observes the world through **Senses**: each exports \`compute(state)\`, \`initialState\`, and persists JSON state under \`data/senses/\`.
|
||||
Per-sense \`interval\` and \`on\` in \`nerve.yaml\` schedule computes; a non-null \`workflow\` in the return starts a **Workflow**.
|
||||
The \`nerve\` CLI manages config and triggers; keep paths aligned with the workspace \`nerve.yaml\` and \`senses/\` directory.
|
||||
`.trim();
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { HermesRoleDefaults, HermesRoleRequired } from "../role-types.js";
|
||||
export { hermesAgent } from "@uncaged/nerve-adapter-hermes";
|
||||
export type { HermesAgentOptions } from "@uncaged/nerve-adapter-hermes";
|
||||
|
||||
// --- Hermes options resolution (absorbed from hermes-options.ts) ---
|
||||
// --- Hermes role defaults resolution ---
|
||||
|
||||
const HERMES_DEFAULTS: HermesRoleDefaults = {
|
||||
model: null,
|
||||
|
||||
Generated
-1144
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user