Merge pull request 'refactor: Stateful Sense (RFC #308)' (#312) from refactor/308-stateful-sense into main

This commit was merged in pull request #312.
This commit is contained in:
2026-05-01 10:20:46 +00:00
75 changed files with 813 additions and 3737 deletions
+18 -20
View File
@@ -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` |
+18 -20
View File
@@ -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` |
+1 -1
View File
@@ -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
+18 -78
View File
@@ -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 }
```
## Workflow Directive Parsing
## Concrete Routing Predicates
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
- `name`: non-empty string
- `maxRounds`: 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.
Invalid triggers are rejected by the daemon when handling the worker message (workflow is not started).
### 3. Fallback to Shorthand
Any value that doesn't match explicit format becomes:
```typescript
{ signal: payload, workflow: null }
```
## Scheduling
## 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).
+25 -18
View File
@@ -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,18 +14,17 @@ 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.
@@ -37,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
@@ -92,12 +91,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 +139,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` |
+54 -45
View File
@@ -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
View File
@@ -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` |
+2 -2
View File
@@ -117,7 +117,7 @@
| 项目 | 位置 | 说明 | 置信度 | 建议 |
|------|------|------|--------|------|
| 已更名 API 仍出现在 README | `packages/core/README.md` | 仍描述 `parseSenseWorkflowDirective``ParsedSenseWorkflowDirective``SenseComputeRoute`;源码已为 `parseWorkflowTrigger` / `routeSenseComputeOutput` / `RoutedSenseOutput` | **高** | **更新文档**(本次分析不改代码,仅记录)。 |
| ~~已更名 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` 适配器包;与常量未被引用叠加 | **中** | **对齐产品**:实现适配器或从列表移除。 |
@@ -130,7 +130,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 名称。
4. ~~**文档**: 修正 `packages/core/README.md` 中 Sense→Workflow 路由 API 名称。~~(已完成)
---
+11 -16
View File
@@ -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
);
-11
View File
@@ -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
View File
@@ -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:
+17 -4
View File
@@ -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> {
+1
View File
@@ -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"
+2 -2
View File
@@ -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
@@ -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);
},
);
+18 -90
View File
@@ -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)");
},
);
});
+10 -26
View File
@@ -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");
+1 -17
View File
@@ -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
+1 -1
View File
@@ -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");
});
});
+2 -28
View File
@@ -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);
});
});
+10 -65
View File
@@ -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 {
+14 -56
View File
@@ -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) {
+2 -132
View File
@@ -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,
},
});
-1
View File
@@ -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");
},
});
+1 -1
View File
@@ -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";
-170
View File
@@ -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`;
}
+48
View File
@@ -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`;
}
+8 -21
View File
@@ -4,9 +4,9 @@ Shared types and configuration parser for the [nerve](../../README.md) observati
## What's Inside
- **Type definitions** — `Signal`, `SenseConfig`, `SenseInfo`, `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** — `parseWorkflowTrigger`, `routeSenseComputeOutput`, and types `WorkflowTrigger`, `RoutedSenseOutput`
- **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`, `RoleResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS`
- **Result type** — `Result<T>` with `ok()` / `err()` helpers for explicit error handling (no thrown exceptions for parse paths)
@@ -15,7 +15,7 @@ Shared types and configuration parser for the [nerve](../../README.md) observati
```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,10 +23,10 @@ if (result.ok) {
}
```
### Sense return → signal vs workflow
### Workflow trigger validation
```typescript
import { parseWorkflowTrigger, routeSenseComputeOutput } from "@uncaged/nerve-core";
import { parseWorkflowTrigger } from "@uncaged/nerve-core";
const directive = parseWorkflowTrigger({
name: "my-workflow",
@@ -37,23 +37,10 @@ const directive = parseWorkflowTrigger({
if (directive.ok) {
console.log(directive.value.name, directive.value.maxRounds, directive.value.prompt);
}
const route = routeSenseComputeOutput({
signal: { metric: 42 },
workflow: {
name: "my-workflow",
maxRounds: 8,
prompt: "Run now",
dryRun: false,
},
});
if (route.ok && route.value.workflow !== null) {
console.log(route.value.workflow);
} else if (route.ok) {
console.log(route.value.signal);
}
```
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:
-1
View File
@@ -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();
});
});
+1 -26
View File
@@ -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,
});
+1 -2
View File
@@ -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")
);
}
+2 -5
View File
@@ -1,4 +1,3 @@
export { DEFAULT_SENSE_SIGNAL_RETENTION } from "./config.js";
export type {
SenseConfig,
DropOverflowConfig,
@@ -9,9 +8,8 @@ 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 { senseTriggerLabels } from "./sense.js";
export type {
@@ -46,8 +44,7 @@ export type { KnowledgeConfig } from "./config.js";
export { parseKnowledgeYaml } from "./config.js";
export { isPlainRecord } from "./util.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 {
+8 -45
View File
@@ -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;
@@ -18,7 +9,6 @@ export type SenseInfo = {
timeout: number | null;
/** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */
triggers: string[];
lastSignalTimestamp: number | null;
};
/**
@@ -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 });
}
+9 -10
View File
@@ -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
-1
View File
@@ -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": {
@@ -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);
}
});
@@ -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,22 +177,17 @@ 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,
},
});
}
@@ -221,7 +219,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -236,20 +233,17 @@ 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,
},
});
}
@@ -280,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" } },
@@ -296,16 +290,14 @@ 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,
},
});
}
@@ -316,17 +308,17 @@ describe("kernel + workflowManager integration", () => {
.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: {
@@ -335,7 +327,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -344,7 +335,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -359,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,
});
}
@@ -396,7 +386,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -411,20 +400,17 @@ 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,
},
});
}
@@ -452,7 +438,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -476,7 +461,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -488,20 +472,17 @@ 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,
},
});
}
@@ -534,7 +515,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -557,7 +537,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -574,20 +553,17 @@ 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,
},
});
}
@@ -621,7 +597,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -636,20 +611,17 @@ 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,
},
});
}
@@ -689,7 +661,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
+26 -17
View File
@@ -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 () => {
+1 -4
View File
@@ -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');
+2 -11
View File
@@ -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";
+31 -14
View File
@@ -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);
+18 -62
View File
@@ -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,
+26 -172
View File
@@ -1,172 +1,38 @@
import { mkdirSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
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);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to create _migrations table: ${msg}`));
const raw = readFileSync(statePath, "utf8");
return JSON.parse(raw);
} catch {
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 {
mkdirSync(dirname(statePath), { recursive: true });
writeFileSync(statePath, JSON.stringify(state, null, 2));
}
/**
* 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 +49,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 +81,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);
}
runtime.state = result.state;
writeState(runtime.statePath, result.state);
return ok(result);
} catch (e) {
+30 -23
View File
@@ -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 };
}
+18 -28
View File
@@ -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,11 +18,11 @@ 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-signals.js";
@@ -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);
-51
View File
@@ -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 -1
View File
@@ -21,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[];
@@ -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();
});
});
@@ -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();
-1144
View File
File diff suppressed because it is too large Load Diff