Compare commits

..

41 Commits

Author SHA1 Message Date
xiaoju 4a43a7f3dd refactor: update all consumers to import from @uncaged/workflow
- workflow-utils, workflow-meta: import workflow types from @uncaged/workflow
- adapter-cursor, adapter-hermes: same
- cli: same
- core: remove workflow re-exports, no longer depends on @uncaged/workflow

Phase 5+6 of #320, Testing: #323
2026-05-05 11:01:08 +00:00
xiaoju cee65bbd87 refactor(workflow): move IPC, worker, manager from daemon to @uncaged/workflow
- Move workflow IPC types (StartThread, ResumeThread, etc.) to workflow/ipc.ts
- Move workflow-worker.ts, workflow-manager.ts, workflow-manager-support.ts
- Move worker-runtime.ts and worker-signals.ts (shared infrastructure)
- Daemon now imports workflow runtime from @uncaged/workflow
- Export WORKFLOW_WORKER_PATH for daemon to spawn workers

Phase 3+4 of #320, Testing: #322
2026-05-05 10:41:59 +00:00
xiaoju 591be21bb0 refactor(workflow): scaffold @uncaged/workflow, move types from core
- Create packages/workflow/ with types.ts (from core/workflow.ts) and config.ts
- Core re-exports workflow types from @uncaged/workflow
- Delete packages/core/src/workflow.ts

Phase 1+2 of #320, Testing: #321
2026-05-05 10:27:08 +00:00
xiaoju 7d0200fa15 docs: add implementation plan for @uncaged/workflow extraction
Refs #320
2026-05-05 10:16:33 +00:00
xiaoju 8dd82d99da refactor(core): remove WorkflowTrigger from SenseTrigger — shell only
Senses trigger shell commands only. Workflows are invoked via CLI.

SenseTrigger is now { command: string } — no discriminated union.

Closes #318

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 12:33:38 +00:00
xiaomo 5ec0c71ee3 Merge pull request 'refactor(core): rename workflow→trigger in sense return, capture shell stderr' (#317) from refactor/316-followup into main 2026-05-02 11:12:09 +00:00
xiaoju 52a03d7de4 refactor(core): rename workflow→trigger in sense return, capture shell stderr
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 10:47:22 +00:00
xiaomo 1aeb23f75a Merge pull request 'feat(core): sense trigger supports arbitrary shell commands' (#316) from feat/315-shell-trigger into main 2026-05-02 10:20:02 +00:00
xiaoju b9b804eac5 feat(core): sense trigger supports arbitrary shell commands
Extend SenseComputeReturn to support shell triggers in addition to workflow
triggers via a discriminated union (kind: 'shell' | 'workflow').

Shell triggers execute a command string in the sense worker subprocess
(spawned detached). The kernel logs 'shell-launch' events without involving
the workflow manager.

Breaking change: WorkflowTrigger now requires kind: 'workflow'.
New ShellTrigger type: { kind: 'shell', command: string }.
SenseTrigger = WorkflowTrigger | ShellTrigger.

Closes #315
2026-05-02 10:00:23 +00:00
xiaomo 6b8c917358 Merge pull request 'feat(cli): nerve agent inject claude — RFC #289 Phase 4' (#306) from feat/agent-inject-claude into main 2026-05-02 02:10:07 +00:00
tuanzi fd65acc329 feat(cli): nerve agent inject claude — inject to ~/.claude/CLAUDE.md
Add Claude Code support to nerve agent:
- nerve agent inject claude — append nerve skill block to ~/.claude/CLAUDE.md
- nerve agent remove claude — remove only the nerve block, preserve user content
- nerve agent status — show Claude Code injection status
- nerve agent update — includes Claude Code

Uses marker comments <!-- nerve-cli:start vX.Y.Z --> / <!-- nerve-cli:end -->
to safely coexist with user's existing CLAUDE.md content.

Closes #305
Ref: #289
2026-05-02 02:02:49 +00:00
xiaomo d442a64275 Merge pull request 'fix: harden state persistence (follow-up #313)' (#314) from fix/313-state-persistence-hardening into main 2026-05-01 12:03:31 +00:00
xiaoju 02c5e8bea6 fix(daemon): harden state persistence, ReadonlyArray triggers
1. writeState: atomic write via temp file + rename
2. readState: distinguish missing file vs corrupt JSON (warn on error)
3. executeCompute: write disk before updating memory state
4. SenseInfo.triggers: ReadonlyArray<string>
5. CLAUDE.md: added Sense State Persistence docs

Fixes #313
2026-05-01 12:01:50 +00:00
xiaomo eb7de9954f Merge pull request 'refactor: Stateful Sense (RFC #308)' (#312) from refactor/308-stateful-sense into main 2026-05-01 10:20:46 +00:00
xiaoju fc7fc9158c docs: update all docs/conventions for stateful sense, remove stale refs
Phase 4 of RFC #308: Stateful Sense refactor.

- CLAUDE.md: updated diagram, tables, examples (no more Signal)
- Cleaned stale Signal Bus / DrizzleDB / _signals / retention refs
  across READMEs, .cursor rules, copilot instructions, .knowledge
- Removed drizzle-orm from core package.json (no longer used)
- Updated pnpm-lock.yaml

Refs #308
2026-05-01 10:09:01 +00:00
xiaoju be1f86044e refactor(cli): migrate senses and CLI to stateful pattern
Phase 3 of RFC #308: Stateful Sense refactor.

- Examples (cpu-usage, nerve-health) use initialState + compute(state)
- CLI create/init templates generate stateful sense (no SQLite/Drizzle)
- Removed: sense query, sense schema commands (no more per-sense SQLite)
- Removed: sense-sqlite.ts, schema templates, migration templates
- Updated all CLI tests for new sense structure

Refs #308, closes #311
2026-05-01 09:58:37 +00:00
xiaoju 5e054facb2 refactor(daemon): stateful sense engine — JSON state, remove Signal Bus
Phase 2 of RFC #308: Stateful Sense refactor.

- SenseRuntime uses JSON file persistence instead of SQLite/Drizzle
- Sense compute now receives state and returns { state, workflow }
- IPC: replaced SignalMessage with ComputeResultMessage
- Removed Signal Bus entirely (on[] now uses reverse-index in scheduler)
- sense-scheduler.onSenseCompleted() triggers dependent senses
- kernel no longer constructs Signal objects or calls routeSenseComputeOutput
- Removed drizzle-orm dependency from daemon package

Refs #308, closes #310
2026-05-01 09:50:46 +00:00
xiaoju e789c7bb34 refactor(core): stateful sense types — remove Signal, add initialState
Phase 1 of RFC #308: Stateful Sense refactor.

- SenseComputeFn<S> now takes state and returns { state, workflow }
- SenseModule<S> exports compute + initialState (no more table)
- Removed: Signal type, ComputeResult, RoutedSenseOutput,
  routeSenseComputeOutput, retention/DEFAULT_SENSE_SIGNAL_RETENTION
- Updated isSenseInfo (removed lastSignalTimestamp)

Refs #308, closes #309
2026-05-01 09:43:13 +00:00
xiaoju 06b91c2e63 test(workflow-utils): add error path tests for createLlmAdapter
Cover non-ok HTTP response (500) and network failure (ECONNREFUSED).
Per review feedback from 星月 🌙 on PR #278.
2026-04-30 15:04:46 +00:00
xiaoju b7200ce51c docs(workflow-meta): document verb-first workflow naming
Add Workflow Naming to CLAUDE.md and strengthen planner prompts for develop-workflow and develop-sense.

Fixes #285

Made-with: Cursor
2026-04-30 15:04:46 +00:00
xingyue 3eab2e29f5 Merge pull request 'chore: dead code cleanup — unused exports, stale docs, deprecated functions' (#304) from chore/dead-code-cleanup into main 2026-04-30 14:47:25 +00:00
xingyue 10c4cf4148 Merge pull request 'feat(cli): nerve agent inject cursor — Phase 4 of RFC #289' (#303) from feat/agent-inject-cursor into main 2026-04-30 14:41:03 +00:00
tuanzi 5db80c99a0 feat(cli): nerve agent inject cursor — generate .cursorrules
Phase 4 of #289:
- Add cursor target to nerve agent inject/remove/status
- Generate .cursorrules from latest SKILL.md content (aligned with PR #301):
  - Parameterless SenseComputeFn compute signature
  - createRole four-tuple for LLM integration
  - Flat roles/<role>.ts layout
  - Sense persistence pitfall
  - nerve agent CLI docs including cursor commands
- Cursor-specific tips (@file, @Folder, @Terminal references)
- Version tracking via .nerve-version + HTML comment
- --path flag for cursor (project-local, not global like hermes)

Closes #299
Ref: #289
2026-04-30 14:38:19 +00:00
xiaomo 49f3d91d1b chore: dead code cleanup — remove unused exports and fix stale docs
- Delete createEchoAgent (daemon, never referenced)
- Delete isDryRun (workflow-utils, deprecated, always false)
- Delete KNOWN_AGENT_ADAPTER_IDS (core, never referenced)
- Remove parseDurationStringToMs, labelSenseTrigger from core public API
- Remove spawnSafe re-export from workflow-utils
- Fix core/README.md stale API names
- Clean stale hermes-options.ts comment

Closes #302
2026-04-30 14:29:45 +00:00
xingyue 4d75c8683f Merge pull request 'docs(cli): sync Hermes SKILL.md with flat workspace and runtime types' (#301) from fix/298-update-hermes-skill into main 2026-04-30 14:20:36 +00:00
xingyue 99b0e58fb6 Merge pull request 'chore: RFC-006 Phase 4 cleanup — delete worker-fork-support.ts' (#300) from chore/rfc-006-cleanup into main 2026-04-30 14:19:44 +00:00
xiaomo a1b1d5eaf1 chore: RFC-006 Phase 4 cleanup — delete worker-fork-support.ts
- Move formatChildExitSummary/formatCapturedStderrTail to worker-runtime.ts
- Move ignoreSessionBroadcastSignals to new worker-signals.ts
- Delete worker-fork-support.ts (teeCapturedStderr no longer used)
- Update .knowledge/worker-isolation.md and architecture.md for WorkerRuntime
- All 167 tests pass, biome check clean

Closes #283
2026-04-30 14:17:16 +00:00
xiaoju 1849789c02 docs(cli): sync Hermes SKILL with workspace AGENT and runtime types
Update sense compute docs to SenseComputeFn (no DB/peers). Document AGENT.md, flat roles, moderator/build helpers, createRole + createLlmAdapter, verb-first naming, nerve agent commands, and root npm/pnpm build.

Fixes #298

Made-with: Cursor
2026-04-30 14:15:14 +00:00
xingyue 7ce46e7735 Merge pull request 'RFC-006 Phase 3: Migrate workflow-manager process logic to WorkerRuntime' (#295) from refactor/rfc-006-workflow-runtime into main 2026-04-30 14:14:05 +00:00
xiaomo 0455f928f5 fix: address review feedback (星月) for Phase 3
1. sendToWorker: IPC send failure now marks thread as failed + dequeues next
2. crashLimitBlocked Set: prevents new startWorkflow from bypassing crash limit
3. "respawning" log skipped when crash limit is active
4. logWorkflowEvent payload: unknown | null (project convention, not ?:)
2026-04-30 14:10:06 +00:00
xingyue 11cedfb5a5 Merge pull request 'refactor(cli): dynamic version for nerve agent — Phase 3 of RFC #289' (#297) from feat/agent-inject-phase3 into main 2026-04-30 14:08:03 +00:00
tuanzi ed1bc4e25f refactor(cli): read CLI version from package.json instead of hardcoding
Phase 3 of #289:
- Replace hardcoded CLI_VERSION constant with dynamic package.json read
- Cache version to avoid repeated fs reads
- Verified: npm pack includes skills/, version detection works correctly

Ref: #289, #296
2026-04-30 14:04:14 +00:00
xiaomo dc4454d23e refactor: migrate workflow-manager process logic to WorkerRuntime (RFC-006 Phase 3)
- workflow-manager.ts: 792 → 498 lines, no more fork/ChildProcess/crash counting
- Extract pure functions to workflow-manager-support.ts (256 lines)
- WorkerRuntime gains: onCrashLimitReached callback, WorkerDrainOpts for per-call timeout
- worker-pool.ts updated for new evict/drain signature (null opts)
- All 167 daemon tests pass

Closes #282
2026-04-30 13:56:43 +00:00
xingyue 7c256620c5 Merge pull request 'feat(cli): nerve agent inject/update/remove/status — Phase 2 of RFC #289' (#294) from feat/agent-inject-phase2 into main 2026-04-30 13:53:39 +00:00
tuanzi 14898e1827 feat(cli): add nerve agent inject/update/remove/status commands
Phase 2 of #289:
- nerve agent inject hermes [--profile <name>]
- nerve agent update (updates all injected skills)
- nerve agent remove hermes [--profile <name>]
- nerve agent status (version check across profiles)
- Include skills/ in npm package files

Ref: #289, #293
2026-04-30 13:46:55 +00:00
xingyue 082d2e72f2 Merge pull request 'refactor: align develop prompts and .knowledge with flat workspace' (#288) from refactor/287-align-prompts-knowledge into main 2026-04-30 13:46:33 +00:00
xingyue fbf63e0266 Merge pull request 'RFC-006 Phase 2: Migrate SenseWorkerPool to WorkerRuntime' (#292) from refactor/rfc-006-worker-runtime into main 2026-04-30 13:44:56 +00:00
xingyue 7d89e8ab61 Merge pull request 'feat(cli): add hermes nerve skill — Phase 1 of RFC #289' (#291) from feat/agent-inject-phase1 into main 2026-04-30 13:42:05 +00:00
xiaoju 06b1e3d785 refactor(cli,workflow-meta): scaffold AGENT.md on init; align develop prompts
Generate AGENT.md at ~/.uncaged-nerve root during nerve init (layout, verb-first workflows, createRole four-tuple, root build, coding style). Role prompts instruct agents to use cat AGENT.md instead of node_modules nerve-skills paths.

E2E init test asserts AGENT.md. Retain .knowledge workflow/adapter updates and flat single-file roles guidance from the branch.

Fixes #287

Made-with: Cursor
2026-04-30 13:41:24 +00:00
tuanzi f828ebc28b fix: align testing issue commands + add moderator sync pitfall
Address review from 星月:
- Update testing issue #290 to match actual CLI commands
- Add pitfall: moderator must be sync (not async)
- knowledge commands confirmed real (exist in codebase)

Ref: #290
2026-04-30 13:38:56 +00:00
tuanzi 809a11afe3 feat(cli): add hermes nerve skill (Phase 1 of #289)
Add SKILL.md for Hermes Agent covering:
- Core concepts (Sense → Signal → Workflow → Log)
- Complete CLI reference
- Sense development guide with examples
- Workflow development guide with examples
- Daily operation patterns
- nerve.yaml config reference

Ref: #289, #290
2026-04-30 13:36:03 +00:00
154 changed files with 5622 additions and 5317 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` |
+1 -1
View File
@@ -20,7 +20,7 @@ Always use static top-level `import` statements.
## Exceptions (must include a comment explaining why)
1. **`sense-runtime.ts`** — loads user-authored sense modules whose paths are only known at runtime
2. **`workflow-worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
2. **`packages/workflow/src/worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
When suppressing, add a comment directly above:
+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` |
+22 -1
View File
@@ -28,8 +28,29 @@ For long-running or incremental agent outputs:
|---------|---------|------|
| `@uncaged/nerve-adapter-cursor` | `cursorAdapter` / `createCursorAdapter()` | cursor-agent CLI |
| `@uncaged/nerve-adapter-hermes` | `hermesAdapter` / `createHermesAdapter()` | hermes chat CLI |
| `@uncaged/nerve-workflow-utils` | `createLlmAdapter(provider)` | OpenAI-compatible HTTP chat (single-turn) |
Each exports a **default instance** (sensible defaults) and a **factory** for custom config.
The Cursor and Hermes adapter packages each export a **default instance** (sensible defaults) and a **factory** for custom config. `createLlmAdapter` is a factory on `@uncaged/nerve-workflow-utils` only.
## createLlmAdapter
`createLlmAdapter` builds an `AgentFn` from an `LlmProvider` (`baseUrl`, `apiKey`, `model`). One chat completion per role step: **system** = the string passed by `createRole` (your prompt); **user** = `ctx.start.content` (the thread’s start frame). On failure it throws with a formatted LLM error.
```ts
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
import { z } from "zod";
const metaSchema = z.object({ ok: z.boolean() });
const planner = createRole(
createLlmAdapter({ baseUrl: "https://api.example.com/v1", apiKey: "…", model: "gpt-4o-mini" }),
"You are a planner…",
metaSchema,
extractConfig,
);
```
Use this when you want a role backed by an HTTP LLM instead of a subprocess CLI adapter.
## Usage in Workflows
+1
View File
@@ -33,6 +33,7 @@ Senses own both the "what" (compute logic) and the "when" (config-driven schedul
- One worker per Workflow type (on-demand)
- Workers never talk to each other
- All user code runs in isolated Workers; kernel never loads user code directly
- **`WorkerRuntime`** (`packages/daemon/src/worker-runtime.ts`) centralizes fork lifecycle for both sense groups (`worker-pool.ts`) and workflow types (`workflow-manager.ts`); see `.knowledge/worker-isolation.md`
## Storage Systems
+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
+8 -9
View File
@@ -17,17 +17,16 @@ export async function compute(): Promise<ComputeResult<T>> { ... } // pure, no
- No database access within compute — runtime provides isolated execution context
- Must be pure function (no side effects, no external API calls)
**Return Value Contract:**
- `ComputeResult<T>` = `null | { signal: T; workflow: WorkflowTrigger | null }`
- `null` → silent, no storage, no signal
- `{ signal: data, workflow: null }` → persist + emit signal
- `{ signal, workflow: WorkflowTrigger }` → persist + emit signal + trigger workflow
- Any other value → treated as `{ signal: value, workflow: null }`
**Return Value Contract (current engine):**
- `compute(state)` returns `Promise<{ state: S; trigger: SenseTrigger | null }>` where `SenseTrigger = { command: string }`.
- `trigger: null` → persist state only; no shell command
- `trigger: { command }` → persist state; worker runs the command with `shell: true` after a successful compute
- Workflows are **not** started from `trigger`; use CLI / daemon IPC (`nerve workflow trigger`, etc.).
**Error Handling & Serialization:**
- Exceptions caught by worker, logged as errors (no signal emitted)
- Signal payload must be JSON-serializable (passed via IPC)
- Invalid workflow triggers silently dropped (signal still emitted)
- Exceptions caught by worker, logged as errors (state unchanged)
- State must be JSON-serializable (persisted to `data/senses/<name>.json`)
- Invalid `trigger` shapes fail IPC validation when the worker sends `compute-result`
**Timeout & Scheduling Semantics:**
- Timeout priority: explicit config → AbortSignal → DEFAULT_TIMEOUT_MS (30s)
+20 -79
View File
@@ -1,91 +1,32 @@
# Signal Routing
# Sense compute → shell + scheduler (issue #318)
Signal routing is the core mechanism that determines how Sense outputs flow through the Nerve system.
Stateful senses run `compute(state)` and return `{ state, trigger }`. The worker persists state JSON and sends `compute-result` to the kernel. Optional side effects are **shell commands only**, executed in the sense worker. Workflows are not started from sense return values.
## 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, trigger }
persist state JSON (data/senses/<name>.json)
trigger !== null → spawn shell command (cwd = nerve root)
IPC compute-result → kernel (audit: shell-launch log)
scheduler.onSenseCompleted(senseName) → dependents with `on: [senseName]`
```
## Two Output Formats
Workflow runs: **`workflowManager.startWorkflow`** from CLI / daemon IPC only (`nerve workflow trigger`, HTTP when enabled).
### 1. Explicit Format
```typescript
{
signal: any, // emitted as signal
workflow: { // optional workflow trigger
name: string,
maxRounds: number,
prompt: string,
dryRun: boolean
} | null
}
```
## Sense trigger shape
### 2. Shorthand Format
Any other value is treated as:
```typescript
{ signal: payload, workflow: null }
```
When `trigger` is non-null it must be a plain object validated by `parseSenseTrigger()` in `packages/core/src/sense.ts`:
## Workflow Directive Parsing
- Exactly one property: `command` (non-empty string after trim)
- No `kind` field; no workflow fields
## Concrete Routing Predicates
Invalid triggers are rejected when parsing the worker message.
The routing decision is implemented in `routeSenseComputeOutput()` using these exact matching criteria:
## Scheduling
### 1. Explicit Format Detection
```typescript
if (isPlainRecord(payload) && Object.hasOwn(payload, "signal"))
```
- Payload must be a plain object
- Must have `signal` property (any value)
- Workflow extracted from `workflow` property or defaults to null
### 2. Workflow Validation
When workflow is non-null, it's validated via `parseWorkflowTrigger()`:
- `name`: non-empty string (trimmed)
- `maxRounds`: positive integer >= 1
- `prompt`: string
- `dryRun`: boolean
**Critical behavior**: Invalid workflows are silently dropped (become null) but signal emission continues. This prevents malformed workflow config from blocking signals.
### 3. Fallback to Shorthand
Any value that doesn't match explicit format becomes:
```typescript
{ signal: payload, workflow: null }
```
## Processing Flow
```typescript
// In kernel.ts handleSenseWorkerSignal()
const { signal: signalPayload, workflow } = routeResult.value;
// Signal is ALWAYS emitted when compute returns non-null
bus.emit({ id, senseId, payload: signalPayload, timestamp });
// Workflow is started ONLY if workflow is non-null
if (workflow !== null) {
workflowManager.startWorkflow(workflow.name, { ... });
}
```
## Legacy String Format (Deprecated)
The old `"name|maxRounds|prompt"` string format is converted to the structured format internally but should not be used in new code.
## Key Behaviors
1. **Signal priority**: Every non-null compute result emits a signal, regardless of workflow
2. **Additive behavior**: Valid workflow triggers are executed in addition to signal emission
3. **Failure tolerance**: Invalid workflow directives are silently ignored, signal still emits
4. **Structure-based routing**: No complex predicates - simply checks object structure and property existence
This routing mechanism ensures clean separation between perception (signals) and action (workflows) while maintaining backward compatibility.
Other senses list this sense under `on` in `nerve.yaml` to be scheduled when this sense completes a successful compute (see sense scheduler reverse-index in the daemon).
+8 -2
View File
@@ -12,6 +12,12 @@ Kernel (Main Process)
└── Workflow Worker (review) ── review workflow instances
```
### WorkerRuntime (RFC-006)
Forked worker processes are managed by **`WorkerRuntime`** (`worker-runtime.ts`): one Node child per logical key, cold start, optional respawn after crash, drain/evict, and coordinated shutdown over IPC. **`worker-pool.ts`** (sense groups) and **`workflow-manager.ts`** (workflow types) both configure and delegate to `createWorkerRuntime` instead of owning ad-hoc fork logic.
Worker **entrypoints** (`sense-worker.ts`, `workflow-worker.ts`) import lightweight helpers only — e.g. `worker-signals.ts` for session broadcast signal handling — so they do not pull in the parent-side runtime module.
## Isolation Boundaries
### 1. Sense Workers
@@ -111,10 +117,10 @@ workflows:
### Process Management
#### Signal Handling
Workers ignore session broadcast signals (SIGINT/SIGTERM):
Workers ignore session broadcast signals (SIGINT/SIGTERM) via `ignoreSessionBroadcastSignals()` in `worker-signals.ts`:
```typescript
// Workers ignore terminal signals; kernel coordinates shutdown
process.on("SIGINT", () => {});
process.on("SIGINT", () => {});
process.on("SIGTERM", () => {});
```
+14
View File
@@ -2,6 +2,20 @@
Stateful multi-step execution driven by Roles and a Moderator.
## Workspace Layout (authoring)
User Nerve workspaces use a **flat** build: one root `package.json`, one root bundle script (typically `scripts/build.mjs` wired from `scripts.build`), and **no** per-workflow `package.json` or `tsconfig.json`.
| Location | Purpose |
|----------|---------|
| `workflows/<name>/index.ts` | Default export: `WorkflowDefinition` (moderator + role map). |
| `workflows/<name>/roles/<role>.ts` | One module per role — schemas, prompts, `createRole` factories, or hand-written async role functions. |
| `dist/workflows/<name>/index.js` | Emit of the root build; this is what the daemon loads. |
**Naming:** Workflow ids should be **verb-first** kebab-case phrases (e.g. `deploy-staging`, `scan-dependencies`), not opaque nouns alone.
Senses follow the same flat pattern: `senses/<name>/src/*.ts`, `migrations/`, root build → `dist/senses/<name>/index.js`. See `.knowledge/sense.md`.
## Core Concepts
- **Workflow** — definition with concurrency strategy
+40 -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,21 +14,33 @@ External World → Sense → Signal → Workflow → Log
| Concept | What it is |
|---------|-----------|
| **Sense** | A `compute()` function that samples or derives data. Returns `ComputeResult<T>` — non-null emits a Signal (and optionally triggers a Workflow), null is silent. Each Sense has its own SQLite database. Scheduling (interval, on) is configured in nerve.yaml. |
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling configured in nerve.yaml. |
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
| **Log** | Immutable audit trail. Records executions, state transitions, errors. Cannot trigger Senses — prevents feedback loops. |
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. |
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
### Architecture Rules
- **Two extension points**: Sense (what to observe + when), Workflow (what to do)
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
- **Causality is one-directional**: External world → Sense → Signal → Workflow + Log. Logs are the end of the chain.
- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
### Sense State Persistence
Each sense's state is persisted as a JSON file at `data/senses/<name>.json` (relative to the nerve root, typically `~/.uncaged-nerve/`).
| Event | Behavior |
|-------|----------|
| **Worker start** | Read `state.json`; if missing or corrupt, use `initialState` from the sense module |
| **Compute success** | Write new state atomically (write-temp + rename), then update in-memory state |
| **Compute failure** | State unchanged (both disk and memory) |
| **Daemon restart** | State restored from last successful write |
State files are written atomically (temp file + rename) to prevent corruption on crash.
## Language & Paradigm
### Functional-first
@@ -37,18 +49,18 @@ Use `function` + `type`, not `class` + `interface`.
```typescript
// ✅ Good
type Signal = {
senseId: string;
value: unknown;
type WorkflowLaunch = {
senseName: string;
workflowName: string;
ts: number;
};
function createSignal(senseId: string, value: unknown): Signal {
return { senseId, value, ts: Date.now() };
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
return { senseName, workflowName, ts: Date.now() };
}
// ❌ Bad — no class, no interface
class Signal implements ISignal { ... }
class WorkflowLaunch implements IWorkflowLaunch { ... }
```
### Rules
@@ -92,12 +104,22 @@ type SenseConfig = {
For mutually exclusive fields, use discriminated unions:
```typescript
// ✅ Good
type ComputeResult<T> =
| null
| { signal: T; workflow: WorkflowTrigger | null };
import type { SenseTrigger } from "@uncaged/nerve-core";
// ✅ Good — sense modules return explicit next state + optional shell trigger only
type SenseComputeReturn<S> = {
state: S;
trigger: SenseTrigger | 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 +154,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` |
+56 -45
View File
@@ -2,35 +2,36 @@
**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) → { state, trigger? } → (shell in worker) / Log
Workflow → Log (CLI / daemon IPC only)
scheduling: interval / on (per sense in nerve.yaml)
```
| Concept | Metaphor | Role |
|---------|----------|------|
| **Sense** | 👁️ Perception | 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, trigger }`. State lives in `data/senses/<name>.json`. |
| **Schedule** | ⏱️ When | Each sense entry sets optional `interval` (periodic) and `on: [other senses]` (run after those senses complete a compute). |
| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started via CLI / daemon IPC (`nerve workflow trigger`, transport). Not started from sense `compute()` results. |
| **Log** | 📝 Record | Immutable audit trail. **Cannot** schedule senses or workflows (prevents feedback loops). |
**Sense → Workflow:** 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 → shell:** when `trigger` is non-null it must be `{ command: string }`. The sense worker runs it with `shell: true` (cwd = nerve root). Use `trigger: null` when no command should run. To start a workflow, invoke it from that shell command (for example calling the CLI) or trigger workflows separately via IPC.
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, sense trigger validation (`parseSenseTrigger`), daemon IPC protocol |
| [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows |
| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, 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 +44,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 +78,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 +89,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 +109,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 +153,9 @@ export async function compute() {
│ │ └──────────────┼──────────────┘ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ Signal Bus │
│ │ │Sense Scheduler
│ │ │(interval + on) │
│ │ └──────┬───────┘ │
│ │ ▼ │
│ │ ┌──────────────────┐ │
│ │ │ Reflex Scheduler│ │
│ │ └────────┬─────────┘ │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │
@@ -152,8 +164,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 +172,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 +191,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` |
+146
View File
@@ -0,0 +1,146 @@
# Nerve 单仓死代码分析报告
**分析日期**: 2026-04-30
**范围**: `packages/core`, `daemon`, `store`, `cli`, `khala`, `workflow-utils`, `workflow-meta`, `adapter-cursor`, `adapter-hermes` 的 TypeScript 源码与 `package.json` 依赖。
**方法**: 全仓 `ripgrep` 交叉验证 `import` / `export` 路径;未运行 Knip/TS 死代码专项工具。
**说明**: 未包含 `role-reviewer` / `role-committer` / `skills` 等包(你列出的范围外),但 `workflow-meta` 对其中部分有依赖,相关结论已注明。
---
## 方法限制(读前必读)
| 限制 | 影响 |
|------|------|
| **动态 `import(url)`** | `cli``loadDaemonModule` 在运行时加载 `@uncaged/nerve-daemon`,静态分析无法把 `createKernel` 等映射到具体导出。 |
| **已发布包的公共 API** | 大量导出仅被仓外消费者使用;本报告中的「仓内未引用」≠「应删除」。 |
| **构建入口** | Rslib 多入口(如 `sense-worker``workflow-worker``daemon-bootstrap`)视为存活入口,不作为孤儿文件。 |
| **内部未导出函数** | 未做逐函数调用图;「死函数」仅列出高置信个例。 |
---
## 1. 未使用导出(仓内无 `import`)
以下符号在 **monorepo 内** 没有任何文件从 `@uncaged/nerve-core` / `@uncaged/nerve-workflow-utils` / `@uncaged/nerve-daemon` 等包根导出处引用(测试与定义自身除外)。
### 1.1 `@uncaged/nerve-core`
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/core/src/index.ts``agent.js` | `KNOWN_AGENT_ADAPTER_IDS` | **高** | **调查**:若 CLI/校验应约束 adapter id,可接入;否则可从公共 API 移除或保留作文档性常量并注明。 |
| `packages/core/src/index.ts``sense.js` | `labelSenseTrigger``senseTriggerLabels``cli`/`daemon` 有使用) | **高(相对仓内)** | **保留或收敛导出**:仅 `senseTriggerLabels` 对外即可;`labelSenseTrigger` 可作为内部函数若不需要单独暴露。 |
| `packages/core/src/index.ts``util.js` | `parseDurationStringToMs`(仅 `config.ts` 内部使用) | **高** | **调查**:无需在 `index` 再导出则改为非导出或移除 re-export,减少 API 面。 |
| `packages/core/src/index.ts``sense.js` | 类型 `SenseModule` | **中** | **保留**:用户文档/外部 Sense 模块常用;仓内未按名引用属正常。 |
| `packages/core/src/index.ts``config.js` | 单独导出的类型 `NerveApiConfig`(仅 `config.ts``index` 出现) | **低** | **保留**:通常随 `NerveConfig` 一并使用;单独 export 多为 TS 公共类型便利。 |
### 1.2 `@uncaged/nerve-workflow-utils`
下列项在 **`packages/*/src/**/*.ts` 中无人从包入口导入**(`workflow-meta``role-committer` 等主要只用 `createRole``decorateRole``onFail``withDryRun``LlmExtractorConfig`)。
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/workflow-utils/src/index.ts` | `createLlmAdapter` | **高** | **保留(对外 API)** 或标注文档;仓内无调用。 |
| 同上 | `llmExtract``llmExtractWithRetry` | **高** | **保留**:高级用法 / 文档示例;内部与测试使用。 |
| 同上 | `mergeExtractConfig``ExtractConfigLayer` | **高** | **调查**:若 RFC 分层配置仍在推进则保留;否则评估移除导出。 |
| 同上 | `assertZodMetaSchemas``createLlmExtractFn``extractMetaOrThrow``zodMeta``ZodMetaSchema` | **高** | **保留(对外 API)**;当前仅 `workflow-utils` 测试与内部 `create-role` 链路使用 `extractMetaOrThrow`。 |
| 同上 | `readNerveYaml``nerveAgentContext` 及相关类型 | **高** | **调查**:若已无 YAML 上下文注入场景可删导出;否则保留给 Agent 工具链。 |
| 同上 | `spawnSafe``nerveCommandEnv``Spawn*` 类型(从 core 再导出) | **高** | **删除再导出或改为文档链接**:仓内均直接从 `@uncaged/nerve-core` 引用 spawn。 |
| 同上 | `isDryRun` | **高** | **删除或实现**:见 §3「死函数」。 |
| 同上 | `LlmMessage``MetaExtractConfig``LlmChatError``LlmError``LlmProvider` 等类型再导出 | **中** | **保留**:供外部精细类型标注;仓内未单独 import。 |
### 1.3 `@uncaged/nerve-daemon`
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/daemon/src/index.ts``agent-adapters/echo.js` | `createEchoAgent` | **高** | **调查**:无任何测试或运行时引用;若设计保留 `type: "echo"` 适配器,应在 workflow/agent 装载路径接线或删除导出与文件。 |
| `packages/daemon/src/index.ts`(及其它导出) | 多数 IPC / runtime 符号 | **低** | **保留**:动态加载与外部集成无法静态判定;仅 `createEchoAgent` 可确定为当前静态图下的死角。 |
### 1.4 `@uncaged/nerve-adapter-cursor` / `@uncaged/nerve-adapter-hermes`
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/adapter-cursor/src/index.ts` | `cursorAdapter``createCursorAdapter`(以及 `cursorAgent` 仅被 `workflow-utils/src/role-cursor.ts` 使用) | **中** | **保留**:默认实例与工厂供下游与工作流仓使用;仓内业务包未直接 import `cursorAdapter` 属预期。 |
| `packages/adapter-hermes/src/index.ts` | `hermesAdapter``createHermesAdapter``hermesAgent``workflow-utils` 间接使用) | **中** | 同上。 |
### 1.5 `@uncaged/nerve-cli`
| 路径 | 未使用项 | 置信度 | 建议 |
|------|----------|--------|------|
| `packages/cli/src/index.ts` | `getNerveRoot``loadDaemonModule` 等程序化导出 | **高(仓内)** | **保留**:无任何 workspace 包引用 `@uncaged/nerve-cli`;面向 CLI 二次开发者。 |
---
## 2. 孤儿文件(非入口、未被其它源码引用)
在当前 Rslib / `cli` 入口定义下,**未发现**「零 import、且非 entry / 非测试」的 `.ts` 业务文件:
- `workflow-utils` 下的 `role-cursor.ts``role-hermes.ts``role-llm.ts``role-react.ts` 虽**未出现在包 `index.ts`**,但被 `packages/workflow-utils/src/__tests__/role-factories.test.ts` 直接引用,**不是孤儿文件**。
- `daemon``sense-worker.ts``workflow-worker.ts` 等为 **显式构建入口**
**置信度**: 对全表扫描为 **中**(未跑依赖图工具;动态路径可能掩盖极少数脚本引用)。
---
## 3. 死函数(内部未导出且未被调用)
| 路径 | 说明 | 置信度 | 建议 |
|------|------|--------|------|
| `packages/workflow-utils/src/role-types.ts` | `isDryRun(_start: StartStep)`:导出在 `index.ts`,但全仓无调用(函数体恒返回 `false` 且标注 deprecated) | **高** | **删除**或改为内部常量;若需向后兼容则保留并文档标注「保留占位」。 |
其它内部函数未系统逐文件枚举。
---
## 4. 未使用的 npm 依赖(package.json)
在声明范围内通过源码 `import` 抽查:**未发现明显「完全未使用」的生产依赖**。
| 包 | 依赖 | 结论 |
|----|------|------|
| `cli` | `citty``yaml``picomatch` | 均有对应源码引用。 |
| `khala` | `hono``jsonata``ulidx``@uncaged/nerve-core` | 均有引用。 |
| `core` / `daemon` | `yaml``drizzle-orm` | 均有引用。 |
| `workflow-utils` / `workflow-meta` | `zod`、workspace 包 | 均有引用。 |
**置信度**: **中**(未对 peer、可选路径、CLI `bin` 专用依赖做自动化扫描)。
---
## 5. 陈旧测试夹具 / 未引用辅助文件
未发现独立的「fixture 目录」明显失联;`cli``e2e-harness``__tests__` 内 helper 均有对应测试引用。
**置信度**: **低**(未枚举每个 `__tests__` 资源文件)。
---
## 6. 重构遗留 / 文档漂移
| 项目 | 位置 | 说明 | 置信度 | 建议 |
|------|------|------|--------|------|
| ~~已更名 API 仍出现在 README~~ | `packages/core/README.md` | (已修正)文档与 stateful sense、`parseSenseTrigger`(shell-only)对齐 | — | 关闭 |
| Hermes 选项合并注释 | `packages/workflow-utils/src/shared/hermes-agent.ts` | 注释称 absorbed from `hermes-options.ts`,该文件已不存在 | **中** | **清理注释**,避免误导。 |
| `KNOWN_AGENT_ADAPTER_IDS``codex` | `packages/core/src/agent.ts` | 仓内无 `codex` 适配器包;与常量未被引用叠加 | **中** | **对齐产品**:实现适配器或从列表移除。 |
未发现用户提到的 `worker-fork-support` 等字符串副本(全仓无匹配)。
---
## 7. 汇总建议优先级
1. **高优先级调查**: `createEchoAgent``KNOWN_AGENT_ADAPTER_IDS` — 要么接入运行时,要么删减以免维护假象。
2. **API 面收敛**: `parseDurationStringToMs``labelSenseTrigger` 若无意对外,可从 `core` 公共导出移除。
3. **`workflow-utils`**: 评估 `isDryRun` 删除;`spawnSafe` 等从 `workflow-utils` 再导出是否仍有必要。
4. ~~**文档**: 修正 `packages/core/README.md` 中 Sense→Workflow 路由 API 名称。~~(已完成)
---
## 8. 重新验证命令示例
后续可在本地采用工具增强置信度(非本次执行):
```bash
pnpm add -D -w knip
pnpm exec knip
```
或在各包对 `@uncaged/nerve-*` 的导出做面向消费者的契约测试,避免误删对外 API。
@@ -0,0 +1,496 @@
# Extract Workflow Engine into `@uncaged/workflow` — Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Extract the workflow engine (types, runtime, IPC, manager) from nerve-core and nerve-daemon into a standalone `@uncaged/workflow` package.
**Architecture:** Create `packages/workflow/` as a new pnpm workspace package. Move workflow types from core and workflow runtime from daemon into it. The daemon becomes a consumer of `@uncaged/workflow`. No backward-compat re-exports — breaking change, update all consumers in one shot.
**Tech Stack:** TypeScript, pnpm workspace, rslib (bundler, same as other packages), Biome
**Ref:** Fixes #320
---
## Phase 1: Scaffold `packages/workflow/`
### Task 1: Create package skeleton
**Objective:** Create the `@uncaged/workflow` package with package.json, tsconfig, rslib config.
**Files:**
- Create: `packages/workflow/package.json`
- Create: `packages/workflow/tsconfig.json`
- Create: `packages/workflow/rslib.config.ts`
- Create: `packages/workflow/src/index.ts` (empty barrel)
**Steps:**
1. Copy `packages/workflow-utils/package.json` as template, change name to `@uncaged/workflow`, remove all dependencies except dev deps (typescript, rslib, etc.). No runtime deps initially.
2. Copy `packages/workflow-utils/tsconfig.json` and `rslib.config.ts` as-is (same monorepo conventions).
3. Create empty `packages/workflow/src/index.ts`:
```typescript
// @uncaged/workflow — standalone workflow orchestration engine
```
4. Verify:
```bash
cd packages/workflow && pnpm install && pnpm run build
```
5. Commit:
```bash
git add packages/workflow/
git commit -m "chore(workflow): scaffold @uncaged/workflow package
Refs #320"
```
---
## Phase 2: Move Types from Core
### Task 2: Move `workflow.ts` types to `@uncaged/workflow`
**Objective:** Move all workflow types and constants from `packages/core/src/workflow.ts``packages/workflow/src/types.ts`.
**Files:**
- Create: `packages/workflow/src/types.ts`
- Modify: `packages/workflow/src/index.ts`
**Steps:**
1. Copy `packages/core/src/workflow.ts``packages/workflow/src/types.ts` verbatim (all 83 lines — `START`, `END`, `DEFAULT_ENGINE_MAX_ROUNDS`, all types).
2. Export everything from `packages/workflow/src/index.ts`:
```typescript
export {
START,
END,
DEFAULT_ENGINE_MAX_ROUNDS,
} from "./types.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartStep,
ThreadContext,
WorkflowContext,
AgentFn,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
} from "./types.js";
```
3. Build to verify types compile:
```bash
cd packages/workflow && pnpm run build
```
4. Commit:
```bash
git commit -am "refactor(workflow): move workflow types from core to @uncaged/workflow
Refs #320"
```
### Task 3: Move `WorkflowConfig` to `@uncaged/workflow`
**Objective:** Move the `WorkflowConfig` type (and its constituent types `DropOverflowConfig`, `QueueOverflowConfig`) from core/config.ts to the workflow package.
**Files:**
- Create: `packages/workflow/src/config.ts`
- Modify: `packages/workflow/src/index.ts`
- Modify: `packages/core/src/config.ts` — remove `WorkflowConfig`, `DropOverflowConfig`, `QueueOverflowConfig`; import from `@uncaged/workflow` instead
**Steps:**
1. Create `packages/workflow/src/config.ts` with the three types:
```typescript
export type DropOverflowConfig = {
concurrency: number;
overflow: "drop";
};
export type QueueOverflowConfig = {
concurrency: number;
overflow: "queue";
maxQueue: number;
};
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
```
2. Re-export from `packages/workflow/src/index.ts`.
3. In `packages/core/package.json`, add dependency: `"@uncaged/workflow": "workspace:*"`.
4. In `packages/core/src/config.ts`:
- Remove the three type definitions
- Add `import type { WorkflowConfig, DropOverflowConfig, QueueOverflowConfig } from "@uncaged/workflow";`
- Keep the re-export from `packages/core/src/index.ts` pointing to config.ts (which now re-exports from workflow)
5. Build entire workspace:
```bash
pnpm run build
```
6. Commit:
```bash
git commit -am "refactor(workflow): move WorkflowConfig types to @uncaged/workflow
Refs #320"
```
### Task 4: Remove workflow types from core, update core exports
**Objective:** Delete `packages/core/src/workflow.ts` entirely. Core re-exports workflow types from `@uncaged/workflow`.
**Files:**
- Delete: `packages/core/src/workflow.ts`
- Modify: `packages/core/src/index.ts` — change workflow exports to re-export from `@uncaged/workflow`
- Modify: `packages/core/src/config.ts` — remove import of `DEFAULT_ENGINE_MAX_ROUNDS` from deleted file
**Steps:**
1. In `packages/core/src/config.ts`, replace:
```typescript
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
```
with:
```typescript
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
```
2. In `packages/core/src/index.ts`, replace all workflow.js imports with re-exports from `@uncaged/workflow`:
```typescript
// Workflow types — re-exported from @uncaged/workflow
export {
START, END, DEFAULT_ENGINE_MAX_ROUNDS,
} from "@uncaged/workflow";
export type {
WorkflowMessage, RoleResult, Role, RoleMeta, StartStep,
ThreadContext, WorkflowContext, AgentFn, RoleStep,
ModeratorContext, Moderator, WorkflowDefinition,
} from "@uncaged/workflow";
```
3. Delete `packages/core/src/workflow.ts`.
4. Full build + test:
```bash
pnpm run build && pnpm test
```
5. Commit:
```bash
git commit -am "refactor(core): remove workflow.ts, re-export from @uncaged/workflow
Refs #320"
```
---
## Phase 3: Move Workflow IPC Messages
### Task 5: Extract workflow IPC types to `@uncaged/workflow`
**Objective:** Move workflow-related IPC message types from `packages/daemon/src/ipc.ts` to `packages/workflow/src/ipc.ts`. Sense IPC stays in daemon.
**Files:**
- Create: `packages/workflow/src/ipc.ts`
- Modify: `packages/workflow/src/index.ts`
- Modify: `packages/daemon/src/ipc.ts` — remove workflow IPC types, import from `@uncaged/workflow`
**Steps:**
1. Extract to `packages/workflow/src/ipc.ts`:
- `StartThreadMessage`, `ResumeThreadMessage`, `KillThreadMessage`
- `ThreadLifecycleEvent`, `ThreadEventMessage`, `WorkflowErrorMessage`, `ThreadWorkflowMessage`
- Workflow-related validation logic from `parseParentToWorkerMessage`
- Union type for workflow parent→worker messages
2. Keep in daemon `ipc.ts`:
- `ComputeMessage`, `ShutdownMessage`, `HealthRequestMessage`
- Sense-related worker→parent messages
- The combined `ParentToWorkerMessage` union (imports workflow types from `@uncaged/workflow`)
3. Add `@uncaged/workflow` as dependency to `packages/daemon/package.json`.
4. Build + test:
```bash
pnpm run build && pnpm test
```
5. Commit:
```bash
git commit -am "refactor(workflow): move workflow IPC types to @uncaged/workflow
Refs #320"
```
---
## Phase 4: Move Workflow Runtime
### Task 6: Move `workflow-worker.ts` to `@uncaged/workflow`
**Objective:** Move workflow execution runtime (the worker that runs inside a child process) from daemon to the workflow package.
**Files:**
- Move: `packages/daemon/src/workflow-worker.ts` → `packages/workflow/src/worker.ts`
- Modify: `packages/workflow/src/index.ts`
- Modify: `packages/daemon/` — update worker spawn path
**Steps:**
1. Copy `workflow-worker.ts` to `packages/workflow/src/worker.ts`.
2. Update imports: replace `@uncaged/nerve-core` with local imports from `./types.js`, `./ipc.js`.
3. Export the worker entry point or the worker file path from `@uncaged/workflow` so daemon can spawn it.
4. In daemon, update worker spawn to reference `@uncaged/workflow`'s worker.
5. Delete `packages/daemon/src/workflow-worker.ts`.
6. Build + test:
```bash
pnpm run build && pnpm test
```
7. Commit:
```bash
git commit -am "refactor(workflow): move workflow-worker runtime to @uncaged/workflow
Refs #320"
```
### Task 7: Move `workflow-manager.ts` and `workflow-manager-support.ts` to `@uncaged/workflow`
**Objective:** Move workflow process management from daemon to workflow package.
**Files:**
- Move: `packages/daemon/src/workflow-manager.ts` → `packages/workflow/src/manager.ts`
- Move: `packages/daemon/src/workflow-manager-support.ts` → `packages/workflow/src/manager-support.ts`
- Modify: `packages/daemon/src/kernel.ts` — import from `@uncaged/workflow`
**Steps:**
1. Copy both files to `packages/workflow/src/`.
2. Update imports to use local paths within the workflow package.
3. Export manager creation function from `packages/workflow/src/index.ts`.
4. Update `packages/daemon/src/kernel.ts` to import workflow manager from `@uncaged/workflow`.
5. Delete the original files from daemon.
6. Build + test:
```bash
pnpm run build && pnpm test
```
7. Commit:
```bash
git commit -am "refactor(workflow): move workflow-manager to @uncaged/workflow
Refs #320"
```
---
## Phase 5: Update All Consumers
### Task 8: Update `workflow-utils` to depend on `@uncaged/workflow`
**Objective:** Change `@uncaged/nerve-workflow-utils` to import workflow types from `@uncaged/workflow` instead of `@uncaged/nerve-core`.
**Files:**
- Modify: `packages/workflow-utils/package.json` — replace `@uncaged/nerve-core` dep with `@uncaged/workflow`
- Modify: all `packages/workflow-utils/src/*.ts` — update import paths
**Steps:**
1. In `package.json`, replace `"@uncaged/nerve-core": "workspace:*"` with `"@uncaged/workflow": "workspace:*"`.
2. Find-and-replace all `from "@uncaged/nerve-core"` → `from "@uncaged/workflow"` in `packages/workflow-utils/src/`.
3. Build + test:
```bash
pnpm run build && pnpm test
```
4. Commit:
```bash
git commit -am "refactor(workflow-utils): import from @uncaged/workflow
Refs #320"
```
### Task 9: Update `workflow-meta` to depend on `@uncaged/workflow`
**Objective:** Same as Task 8 but for `packages/workflow-meta/`.
**Files:**
- Modify: `packages/workflow-meta/package.json`
- Modify: all `packages/workflow-meta/src/**/*.ts`
**Steps:** Same pattern as Task 8.
### Task 10: Update adapter packages
**Objective:** Update `adapter-cursor` and `adapter-hermes` to import workflow types from `@uncaged/workflow`.
**Files:**
- Modify: `packages/adapter-cursor/src/index.ts`
- Modify: `packages/adapter-cursor/package.json`
- Modify: `packages/adapter-hermes/src/index.ts`
- Modify: `packages/adapter-hermes/package.json`
**Steps:** Same pattern — add `@uncaged/workflow` dep, update imports.
### Task 11: Update CLI package
**Objective:** Update CLI to import workflow types from `@uncaged/workflow`.
**Files:**
- Modify: `packages/cli/package.json`
- Modify: `packages/cli/src/commands/workflow.ts`
- Modify: `packages/cli/src/commands/thread.ts`
- Modify: `packages/cli/src/commands/create.ts`
- Modify: `packages/cli/src/workflow-agent-validation.ts`
- Modify: other files that import workflow types from nerve-core
**Steps:**
1. Add `"@uncaged/workflow": "workspace:*"` to `packages/cli/package.json`.
2. In each file, change workflow-related imports from `@uncaged/nerve-core` to `@uncaged/workflow`.
3. Build + test:
```bash
pnpm run build && pnpm test
```
4. Commit:
```bash
git commit -am "refactor(cli): import workflow types from @uncaged/workflow
Refs #320"
```
---
## Phase 6: Clean Up Core Re-exports
### Task 12: Remove workflow re-exports from `@uncaged/nerve-core`
**Objective:** Core no longer re-exports any workflow types. All consumers import directly from `@uncaged/workflow`.
**Files:**
- Modify: `packages/core/src/index.ts` — remove all workflow re-exports
- Modify: `packages/core/package.json` — remove `@uncaged/workflow` dependency (core no longer needs it)
**Steps:**
1. Remove all workflow-related `export` lines from `packages/core/src/index.ts`.
2. Remove `@uncaged/workflow` from core's dependencies.
3. Full build + full test:
```bash
pnpm run build && pnpm test
```
4. Run biome check:
```bash
pnpm run check
```
5. Commit:
```bash
git commit -am "refactor(core): remove workflow re-exports, clean break
Fixes #320"
```
---
## Phase 7: Verify & PR
### Task 13: Final verification
**Objective:** Full workspace build, all tests pass, biome clean.
**Steps:**
1. Clean build:
```bash
pnpm run build
```
2. Full tests:
```bash
pnpm test
```
3. Biome:
```bash
pnpm run check
```
4. Verify monorepo structure matches issue spec:
```
packages/
core/ # @uncaged/nerve-core — sense types, config (no workflow)
workflow/ # @uncaged/workflow — standalone orchestration engine
workflow-utils/ # helper roles, extract layer
workflow-meta/ # meta-workflows
daemon/ # @uncaged/nerve-daemon — sense engine + workflow integration
cli/ # @uncaged/nerve-cli
```
5. Create PR:
```bash
tea pr create --title "refactor: extract workflow engine into @uncaged/workflow" \
--description "## What
Extract workflow engine into standalone @uncaged/workflow package.
## Why
Workflow engine is now independent of sense observation — can be used standalone.
## Changes
- packages/workflow/ — new package with types, IPC, worker, manager
- packages/core/ — removed workflow types, no longer re-exports them
- packages/daemon/ — imports workflow runtime from @uncaged/workflow
- packages/cli/ — imports workflow types from @uncaged/workflow
- packages/workflow-utils/ — depends on @uncaged/workflow
- packages/workflow-meta/ — depends on @uncaged/workflow
- packages/adapter-*/ — depends on @uncaged/workflow
## Ref
Fixes #320"
```
---
## Pitfalls & Notes
1. **Worker spawn path**: `workflow-worker.ts` is spawned as a child process via `node`. After moving to `@uncaged/workflow`, the daemon needs to resolve the worker entry point from the package (e.g. `require.resolve("@uncaged/workflow/worker")`). This likely requires a separate export in `package.json` exports map.
2. **Dynamic import exception**: `workflow-worker.ts` uses dynamic `import()` for user workflow modules — this exception carries over to the new package. Add comment per CLAUDE.md conventions.
3. **IPC split**: The `ipc.ts` in daemon has both sense and workflow messages in one file with shared validation. The split needs careful handling of the `ParentToWorkerMessage` union type and `parseParentToWorkerMessage` function.
4. **No backward compat**: Per user preference, no deprecated re-exports — straight breaking change. Phase 6 removes re-exports from core entirely.
5. **`workflow-utils` may still need `nerve-core`**: If workflow-utils imports non-workflow types (like `Schema`, `ExtractFn`, `ExtractError`) from core, it will need both deps. Check carefully.
6. **Test files**: Many test files in daemon import workflow types. They need updating in Phase 5 alongside the source files.
+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> {
+8 -1
View File
@@ -4,9 +4,16 @@
"engines": {
"node": ">=22.5.0"
},
"pnpm": {
"overrides": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*"
}
},
"scripts": {
"prepare": "husky",
"build": "pnpm -r run build",
"build": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build && pnpm -r --filter '!@uncaged/nerve-core' --filter '!@uncaged/nerve-store' --filter '!@uncaged/workflow' run build",
"test": "pnpm -r test",
"check": "biome check .",
"format": "biome format --write .",
"link:dev": "bash scripts/link-dev.sh"
+2 -1
View File
@@ -14,7 +14,8 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*"
"@uncaged/nerve-core": "workspace:*",
"@uncaged/workflow": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
+2 -1
View File
@@ -1,5 +1,6 @@
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import type { AgentConfig } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
export type CursorAgentMode = "plan" | "ask" | "default";
+2 -1
View File
@@ -14,7 +14,8 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*"
"@uncaged/nerve-core": "workspace:*",
"@uncaged/workflow": "workspace:*"
},
"devDependencies": {
"@rslib/core": "^0.21.3",
+2 -1
View File
@@ -1,5 +1,6 @@
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
import type { AgentConfig } from "@uncaged/nerve-core";
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
/**
* Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only
+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
+3 -2
View File
@@ -10,19 +10,20 @@
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"files": ["dist", "skills"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-daemon run build",
"pretest": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build && pnpm --filter @uncaged/nerve-daemon run build",
"test": "vitest run"
},
"dependencies": {
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*",
"@uncaged/workflow": "workspace:*",
"citty": "^0.1.6",
"picomatch": "^4.0.2",
"yaml": "^2.8.3"
+547
View File
@@ -0,0 +1,547 @@
# Nerve — AI Agent 观测引擎
Nerve 是一个轻量级观测引擎守护进程。它持续观测外部状态,通过声明式规则响应变化,编排多步骤工作流。
## 核心架构
```
External World → Sense(state) → { newState, workflow? } → Workflow → Log
```
| 概念 | 说明 |
|------|------|
| **Sense** | 有状态的 `compute(state)` 函数。返回新状态和可选的 workflow trigger。状态以 JSON 文件持久化。调度由 nerve.yaml 配置。 |
| **Workflow** | 有状态的多步骤执行。包含 Role(有副作用的执行者)和 Moderator(纯路由器)。每个实例是一个 Thread,有唯一 runId。 |
| **Log** | 不可变审计日志。记录执行、状态转换、错误。不能触发 Sense(防止反馈循环)。 |
| **Engine** | 内核,持有 Process Manager、Workflow Manager、Sense Scheduler。不直接加载用户代码。 |
| **Daemon** | 引擎运行时,作为后台进程运行。 |
**关键规则:**
- 因果链单向:External → Sense(state) → Workflow(触发时) + Log
- 进程隔离:每个 Sense group 一个 worker(长期),每个 Workflow 类型一个 worker(按需)
- 两个扩展点:Sense(观测什么 + 何时)、Workflow(做什么)
## 工作区结构
`nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件。
```
~/.uncaged-nerve/
├── AGENT.md # 人类 / Agent 可读的工作区约定(init 生成)
├── nerve.yaml # 核心配置
├── package.json # 单一根包(sense/workflow 下不再有独立 package)
├── scripts/build.mjs # 根目录 esbuild;通过 npm/pnpm 的 build 脚本调用
├── senses/
│ └── <name>/
│ └── src/index.ts # exports compute(state) + initialState
├── workflows/
│ └── <name>/
│ ├── index.ts # default export:WorkflowDefinition
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
│ └── roles/
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
└── data/
├── senses/ # sense 状态 JSON 文件(自动生成)
└── logs.db # 日志存储(自动生成)
```
### 命名约定
- **Workflow**:动词开头的 kebab-case(例如 `review-pull-request``deploy-staging`)。避免单独名词式命名(如 `notifications`)。
- **Sense**:描述性名词 kebab-case(例如 `cpu-usage`)。
---
## CLI 完整参考
全局选项:`--host <host:port>`(连接远程 daemon)、`--api-token <secret>`(Bearer 认证)
### 初始化与脚手架
```bash
nerve init # 初始化工作区
nerve init --from <git-url> # 从 git 仓库克隆工作区
nerve init workspace # 只初始化工作区结构
nerve create sense <name> # 创建 sense 脚手架
nerve create sense <name> --force # 覆盖已有
nerve create workflow <name> # 创建 workflow 脚手架
nerve create workflow <name> --force
nerve validate # 验证 nerve.yaml 配置
```
### Daemon 管理
```bash
nerve daemon start # 启动后台 daemon
nerve daemon start --port 3000 # 指定 HTTP API 端口
nerve daemon stop # 停止 daemon
nerve daemon restart # 重启
nerve daemon status # 查看状态
nerve daemon logs # 查看日志
nerve daemon logs --follow # 实时日志
nerve daemon logs --n 50 # 最近 50 行
nerve dev # 前台开发模式(不 fork daemon)
nerve dev --port 3000 # 指定端口
```
### Sense 操作
```bash
nerve sense list # 列出所有注册的 sense
nerve sense trigger <name> # 手动触发 sense 计算
```
### Workflow 操作
```bash
nerve workflow list # 列出 nerve.yaml 中定义的 workflow
nerve workflow status # 查看运行中的 workflow 状态
nerve workflow trigger <name> # 触发 workflow
nerve workflow trigger <name> --prompt "检查生产环境"
nerve workflow trigger <name> --maxRounds 50
nerve workflow trigger <name> --dryRun # 干跑模式
```
### Thread(Workflow 执行记录)
```bash
nerve thread list # 列出最近的 workflow 执行
nerve thread list --all # 包含已完成/失败的
nerve thread list --workflow <name> # 按 workflow 过滤
nerve thread list --limit 50 # 最多 50 条
nerve thread show <runId> # 查看 role 对话轮次
nerve thread show <runId> --budget 16000 # 增大输出预算(默认 8000 字符)
nerve thread inspect <runId> # 查看详情和事件
nerve thread kill <runId> # 终止运行中/排队中的 thread
```
### Store(日志归档)
```bash
nerve store archive # 导出旧日志到 JSONL 归档
nerve store archive --vacuum # 归档后 VACUUM 数据库
```
### Knowledge(知识库)
```bash
nerve knowledge sync # 从 knowledge.yaml 重建索引
nerve knowledge query "搜索内容" # 搜索知识库
nerve knowledge query "内容" --limit 5
nerve knowledge query "内容" -g # 搜索所有注册仓库
```
### Remote(远程 daemon)
```bash
nerve remote add <name> <host:port> --token <secret>
nerve remote list
nerve remote show <name>
nerve remote set-url <name> <host>
nerve remote set-token <name> <token>
nerve remote remove <name>
nerve remote default <name> # 设为默认远程
```
### Agent(向 AI Agent 注入 nerve skill)
```bash
nerve agent status # CLI 版本与各注入目录中的 skill 版本
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
nerve agent inject cursor # 注入到当前项目 .cursorrules
nerve agent inject claude # 注入到 ~/.claude/CLAUDE.md
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
nerve agent remove hermes # 移除 Hermes 注入
nerve agent remove cursor # 移除 Cursor 注入
nerve agent remove claude # 移除 Claude Code 注入
```
---
## nerve.yaml 配置参考
```yaml
# 引擎全局配置
max_rounds: 100 # moderator 最大轮次(默认 100)
# Sense 配置
senses:
cpu-usage:
group: system # 必填,同 group 的 sense 共享 worker
interval: 10s # 轮询间隔(duration: 5s, 10m, 1h)
throttle: 5s # 最小计算间隔
timeout: 10s # compute 超时
grace_period: null # 超时后优雅等待
system-health:
group: derived
on: [cpu-usage, disk-usage] # 响应式:被列出的 sense 完成 compute 时触发
throttle: null
timeout: null
# Workflow 配置
workflows:
my-workflow:
concurrency: 1 # 必填,并发数
overflow: drop # 必填,超并发时处理:drop | queue
max_queue: 100 # overflow=queue 时的队列上限(默认 100)
# HTTP API
api:
port: 3000 # null = 不启用 HTTP
host: "127.0.0.1" # 监听地址
token: null # 非 loopback 时必填
# LLM Extract(可选)
extract:
provider: anthropic
model: claude-sonnet-4-20250514
```
---
## Sense 开发指南
### compute 函数签名
Sense 的 `compute` 接收当前状态,返回新状态和可选的 shell trigger(`{ command: string }`)。状态以 JSON 文件持久化在 `data/senses/<name>.json`。Workflow 只能通过 CLI / daemon IPC 启动,不能从 sense 返回值直接启动。
```typescript
import type { SenseComputeFn } from "@uncaged/nerve-core";
type MyState = {
lastRun: number | null;
count: number;
};
export const initialState: MyState = { lastRun: null, count: 0 };
export async function compute(state: MyState): Promise<{
state: MyState;
trigger: { command: string } | null;
}> {
return {
state: { lastRun: Date.now(), count: state.count + 1 },
trigger: null,
};
}
```
### 状态持久化
| 时机 | 行为 |
|------|------|
| Worker 启动 | 读取 `data/senses/<name>.json`;不存在或损坏则使用 `initialState` |
| Compute 成功 | 原子写入新状态(temp + rename),然后更新内存 |
| Compute 失败 | 状态不变(磁盘和内存都不变) |
| Daemon 重启 | 从上次成功写入恢复 |
### 返回值
```typescript
// trigger: null → 不执行 shell 命令
// trigger: { command } → sense worker 在成功的 compute 后以 shell 执行该命令(cwd = nerve 根目录)
// 启动 workflow:在 shell 中调用 `nerve workflow trigger ...`,或使用 daemon IPC / HTTP API
```
### Sense 模块导出
每个 sense 的 `src/index.ts` 必须导出两个东西:
```typescript
// senses/<name>/src/index.ts
// 1. 初始状态
export const initialState: MyState = { ... };
// 2. compute 函数
export async function compute(state: MyState): Promise<{
state: MyState;
trigger: { command: string } | null;
}> {
// ...
}
```
### 调度方式
1. **interval 轮询**`interval: 10s` — 每 10 秒执行一次
2. **响应式触发**`on: [cpu-usage]` — 当 cpu-usage 完成 compute 后触发
3. 两者可以组合
### 调试
```bash
nerve dev # 前台运行,看实时输出
nerve sense trigger <name> # 手动触发一次
```
### 完整示例:CPU 监控
```typescript
// senses/cpu-usage/src/index.ts
import { loadavg } from "node:os";
type CpuState = {
samples: Array<{ ts: number; value: number }>;
};
export const initialState: CpuState = { samples: [] };
export async function compute(state: CpuState): Promise<{
state: CpuState;
trigger: null;
}> {
const [oneMin] = loadavg();
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
return { state: { samples: newSamples }, trigger: null };
}
```
nerve.yaml:
```yaml
senses:
cpu-usage:
group: system
interval: 10s
throttle: 5s
timeout: 10s
```
---
## Workflow 开发指南
### 核心类型
```typescript
import type {
WorkflowDefinition,
RoleResult,
ThreadContext,
RoleMeta,
Moderator,
} from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
// Role<Meta> — (ctx: ThreadContext) => Promise<RoleResult<Meta>>
// RoleResult<Meta> — { content: string; meta: Meta }
// ThreadContext<M extends RoleMeta> — threadId, start(__start__ 帧), steps(各 role 轮次)
// Moderator<M> — (ctx) => 下一个 role 名 | END
// WorkflowDefinition<M extends RoleMeta> — name, roles, moderator
```
### createRole 四元组(接入 LLM 时推荐)
工作区根目录需安装 **`@uncaged/nerve-workflow-utils`**(及所选 agent 适配器包)。默认 `nerve init``package.json` 不含该依赖时,在 `~/.uncaged-nerve` 下执行 `pnpm add @uncaged/nerve-workflow-utils`(或 npm 等价命令)。
使用 **`createRole`**,按固定顺序传入四件事:
1. **adapter**`AgentFn``(ctx, systemPrompt) => Promise<string>`(原始模型输出文本)。
2. **prompt**`string`,或 `async (ctx: ThreadContext) => string`
3. **meta**`z.ZodType<M>`,供 moderator 路由的结构化 meta。
4. **extract**`{ provider: LlmProvider; dryRun: boolean | null }`,声明从回复中抽取 meta 时用的 LLM(OpenAI 兼容)及是否 dry-run。
```typescript
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
import type { ThreadContext } from "@uncaged/nerve-core";
import { z } from "zod";
const provider = {
baseUrl: "https://api.example.com/v1",
apiKey: process.env.EXAMPLE_API_KEY!,
model: "gpt-4o-mini",
};
const planMeta = z.object({ next: z.enum(["execute", "stop"]) });
export const planner = createRole(
createLlmAdapter(provider),
async (ctx: ThreadContext) => `规划任务:${ctx.start.content}`,
planMeta,
{ provider, dryRun: null },
);
```
`createLlmAdapter` 仅位于 **`@uncaged/nerve-workflow-utils`**:用 `LlmProvider` 生成 `AgentFn`,单轮对话里 **system** 来自 `createRole` 解析后的 prompt 字符串,**user** 为线程起点 `ctx.start.content`
### 基本 Workflow 示例(平铺 `roles/<role>.ts`)
```typescript
// workflows/example/roles/main.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
const prompt = ctx.start.content;
return {
content: `处理完成: ${prompt}`,
meta: { round: ctx.steps.length },
};
}
```
```typescript
// workflows/example/index.ts
import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { main } from "./roles/main.js";
type Meta = Record<"main", { round: number }>;
const workflow: WorkflowDefinition<Meta> = {
name: "example",
roles: { main },
moderator(ctx: ThreadContext<Meta>) {
return ctx.steps.length === 0 ? "main" : END;
},
};
export default workflow;
```
可选:将 `moderator` 挪到 `moderator.ts``import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`
### 多 Role Workflow 示例
```typescript
// workflows/plan-execute-review/roles/planner.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "计划: ...", meta: { status: "planned" } };
}
```
```typescript
// workflows/plan-execute-review/roles/executor.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "执行: ...", meta: { status: "executed" } };
}
```
```typescript
// workflows/plan-execute-review/roles/reviewer.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "审核通过", meta: { status: "approved" } };
}
```
```typescript
// workflows/plan-execute-review/index.ts
import type { WorkflowDefinition, ThreadContext } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { executor } from "./roles/executor.js";
import { planner } from "./roles/planner.js";
import { reviewer } from "./roles/reviewer.js";
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
const workflow: WorkflowDefinition<Roles> = {
name: "plan-execute-review",
roles: { planner, executor, reviewer },
moderator(ctx: ThreadContext<Roles>) {
if (ctx.steps.length === 0) return "planner";
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") return "executor";
if (last.role === "executor") return "reviewer";
return END;
},
};
export default workflow;
```
### Agent 适配器
Workflow role 可以集成 AI agent。已知适配器 **ID**`echo``cursor``hermes``codex`
```typescript
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
```
没有现成 agent 包时,用 **`createLlmAdapter``@uncaged/nerve-workflow-utils`)** 从 OpenAI 兼容的 `LlmProvider` 构造 `AgentFn`,再交给 **`createRole`** 的四元组。
### Workflow 运行状态
`queued``started``completed` | `failed` | `crashed` | `killed` | `interrupted` | `dropped`
---
## 日常操作 Pattern
### 查看系统整体状态
```bash
nerve daemon status # daemon 是否在运行
nerve sense list # 所有 sense 及其调度配置
nerve workflow status # 运行中的 workflow
nerve thread list # 最近的 workflow 执行记录
```
### 手动触发 workflow
```bash
nerve workflow trigger my-workflow --prompt "手动检查"
nerve thread list --workflow my-workflow # 查看执行状态
nerve thread show <runId> # 查看对话详情
```
### 排查 sense 报错
```bash
nerve daemon logs --follow # 查看实时日志
nerve sense trigger <name> # 手动触发看报错
nerve dev # 前台模式,更详细的输出
```
### 开发新 sense
```bash
nerve create sense my-sensor # 脚手架
# 编辑 senses/my-sensor/src/index.ts — 实现 compute(state) + initialState
nerve validate # 验证配置
nerve dev # 前台测试
nerve sense trigger my-sensor # 单次触发验证
```
### 开发新 workflow
```bash
nerve create workflow my-flow # 脚手架
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
nerve validate # 验证配置
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建;勿在单个 workflow 子目录单独跑 build
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
nerve thread show <runId> # 查看执行轨迹
```
---
## Pitfalls
- **Sense 状态**:`compute(state)` 必须返回 `{ state, workflow }` 对象。不要返回 null 或 undefined。
- **initialState**:每个 sense 必须导出 `initialState`,否则加载失败。
- **状态持久化**:daemon 自动管理状态读写,业务代码不要自行读写 state.json。
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
+572
View File
@@ -0,0 +1,572 @@
<!-- nerve-cli-version: __NERVE_CLI_VERSION__ -->
## Cursor Agent 使用提示
在 Cursor 中与 Agent 对话时,可以用以下方式指代代码与配置:
- **`@Files` / `@file`**:引用单个文件,例如 `@nerve.yaml`、`@senses/cpu-usage/src/index.ts`,减少幻觉并让修改对准正确路径。
- **`@Folder` / `@Codebase`**:需要跨目录理解工作区结构时使用;改动前仍应优先打开相关 sense/workflow 源文件确认。
- **`@Terminal`**:把 CLI 输出纳入上下文,便于对照 `nerve daemon logs`、`nerve sense query` 等结果。
- **`@Docs`**:若项目或依赖有文档索引,可用来对齐 API 与约定。
- 工作区根目录下的 **`nerve.yaml`**、`senses/`、`workflows/` 是 nerve 的核心入口;讨论调度与配置时优先 `@` 这些路径。
- 本规则由 `nerve agent inject cursor` 安装;更新 CLI 后在同一目录再次执行可覆盖为新版。
---
# Nerve — AI Agent 观测引擎
Nerve 是一个轻量级观测引擎守护进程。它持续观测外部状态,通过声明式规则响应变化,编排多步骤工作流。
## 核心架构
```
External World → Sense → Signal → Workflow → Log
```
| 概念 | 说明 |
|------|------|
| **Sense** | 观测函数,`compute()` 采样或推导数据。返回非 null 则发出 Signal,可选触发 Workflow。每个 Sense 有独立 SQLite 数据库。 |
| **Signal** | Sense 返回非 null 时发出的通知。纯事实,无意图。通过内存 Signal Bus 分发,不持久化。 |
| **Workflow** | 有状态的多步骤执行。包含 Role(有副作用的执行者)和 Moderator(纯路由器)。每个实例是一个 Thread,有唯一 runId。 |
| **Log** | 不可变审计日志。记录执行、状态转换、错误。不能触发 Sense(防止反馈循环)。 |
| **Engine** | 内核,持有 Signal Bus、Process Manager、Workflow Manager。不直接加载用户代码。 |
| **Daemon** | 引擎运行时,作为后台进程运行。 |
**关键规则:**
- 因果链单向:External → Sense → Signal → Workflow + Log
- 进程隔离:每个 Sense group 一个 worker(长期),每个 Workflow 类型一个 worker(按需)
- 两个扩展点:Sense(观测什么 + 何时)、Workflow(做什么)
## 工作区结构
由 `nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件:它与本文 skill 对齐,约定目录布局、`createRole` 用法以及**始终在仓库根目录**执行的构建命令。
```
~/.uncaged-nerve/
├── AGENT.md # 人类 / Agent 可读的工作区约定(init 生成)
├── nerve.yaml # 核心配置
├── package.json # 单一根包(sense/workflow 下不再有独立 package)
├── scripts/build.mjs # 根目录 esbuild;通过 npm/pnpm 的 build 脚本调用
├── senses/
│ └── <name>/
│ ├── src/index.ts # exports compute() + table
│ ├── src/schema.ts # Drizzle 表定义
│ └── migrations/ # SQL 迁移
├── workflows/
│ └── <name>/
│ ├── index.ts # default export:WorkflowDefinition
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
│ └── roles/
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
└── data/ # 运行时数据(SQLite、blobs)
```
### 命名约定
- **Workflow**:动词开头的 kebab-case(例如 `review-pull-request`、`deploy-staging`)。避免单独名词式命名(如 `notifications`)。
- **Sense**:描述性名词 kebab-case(例如 `cpu-usage`)。
---
## CLI 完整参考
全局选项:`--host <host:port>`(连接远程 daemon)、`--api-token <secret>`(Bearer 认证)
### 初始化与脚手架
```bash
nerve init # 初始化工作区
nerve init --from <git-url> # 从 git 仓库克隆工作区
nerve init workspace # 只初始化工作区结构
nerve create sense <name> # 创建 sense 脚手架
nerve create sense <name> --force # 覆盖已有
nerve create workflow <name> # 创建 workflow 脚手架
nerve create workflow <name> --force
nerve validate # 验证 nerve.yaml 配置
```
### Daemon 管理
```bash
nerve daemon start # 启动后台 daemon
nerve daemon start --port 3000 # 指定 HTTP API 端口
nerve daemon stop # 停止 daemon
nerve daemon restart # 重启
nerve daemon status # 查看状态
nerve daemon logs # 查看日志
nerve daemon logs --follow # 实时日志
nerve daemon logs --n 50 # 最近 50 行
nerve dev # 前台开发模式(不 fork daemon)
nerve dev --port 3000 # 指定端口
```
### Sense 操作
```bash
nerve sense list # 列出所有注册的 sense
nerve sense trigger <name> # 手动触发 sense 计算
nerve sense schema <name> # 查看 sense 数据库表结构
nerve sense schema <name> --json # JSON 格式
nerve sense query <name> <sql> # 对 sense 数据库执行只读 SQL
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
```
### Workflow 操作
```bash
nerve workflow list # 列出 nerve.yaml 中定义的 workflow
nerve workflow status # 查看运行中的 workflow 状态
nerve workflow trigger <name> # 触发 workflow
nerve workflow trigger <name> --prompt "检查生产环境"
nerve workflow trigger <name> --maxRounds 50
nerve workflow trigger <name> --dryRun # 干跑模式
```
### Thread(Workflow 执行记录)
```bash
nerve thread list # 列出最近的 workflow 执行
nerve thread list --all # 包含已完成/失败的
nerve thread list --workflow <name> # 按 workflow 过滤
nerve thread list --limit 50 # 最多 50 条
nerve thread show <runId> # 查看 role 对话轮次
nerve thread show <runId> --budget 16000 # 增大输出预算(默认 8000 字符)
nerve thread inspect <runId> # 查看详情和事件
nerve thread kill <runId> # 终止运行中/排队中的 thread
```
### Store(日志归档)
```bash
nerve store archive # 导出旧日志到 JSONL 归档
nerve store archive --vacuum # 归档后 VACUUM 数据库
```
### Knowledge(知识库)
```bash
nerve knowledge sync # 从 knowledge.yaml 重建索引
nerve knowledge query "搜索内容" # 搜索知识库
nerve knowledge query "内容" --limit 5
nerve knowledge query "内容" -g # 搜索所有注册仓库
```
### Remote(远程 daemon)
```bash
nerve remote add <name> <host:port> --token <secret>
nerve remote list
nerve remote show <name>
nerve remote set-url <name> <host>
nerve remote set-token <name> <token>
nerve remote remove <name>
nerve remote default <name> # 设为默认远程
```
### Agent(向 Hermes 注入本 skill)
```bash
nerve agent status # CLI 版本与各 Hermes 注入目录中的 skill 版本
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
nerve agent remove hermes # 移除默认 profile 的注入
nerve agent remove hermes --profile <name>
nerve agent inject cursor # 在 cwd 生成 .cursorrules
nerve agent inject cursor --path /foo # 在指定目录生成
nerve agent remove cursor [--path /foo]
```
---
## nerve.yaml 配置参考
```yaml
# 引擎全局配置
max_rounds: 100 # moderator 最大轮次(默认 100)
# Sense 配置
senses:
cpu-usage:
group: system # 必填,同 group 的 sense 共享 worker
interval: 10s # 轮询间隔(duration: 5s, 10m, 1h)
throttle: 5s # 最小计算间隔
timeout: 10s # compute 超时
grace_period: null # 优雅关闭等待
retention: 10000 # _signals 表最大行数(默认 10000)
system-health:
group: derived
on: [cpu-usage, disk-usage] # 响应式:被列出的 sense 发出 signal 时触发
throttle: null
timeout: null
# Workflow 配置
workflows:
my-workflow:
concurrency: 1 # 必填,并发数
overflow: drop # 必填,超并发时处理:drop | queue
max_queue: 100 # overflow=queue 时的队列上限(默认 100)
# HTTP API
api:
port: 3000 # null = 不启用 HTTP
host: "127.0.0.1" # 监听地址
token: null # 非 loopback 时必填
# LLM Extract(可选)
extract:
provider: anthropic
model: claude-sonnet-4-20250514
```
---
## Sense 开发指南
### compute 函数签名
Sense 的 `compute` **无参数**。它不接收数据库句柄:daemon 在 worker 内调用 `SenseComputeFn`,由运行时负责把非 null 结果的 `signal` 写入该 sense 的 Drizzle 表并记入 `_signals`。超时由运行时控制(对应 `nerve.yaml` 里的 `timeout`),无需在业务代码里读取 `AbortSignal`。
```typescript
import type { ComputeResult, SenseComputeFn } from "@uncaged/nerve-core";
export const compute: SenseComputeFn<MySignalShape> = async () => {
// ...
};
// 或等价地:
export async function compute(): Promise<ComputeResult<MySignalShape>> {
// ...
}
```
(运行时定义见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseModule`,daemon 侧在 `sense-runtime.ts` 的 `executeCompute` 中插入 `result.signal`。)
### 返回值
当前引擎:`compute(state)` 返回 `{ state, trigger }`,`trigger` 为 `null` 或 `{ command: string }`(shell 命令)。Workflow 仅通过 CLI / daemon IPC 启动;类型见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseTrigger`。
### Sense 模块导出
```typescript
// senses/<name>/src/index.ts
import type { ComputeResult } from "@uncaged/nerve-core";
import { table } from "./schema.js";
type Row = { ts: number; value: number };
export async function compute(): Promise<ComputeResult<Row>> {
const row: Row = { ts: Date.now(), value: Math.random() }; // 替换为真实观测逻辑
return { signal: row, workflow: null };
}
export { table };
```
### Schema 定义
```typescript
// senses/<name>/src/schema.ts
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("samples", {
ts: integer("ts").notNull(),
value: real("value").notNull(),
});
```
### 调度方式
1. **interval 轮询**:`interval: 10s` — 每 10 秒执行一次
2. **响应式触发**:`on: [cpu-usage]` — 当 cpu-usage 发出 signal 时触发
3. 两者可以组合
### 调试
```bash
nerve dev # 前台运行,看实时输出
nerve sense trigger <name> # 手动触发一次
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 5"
```
### 完整示例:CPU 监控
```typescript
// senses/cpu-usage/src/schema.ts
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("samples", {
ts: integer("ts").notNull(),
value: real("value").notNull(),
});
// senses/cpu-usage/src/index.ts
import os from "node:os";
import type { ComputeResult } from "@uncaged/nerve-core";
import { table } from "./schema.js";
type Row = { ts: number; value: number };
export async function compute(): Promise<ComputeResult<Row>> {
const oneMin = os.loadavg()[0];
return { signal: { ts: Date.now(), value: oneMin }, workflow: null };
}
export { table };
```
nerve.yaml:
```yaml
senses:
cpu-usage:
group: system
interval: 10s
throttle: 5s
timeout: 10s
retention: 10000
```
---
## Workflow 开发指南
### 核心类型
```typescript
import type {
WorkflowDefinition,
RoleResult,
ThreadContext,
RoleMeta,
Moderator,
} from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
// Role<Meta> — (ctx: ThreadContext) => Promise<RoleResult<Meta>>
// RoleResult<Meta> — { content: string; meta: Meta }
// ThreadContext<M extends RoleMeta> — threadId, start(__start__ 帧), steps(各 role 轮次)
// Moderator<M> — (ctx) => 下一个 role 名 | END
// WorkflowDefinition<M extends RoleMeta> — name, roles, moderator
```
### createRole 四元组(接入 LLM 时推荐)
工作区根目录需安装 **`@uncaged/nerve-workflow-utils`**(及所选 agent 适配器包)。默认 `nerve init` 的 `package.json` 不含该依赖时,在 `~/.uncaged-nerve` 下执行 `pnpm add @uncaged/nerve-workflow-utils`(或 npm 等价命令)。
使用 **`createRole`**,按固定顺序传入四件事:
1. **adapter** — `AgentFn`,`(ctx, systemPrompt) => Promise<string>`(原始模型输出文本)。
2. **prompt** — `string`,或 `async (ctx: ThreadContext) => string`。
3. **meta** — `z.ZodType<M>`,供 moderator 路由的结构化 meta。
4. **extract** — `{ provider: LlmProvider; dryRun: boolean | null }`,声明从回复中抽取 meta 时用的 LLM(OpenAI 兼容)及是否 dry-run。
```typescript
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
import type { ThreadContext } from "@uncaged/nerve-core";
import { z } from "zod";
const provider = {
baseUrl: "https://api.example.com/v1",
apiKey: process.env.EXAMPLE_API_KEY!,
model: "gpt-4o-mini",
};
const planMeta = z.object({ next: z.enum(["execute", "stop"]) });
export const planner = createRole(
createLlmAdapter(provider),
async (ctx: ThreadContext) => `规划任务:${ctx.start.content}`,
planMeta,
{ provider, dryRun: null },
);
```
`createLlmAdapter` 仅位于 **`@uncaged/nerve-workflow-utils`**:用 `LlmProvider` 生成 `AgentFn`,单轮对话里 **system** 来自 `createRole` 解析后的 prompt 字符串,**user** 为线程起点 `ctx.start.content`。
### 基本 Workflow 示例(平铺 `roles/<role>.ts`)
```typescript
// workflows/example/roles/main.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
const prompt = ctx.start.content;
return {
content: `处理完成: ${prompt}`,
meta: { round: ctx.steps.length },
};
}
```
```typescript
// workflows/example/index.ts
import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { main } from "./roles/main.js";
type Meta = Record<"main", { round: number }>;
const workflow: WorkflowDefinition<Meta> = {
name: "example",
roles: { main },
moderator(ctx: ThreadContext<Meta>) {
return ctx.steps.length === 0 ? "main" : END;
},
};
export default workflow;
```
可选:将 `moderator` 挪到 `moderator.ts` 再 `import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`。
### 多 Role Workflow 示例
```typescript
// workflows/plan-execute-review/roles/planner.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "计划: ...", meta: { status: "planned" } };
}
```
```typescript
// workflows/plan-execute-review/roles/executor.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "执行: ...", meta: { status: "executed" } };
}
```
```typescript
// workflows/plan-execute-review/roles/reviewer.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "审核通过", meta: { status: "approved" } };
}
```
```typescript
// workflows/plan-execute-review/index.ts
import type { WorkflowDefinition, ThreadContext } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { executor } from "./roles/executor.js";
import { planner } from "./roles/planner.js";
import { reviewer } from "./roles/reviewer.js";
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
const workflow: WorkflowDefinition<Roles> = {
name: "plan-execute-review",
roles: { planner, executor, reviewer },
moderator(ctx: ThreadContext<Roles>) {
if (ctx.steps.length === 0) return "planner";
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") return "executor";
if (last.role === "executor") return "reviewer";
return END;
},
};
export default workflow;
```
### Agent 适配器
Workflow role 可以集成 AI agent。已知适配器 **ID**:`echo`、`cursor`、`hermes`、`codex`。
```typescript
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
```
没有现成 agent 包时,用 **`createLlmAdapter`(`@uncaged/nerve-workflow-utils`)** 从 OpenAI 兼容的 `LlmProvider` 构造 `AgentFn`,再交给 **`createRole`** 的四元组。
### Workflow 运行状态
`queued` → `started` → `completed` | `failed` | `crashed` | `killed` | `interrupted` | `dropped`
---
## 日常操作 Pattern
### 查看系统整体状态
```bash
nerve daemon status # daemon 是否在运行
nerve sense list # 所有 sense 及其调度配置
nerve workflow status # 运行中的 workflow
nerve thread list # 最近的 workflow 执行记录
```
### 检查某个 sense 的历史数据
```bash
nerve sense query cpu-usage "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
nerve sense schema cpu-usage # 查看表结构
```
### 手动触发 workflow
```bash
nerve workflow trigger my-workflow --prompt "手动检查"
nerve thread list --workflow my-workflow # 查看执行状态
nerve thread show <runId> # 查看对话详情
```
### 排查 sense 报错
```bash
nerve daemon logs --follow # 查看实时日志
nerve sense trigger <name> # 手动触发看报错
nerve dev # 前台模式,更详细的输出
```
### 开发新 sense
```bash
nerve create sense my-sensor # 脚手架
# 编辑 senses/my-sensor/src/index.ts 和 schema.ts
nerve validate # 验证配置
nerve dev # 前台测试
nerve sense trigger my-sensor # 单次触发验证
nerve sense query my-sensor "SELECT * FROM ..." # 检查数据
```
### 开发新 workflow
```bash
nerve create workflow my-flow # 脚手架(当前 CLI 可能仍生成 roles/<name>/ 子目录)
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
nerve validate # 验证配置
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建(等价:pnpm run build);勿在单个 workflow 子目录单独跑 build
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
nerve thread show <runId> # 查看执行轨迹
```
---
## Pitfalls
- **Sense 返回值**:返回 `null` 表示静默(不发 signal);返回 `{ signal, workflow }` 才发 signal。不要返回 undefined。
- **Sense 持久化**:daemon 在 `compute()` 返回非 null 时自动执行 `db.insert(table).values(signal)` 并写入 `_signals`;业务代码不要自行 insert。
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`。
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
- **_signals 表**:每个 sense 自动有 `_signals` 表记录 signal 历史,受 `retention` 配置限制。
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
+565
View File
@@ -0,0 +1,565 @@
---
name: nerve
version: 0.5.0
description: >
Nerve — AI agent 观测引擎。掌握 nerve 的核心概念、CLI 操作、sense/workflow 开发。
加载此 skill 后你可以:查看系统状态、监控 sense、触发 workflow、开发新 sense 和 workflow。
metadata:
hermes:
tags: [nerve, sense, workflow, monitoring, agent-kernel]
homepage: https://git.shazhou.work/uncaged/nerve
---
# Nerve — AI Agent 观测引擎
Nerve 是一个轻量级观测引擎守护进程。它持续观测外部状态,通过声明式规则响应变化,编排多步骤工作流。
## 核心架构
```
External World → Sense → Signal → Workflow → Log
```
| 概念 | 说明 |
|------|------|
| **Sense** | 观测函数,`compute()` 采样或推导数据。返回非 null 则发出 Signal,可选触发 Workflow。每个 Sense 有独立 SQLite 数据库。 |
| **Signal** | Sense 返回非 null 时发出的通知。纯事实,无意图。通过内存 Signal Bus 分发,不持久化。 |
| **Workflow** | 有状态的多步骤执行。包含 Role(有副作用的执行者)和 Moderator(纯路由器)。每个实例是一个 Thread,有唯一 runId。 |
| **Log** | 不可变审计日志。记录执行、状态转换、错误。不能触发 Sense(防止反馈循环)。 |
| **Engine** | 内核,持有 Signal Bus、Process Manager、Workflow Manager。不直接加载用户代码。 |
| **Daemon** | 引擎运行时,作为后台进程运行。 |
**关键规则:**
- 因果链单向:External → Sense → Signal → Workflow + Log
- 进程隔离:每个 Sense group 一个 worker(长期),每个 Workflow 类型一个 worker(按需)
- 两个扩展点:Sense(观测什么 + 何时)、Workflow(做什么)
## 工作区结构
`nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件:它与本文 skill 对齐,约定目录布局、`createRole` 用法以及**始终在仓库根目录**执行的构建命令。
```
~/.uncaged-nerve/
├── AGENT.md # 人类 / Agent 可读的工作区约定(init 生成)
├── nerve.yaml # 核心配置
├── package.json # 单一根包(sense/workflow 下不再有独立 package)
├── scripts/build.mjs # 根目录 esbuild;通过 npm/pnpm 的 build 脚本调用
├── senses/
│ └── <name>/
│ ├── src/index.ts # exports compute() + table
│ ├── src/schema.ts # Drizzle 表定义
│ └── migrations/ # SQL 迁移
├── workflows/
│ └── <name>/
│ ├── index.ts # default export:WorkflowDefinition
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
│ └── roles/
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
└── data/ # 运行时数据(SQLite、blobs)
```
### 命名约定
- **Workflow**:动词开头的 kebab-case(例如 `review-pull-request``deploy-staging`)。避免单独名词式命名(如 `notifications`)。
- **Sense**:描述性名词 kebab-case(例如 `cpu-usage`)。
---
## CLI 完整参考
全局选项:`--host <host:port>`(连接远程 daemon)、`--api-token <secret>`(Bearer 认证)
### 初始化与脚手架
```bash
nerve init # 初始化工作区
nerve init --from <git-url> # 从 git 仓库克隆工作区
nerve init workspace # 只初始化工作区结构
nerve create sense <name> # 创建 sense 脚手架
nerve create sense <name> --force # 覆盖已有
nerve create workflow <name> # 创建 workflow 脚手架
nerve create workflow <name> --force
nerve validate # 验证 nerve.yaml 配置
```
### Daemon 管理
```bash
nerve daemon start # 启动后台 daemon
nerve daemon start --port 3000 # 指定 HTTP API 端口
nerve daemon stop # 停止 daemon
nerve daemon restart # 重启
nerve daemon status # 查看状态
nerve daemon logs # 查看日志
nerve daemon logs --follow # 实时日志
nerve daemon logs --n 50 # 最近 50 行
nerve dev # 前台开发模式(不 fork daemon)
nerve dev --port 3000 # 指定端口
```
### Sense 操作
```bash
nerve sense list # 列出所有注册的 sense
nerve sense trigger <name> # 手动触发 sense 计算
nerve sense schema <name> # 查看 sense 数据库表结构
nerve sense schema <name> --json # JSON 格式
nerve sense query <name> <sql> # 对 sense 数据库执行只读 SQL
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
```
### Workflow 操作
```bash
nerve workflow list # 列出 nerve.yaml 中定义的 workflow
nerve workflow status # 查看运行中的 workflow 状态
nerve workflow trigger <name> # 触发 workflow
nerve workflow trigger <name> --prompt "检查生产环境"
nerve workflow trigger <name> --maxRounds 50
nerve workflow trigger <name> --dryRun # 干跑模式
```
### Thread(Workflow 执行记录)
```bash
nerve thread list # 列出最近的 workflow 执行
nerve thread list --all # 包含已完成/失败的
nerve thread list --workflow <name> # 按 workflow 过滤
nerve thread list --limit 50 # 最多 50 条
nerve thread show <runId> # 查看 role 对话轮次
nerve thread show <runId> --budget 16000 # 增大输出预算(默认 8000 字符)
nerve thread inspect <runId> # 查看详情和事件
nerve thread kill <runId> # 终止运行中/排队中的 thread
```
### Store(日志归档)
```bash
nerve store archive # 导出旧日志到 JSONL 归档
nerve store archive --vacuum # 归档后 VACUUM 数据库
```
### Knowledge(知识库)
```bash
nerve knowledge sync # 从 knowledge.yaml 重建索引
nerve knowledge query "搜索内容" # 搜索知识库
nerve knowledge query "内容" --limit 5
nerve knowledge query "内容" -g # 搜索所有注册仓库
```
### Remote(远程 daemon)
```bash
nerve remote add <name> <host:port> --token <secret>
nerve remote list
nerve remote show <name>
nerve remote set-url <name> <host>
nerve remote set-token <name> <token>
nerve remote remove <name>
nerve remote default <name> # 设为默认远程
```
### Agent(向 Hermes 注入本 skill)
```bash
nerve agent status # CLI 版本与各 Hermes 注入目录中的 skill 版本
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
nerve agent remove hermes # 移除默认 profile 的注入
nerve agent remove hermes --profile <name>
```
---
## nerve.yaml 配置参考
```yaml
# 引擎全局配置
max_rounds: 100 # moderator 最大轮次(默认 100)
# Sense 配置
senses:
cpu-usage:
group: system # 必填,同 group 的 sense 共享 worker
interval: 10s # 轮询间隔(duration: 5s, 10m, 1h)
throttle: 5s # 最小计算间隔
timeout: 10s # compute 超时
grace_period: null # 优雅关闭等待
retention: 10000 # _signals 表最大行数(默认 10000)
system-health:
group: derived
on: [cpu-usage, disk-usage] # 响应式:被列出的 sense 发出 signal 时触发
throttle: null
timeout: null
# Workflow 配置
workflows:
my-workflow:
concurrency: 1 # 必填,并发数
overflow: drop # 必填,超并发时处理:drop | queue
max_queue: 100 # overflow=queue 时的队列上限(默认 100)
# HTTP API
api:
port: 3000 # null = 不启用 HTTP
host: "127.0.0.1" # 监听地址
token: null # 非 loopback 时必填
# LLM Extract(可选)
extract:
provider: anthropic
model: claude-sonnet-4-20250514
```
---
## Sense 开发指南
### compute 函数签名
Sense 的 `compute` **无参数**。它不接收数据库句柄:daemon 在 worker 内调用 `SenseComputeFn`,由运行时负责把非 null 结果的 `signal` 写入该 sense 的 Drizzle 表并记入 `_signals`。超时由运行时控制(对应 `nerve.yaml` 里的 `timeout`),无需在业务代码里读取 `AbortSignal`
```typescript
import type { ComputeResult, SenseComputeFn } from "@uncaged/nerve-core";
export const compute: SenseComputeFn<MySignalShape> = async () => {
// ...
};
// 或等价地:
export async function compute(): Promise<ComputeResult<MySignalShape>> {
// ...
}
```
(运行时定义见 `@uncaged/nerve-core``SenseComputeFn` / `SenseModule`,daemon 侧在 `sense-runtime.ts``executeCompute` 中插入 `result.signal`。)
### 返回值
当前引擎:`compute(state)` 返回 `{ state, trigger }`,其中 `trigger``null``{ command: string }`(sense worker 内 `shell: true` 执行)。Workflow 仅通过 CLI / daemon IPC 启动,类型见 `@uncaged/nerve-core``SenseComputeFn` / `SenseTrigger`
### Sense 模块导出
```typescript
// senses/<name>/src/index.ts
import type { ComputeResult } from "@uncaged/nerve-core";
import { table } from "./schema.js";
type Row = { ts: number; value: number };
export async function compute(): Promise<ComputeResult<Row>> {
const row: Row = { ts: Date.now(), value: Math.random() }; // 替换为真实观测逻辑
return { signal: row, trigger: null };
}
export { table };
```
### Schema 定义
```typescript
// senses/<name>/src/schema.ts
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("samples", {
ts: integer("ts").notNull(),
value: real("value").notNull(),
});
```
### 调度方式
1. **interval 轮询**`interval: 10s` — 每 10 秒执行一次
2. **响应式触发**`on: [cpu-usage]` — 当 cpu-usage 发出 signal 时触发
3. 两者可以组合
### 调试
```bash
nerve dev # 前台运行,看实时输出
nerve sense trigger <name> # 手动触发一次
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 5"
```
### 完整示例:CPU 监控
```typescript
// senses/cpu-usage/src/schema.ts
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
export const table = sqliteTable("samples", {
ts: integer("ts").notNull(),
value: real("value").notNull(),
});
// senses/cpu-usage/src/index.ts
import os from "node:os";
import type { ComputeResult } from "@uncaged/nerve-core";
import { table } from "./schema.js";
type Row = { ts: number; value: number };
export async function compute(): Promise<ComputeResult<Row>> {
const oneMin = os.loadavg()[0];
return { signal: { ts: Date.now(), value: oneMin }, trigger: null };
}
export { table };
```
nerve.yaml:
```yaml
senses:
cpu-usage:
group: system
interval: 10s
throttle: 5s
timeout: 10s
retention: 10000
```
---
## Workflow 开发指南
### 核心类型
```typescript
import type {
WorkflowDefinition,
RoleResult,
ThreadContext,
RoleMeta,
Moderator,
} from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
// Role<Meta> — (ctx: ThreadContext) => Promise<RoleResult<Meta>>
// RoleResult<Meta> — { content: string; meta: Meta }
// ThreadContext<M extends RoleMeta> — threadId, start(__start__ 帧), steps(各 role 轮次)
// Moderator<M> — (ctx) => 下一个 role 名 | END
// WorkflowDefinition<M extends RoleMeta> — name, roles, moderator
```
### createRole 四元组(接入 LLM 时推荐)
工作区根目录需安装 **`@uncaged/nerve-workflow-utils`**(及所选 agent 适配器包)。默认 `nerve init``package.json` 不含该依赖时,在 `~/.uncaged-nerve` 下执行 `pnpm add @uncaged/nerve-workflow-utils`(或 npm 等价命令)。
使用 **`createRole`**,按固定顺序传入四件事:
1. **adapter**`AgentFn``(ctx, systemPrompt) => Promise<string>`(原始模型输出文本)。
2. **prompt**`string`,或 `async (ctx: ThreadContext) => string`
3. **meta**`z.ZodType<M>`,供 moderator 路由的结构化 meta。
4. **extract**`{ provider: LlmProvider; dryRun: boolean | null }`,声明从回复中抽取 meta 时用的 LLM(OpenAI 兼容)及是否 dry-run。
```typescript
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
import type { ThreadContext } from "@uncaged/nerve-core";
import { z } from "zod";
const provider = {
baseUrl: "https://api.example.com/v1",
apiKey: process.env.EXAMPLE_API_KEY!,
model: "gpt-4o-mini",
};
const planMeta = z.object({ next: z.enum(["execute", "stop"]) });
export const planner = createRole(
createLlmAdapter(provider),
async (ctx: ThreadContext) => `规划任务:${ctx.start.content}`,
planMeta,
{ provider, dryRun: null },
);
```
`createLlmAdapter` 仅位于 **`@uncaged/nerve-workflow-utils`**:用 `LlmProvider` 生成 `AgentFn`,单轮对话里 **system** 来自 `createRole` 解析后的 prompt 字符串,**user** 为线程起点 `ctx.start.content`
### 基本 Workflow 示例(平铺 `roles/<role>.ts`)
```typescript
// workflows/example/roles/main.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
const prompt = ctx.start.content;
return {
content: `处理完成: ${prompt}`,
meta: { round: ctx.steps.length },
};
}
```
```typescript
// workflows/example/index.ts
import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { main } from "./roles/main.js";
type Meta = Record<"main", { round: number }>;
const workflow: WorkflowDefinition<Meta> = {
name: "example",
roles: { main },
moderator(ctx: ThreadContext<Meta>) {
return ctx.steps.length === 0 ? "main" : END;
},
};
export default workflow;
```
可选:将 `moderator` 挪到 `moderator.ts``import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`
### 多 Role Workflow 示例
```typescript
// workflows/plan-execute-review/roles/planner.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "计划: ...", meta: { status: "planned" } };
}
```
```typescript
// workflows/plan-execute-review/roles/executor.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "执行: ...", meta: { status: "executed" } };
}
```
```typescript
// workflows/plan-execute-review/roles/reviewer.ts
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
export async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
void ctx;
return { content: "审核通过", meta: { status: "approved" } };
}
```
```typescript
// workflows/plan-execute-review/index.ts
import type { WorkflowDefinition, ThreadContext } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
import { executor } from "./roles/executor.js";
import { planner } from "./roles/planner.js";
import { reviewer } from "./roles/reviewer.js";
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
const workflow: WorkflowDefinition<Roles> = {
name: "plan-execute-review",
roles: { planner, executor, reviewer },
moderator(ctx: ThreadContext<Roles>) {
if (ctx.steps.length === 0) return "planner";
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") return "executor";
if (last.role === "executor") return "reviewer";
return END;
},
};
export default workflow;
```
### Agent 适配器
Workflow role 可以集成 AI agent。已知适配器 **ID**`echo``cursor``hermes``codex`
```typescript
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
```
没有现成 agent 包时,用 **`createLlmAdapter``@uncaged/nerve-workflow-utils`)** 从 OpenAI 兼容的 `LlmProvider` 构造 `AgentFn`,再交给 **`createRole`** 的四元组。
### Workflow 运行状态
`queued``started``completed` | `failed` | `crashed` | `killed` | `interrupted` | `dropped`
---
## 日常操作 Pattern
### 查看系统整体状态
```bash
nerve daemon status # daemon 是否在运行
nerve sense list # 所有 sense 及其调度配置
nerve workflow status # 运行中的 workflow
nerve thread list # 最近的 workflow 执行记录
```
### 检查某个 sense 的历史数据
```bash
nerve sense query cpu-usage "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
nerve sense schema cpu-usage # 查看表结构
```
### 手动触发 workflow
```bash
nerve workflow trigger my-workflow --prompt "手动检查"
nerve thread list --workflow my-workflow # 查看执行状态
nerve thread show <runId> # 查看对话详情
```
### 排查 sense 报错
```bash
nerve daemon logs --follow # 查看实时日志
nerve sense trigger <name> # 手动触发看报错
nerve dev # 前台模式,更详细的输出
```
### 开发新 sense
```bash
nerve create sense my-sensor # 脚手架
# 编辑 senses/my-sensor/src/index.ts 和 schema.ts
nerve validate # 验证配置
nerve dev # 前台测试
nerve sense trigger my-sensor # 单次触发验证
nerve sense query my-sensor "SELECT * FROM ..." # 检查数据
```
### 开发新 workflow
```bash
nerve create workflow my-flow # 脚手架(当前 CLI 可能仍生成 roles/<name>/ 子目录)
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
nerve validate # 验证配置
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建(等价:pnpm run build);勿在单个 workflow 子目录单独跑 build
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
nerve thread show <runId> # 查看执行轨迹
```
---
## Pitfalls
- **Sense 返回值**:返回 `null` 表示静默(不发 signal);返回 `{ signal, workflow }` 才发 signal。不要返回 undefined。
- **Sense 持久化**:daemon 在 `compute()` 返回非 null 时自动执行 `db.insert(table).values(signal)` 并写入 `_signals`;业务代码不要自行 insert。
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
- **_signals 表**:每个 sense 自动有 `_signals` 表记录 signal 历史,受 `retention` 配置限制。
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
@@ -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("trigger: null");
expect(ts).toContain("lastRun");
});
});
@@ -27,10 +27,10 @@ describe("buildWorkflowScaffold", () => {
expect(roleMainIndexTs).toContain("my-workflow started");
});
it("root index contains WorkflowDefinition import from nerve-core", () => {
it("root index contains WorkflowDefinition import from @uncaged/workflow", () => {
const { indexTs } = buildWorkflowScaffold("test");
expect(indexTs).toContain("WorkflowDefinition");
expect(indexTs).toContain("@uncaged/nerve-core");
expect(indexTs).toContain("@uncaged/workflow");
});
it("root index wires moderator with ThreadContext and END", () => {
+26 -6
View File
@@ -2,9 +2,12 @@
* E2E-style tests for `nerve create workflow` and `nerve create sense`.
*/
import { execFile } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { defineCommand, runCommand } from "citty";
import { afterEach, describe, expect, it } from "vitest";
@@ -12,6 +15,23 @@ import { afterEach, describe, expect, it } from "vitest";
import { createCommand } from "../commands/create.js";
import { initCommand } from "../commands/init.js";
const execFileAsync = promisify(execFile);
const requireFromHere = createRequire(import.meta.url);
/**
* Default init pins `@uncaged/workflow` to npm `latest`, but that package is not published yet.
* Install from the monorepo workspace so `pnpm run build` can resolve workflow types.
*/
async function installWorkspaceWithLocalWorkflow(nerveRoot: string): Promise<void> {
const pkgPath = join(nerveRoot, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { dependencies: Record<string, string> };
const wfRoot = dirname(requireFromHere.resolve("@uncaged/workflow/package.json"));
pkg.dependencies["@uncaged/workflow"] = `file:${wfRoot}`;
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
await execFileAsync("pnpm", ["install", "--no-frozen-lockfile"], { cwd: nerveRoot });
await execFileAsync("pnpm", ["run", "build"], { cwd: nerveRoot });
}
const testRootCommand = defineCommand({
meta: { name: "nerve", description: "e2e-create" },
subCommands: {
@@ -128,7 +148,8 @@ describe("e2e create", () => {
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force"]);
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
await installWorkspaceWithLocalWorkflow(nerveRoot);
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
expect(wf.exitCode).toBe(0);
@@ -147,13 +168,14 @@ 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-"));
const nerveRoot = join(fakeHome, ".uncaged-nerve");
await runTestCli(fakeHome, ["init", "--force"]);
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
await installWorkspaceWithLocalWorkflow(nerveRoot);
const sense = await runTestCli(fakeHome, ["create", "sense", "e2e-sense"]);
expect(sense.exitCode).toBe(0);
@@ -162,8 +184,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);
},
);
+15 -112
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";
@@ -66,29 +51,9 @@ import { workflowCommand } from "../commands/workflow.js";
const require = createRequire(import.meta.url);
const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
const nerveWorkflowRoot = dirname(require.resolve("@uncaged/workflow/package.json"));
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
const workflowWorkerScript = join(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 workflowWorkerScript = join(nerveWorkflowRoot, "dist", "worker.js");
const nerveYamlTemplate = `senses:
counter:
@@ -150,61 +115,14 @@ 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 };
}
`;
/** First trigger launches local noop workflow; later triggers emit a plain signal. */
const counterIndexJsWithNoopWorkflow = `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 _launched = false;
export async function compute(_db, _peers, _options) {
if (!_launched) {
_launched = true;
return {
signal: { launched: 1 },
workflow: {
name: "noop",
maxRounds: 3,
prompt: "e2e-archive",
dryRun: false,
},
};
}
return { signal: { idle: 1 }, workflow: null };
export async function compute(state) {
return {
state: { count: state.count + 1 },
trigger: null,
};
}
`;
@@ -241,7 +159,6 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -259,7 +176,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,16 +183,7 @@ 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,
"utf8",
);
writeFileSync(join(nerveRoot, "dist", "senses", "counter", "index.js"), counterIndexJs, "utf8");
writeFileSync(
join(nerveRoot, "dist", "workflows", "echo", "index.js"),
echoWorkflowIndexJs,
@@ -302,8 +209,8 @@ export type TestDaemonHandle = {
export type StartTestDaemonOpts = {
/**
* When true, counter sense's first compute launches a local `noop` workflow (real
* workflow-worker child). Requires built `workflow-worker.js` next to `sense-worker.js`.
* When true, bundles a local `noop` workflow under `dist/workflows/noop` for tests that
* start runs via `nerve workflow trigger` (real workflow-worker child).
*/
withNoopWorkflow: boolean;
} | null;
@@ -325,10 +232,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);
}
/**
@@ -372,7 +275,7 @@ export async function startTestDaemon(
}
if (!existsSync(workflowWorkerScript)) {
throw new Error(
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\`.`,
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/workflow build\`.`,
);
}
@@ -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");
});
});
@@ -46,8 +46,14 @@ describe("e2e store archive", () => {
daemon = await startTestDaemon({ withNoopWorkflow: true });
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
expect(triggerResult.exitCode).toBe(0);
const wfTrigger = await runCli(daemon, [
"workflow",
"trigger",
"noop",
"--prompt",
"e2e-archive",
]);
expect(wfTrigger.exitCode).toBe(0);
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
await pollUntil(() => {
@@ -101,8 +107,14 @@ describe("e2e store archive", () => {
daemon = await startTestDaemon({ withNoopWorkflow: true });
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
expect(triggerResult.exitCode).toBe(0);
const wfTrigger = await runCli(daemon, [
"workflow",
"trigger",
"noop",
"--prompt",
"e2e-archive",
]);
expect(wfTrigger.exitCode).toBe(0);
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
await pollUntil(() => {
@@ -205,11 +205,11 @@ describe("e2e init", () => {
expect(existsSync(join(nerveRoot, "scripts", "build.mjs"))).toBe(true);
expect(existsSync(join(nerveRoot, "biome.json"))).toBe(true);
expect(existsSync(join(nerveRoot, ".gitignore"))).toBe(true);
expect(existsSync(join(nerveRoot, "AGENT.md"))).toBe(true);
const agentMd = readFileSync(join(nerveRoot, "AGENT.md"), "utf8");
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);
});
});
+2
View File
@@ -3,6 +3,7 @@ import "@uncaged/nerve-daemon/experimental-warning-suppression.js";
import { defineCommand, runMain } from "citty";
import { consumeGlobalDaemonCliFlags } from "./cli-global.js";
import { agentCommand } from "./commands/agent.js";
import { createCommand } from "./commands/create.js";
import { daemonCommand } from "./commands/daemon.js";
import { devCommand } from "./commands/dev.js";
@@ -42,6 +43,7 @@ const main = defineCommand({
"Nerve — an AI agent kernel. Global options: --host <host:port> (remote HTTP), --api-token <secret> (Bearer auth).",
},
subCommands: {
agent: agentCommand,
init: initCommand,
create: createCommand,
daemon: daemonCommand,
+477
View File
@@ -0,0 +1,477 @@
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
import { defineCommand } from "citty";
function getPackageRootDir(): string {
const thisFile = fileURLToPath(import.meta.url);
let dir = dirname(thisFile);
for (let i = 0; i < 5; i++) {
if (existsSync(join(dir, "package.json"))) return dir;
dir = dirname(dir);
}
throw new Error("Cannot locate package root. Is the CLI package intact?");
}
function getCliVersion(): string {
const pkgPath = join(getPackageRootDir(), "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string };
return pkg.version;
}
let _cachedVersion: string | null = null;
function cliVersion(): string {
if (_cachedVersion === null) _cachedVersion = getCliVersion();
return _cachedVersion;
}
function getSkillSourceDir(): string {
const root = getPackageRootDir();
const skillsDir = join(root, "skills");
if (!existsSync(skillsDir)) {
throw new Error("Cannot locate skills directory. Is the CLI package intact?");
}
return skillsDir;
}
function getHermesSkillDir(profile: string | null): string {
const hermesHome = join(homedir(), ".hermes");
if (profile !== null) {
return join(hermesHome, "profiles", profile, "skills", "nerve");
}
return join(hermesHome, "skills", "nerve");
}
function readVersionFile(skillDir: string): string | null {
const versionPath = join(skillDir, ".nerve-version");
if (!existsSync(versionPath)) return null;
return readFileSync(versionPath, "utf8").trim();
}
function writeVersionFile(skillDir: string, version: string): void {
writeFileSync(join(skillDir, ".nerve-version"), `${version}\n`, "utf8");
}
const CURSOR_VERSION_MARKER_RE = /<!--\s*nerve-cli-version:\s*([^>]+?)\s*-->/;
const CLAUDE_BLOCK_START_RE = /<!--\s*nerve-cli:start\s+v([^\s>]+)\s*-->/;
const CLAUDE_BLOCK_END = "<!-- nerve-cli:end -->";
const CLAUDE_BLOCK_RE = /<!--\s*nerve-cli:start\s+v[^\s>]+\s*-->[\s\S]*?<!--\s*nerve-cli:end\s*-->/;
function resolveCursorProjectDir(pathArg: string | null): string {
const raw = pathArg !== null && pathArg !== "" ? pathArg : process.cwd();
return resolvePath(raw);
}
function assertDirectory(projectDir: string, label: string): void {
if (!existsSync(projectDir)) {
process.stderr.write(`${label} does not exist: ${projectDir}\n`);
process.exit(1);
}
if (!statSync(projectDir).isDirectory()) {
process.stderr.write(`${label} is not a directory: ${projectDir}\n`);
process.exit(1);
}
}
function readCursorInjectVersion(projectDir: string): string | null {
const versionPath = join(projectDir, ".nerve-version");
if (existsSync(versionPath)) {
return readFileSync(versionPath, "utf8").trim();
}
const rulesPath = join(projectDir, ".cursorrules");
if (!existsSync(rulesPath)) return null;
const content = readFileSync(rulesPath, "utf8");
const match = content.match(CURSOR_VERSION_MARKER_RE);
return match !== null ? match[1].trim() : null;
}
function injectCursor(projectDir: string): void {
assertDirectory(projectDir, "Project directory");
const rulesPath = join(projectDir, ".cursorrules");
const existingVer = readCursorInjectVersion(projectDir);
if (existingVer === cliVersion() && existsSync(rulesPath)) {
process.stdout.write(
`✅ Cursor .cursorrules is already up to date (v${cliVersion()}) at ${projectDir}\n`,
);
return;
}
const templatePath = join(getSkillSourceDir(), "cursor", ".cursorrules");
if (!existsSync(templatePath)) {
throw new Error("Cannot locate cursor/.cursorrules template. Is the CLI package intact?");
}
let body = readFileSync(templatePath, "utf8");
body = body.replaceAll("__NERVE_CLI_VERSION__", cliVersion());
writeFileSync(rulesPath, body, "utf8");
writeVersionFile(projectDir, cliVersion());
const action = existingVer !== null ? "Updated" : "Installed";
process.stdout.write(`${action} Cursor .cursorrules v${cliVersion()} at ${projectDir}\n`);
}
function removeCursor(projectDir: string): void {
assertDirectory(projectDir, "Project directory");
const rulesPath = join(projectDir, ".cursorrules");
const versionPath = join(projectDir, ".nerve-version");
if (!existsSync(rulesPath)) {
process.stdout.write(`ℹ️ Cursor .cursorrules is not present at ${projectDir}\n`);
return;
}
rmSync(rulesPath, { force: true });
if (existsSync(versionPath)) {
rmSync(versionPath, { force: true });
}
process.stdout.write(`✅ Removed Cursor .cursorrules from ${projectDir}\n`);
}
function injectHermes(profile: string | null): void {
const sourceDir = join(getSkillSourceDir(), "hermes");
const targetDir = getHermesSkillDir(profile);
const existing = readVersionFile(targetDir);
if (existing === cliVersion()) {
const loc = profile !== null ? ` (profile: ${profile})` : "";
process.stdout.write(`✅ Hermes nerve skill is already up to date (v${cliVersion()})${loc}\n`);
return;
}
mkdirSync(targetDir, { recursive: true });
cpSync(sourceDir, targetDir, { recursive: true });
writeVersionFile(targetDir, cliVersion());
const action = existing !== null ? "Updated" : "Installed";
const loc = profile !== null ? ` (profile: ${profile})` : "";
process.stdout.write(`${action} Hermes nerve skill v${cliVersion()}${loc}\n`);
process.stdout.write(`${targetDir}/SKILL.md\n`);
}
function removeHermes(profile: string | null): void {
const targetDir = getHermesSkillDir(profile);
if (!existsSync(targetDir)) {
process.stdout.write("ℹ️ Hermes nerve skill is not installed.\n");
return;
}
rmSync(targetDir, { recursive: true, force: true });
const loc = profile !== null ? ` (profile: ${profile})` : "";
process.stdout.write(`✅ Removed Hermes nerve skill${loc}\n`);
}
// --- Claude Code ---
function getClaudeGlobalFile(): string {
return join(homedir(), ".claude", "CLAUDE.md");
}
function readClaudeBlockVersion(): string | null {
const filePath = getClaudeGlobalFile();
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, "utf8");
const match = content.match(CLAUDE_BLOCK_START_RE);
return match !== null ? match[1].trim() : null;
}
function injectClaude(): void {
const templatePath = join(getSkillSourceDir(), "claude", "CLAUDE.md");
if (!existsSync(templatePath)) {
throw new Error("Cannot locate claude/CLAUDE.md template. Is the CLI package intact?");
}
const version = cliVersion();
const existingVer = readClaudeBlockVersion();
if (existingVer === version) {
process.stdout.write(`✅ Claude Code nerve skill is already up to date (v${version})\n`);
return;
}
const body = readFileSync(templatePath, "utf8");
const block = `<!-- nerve-cli:start v${version} -->\n${body.trim()}\n${CLAUDE_BLOCK_END}`;
const filePath = getClaudeGlobalFile();
mkdirSync(dirname(filePath), { recursive: true });
if (existsSync(filePath)) {
const existing = readFileSync(filePath, "utf8");
if (CLAUDE_BLOCK_RE.test(existing)) {
// Replace existing block
const updated = existing.replace(CLAUDE_BLOCK_RE, block);
writeFileSync(filePath, updated, "utf8");
} else {
// Append
const sep = existing.endsWith("\n") ? "\n" : "\n\n";
writeFileSync(filePath, `${existing}${sep}${block}\n`, "utf8");
}
} else {
writeFileSync(filePath, `${block}\n`, "utf8");
}
const action = existingVer !== null ? "Updated" : "Installed";
process.stdout.write(`${action} Claude Code nerve skill v${version}\n`);
process.stdout.write(`${filePath}\n`);
}
function removeClaude(): void {
const filePath = getClaudeGlobalFile();
if (!existsSync(filePath)) {
process.stdout.write("ℹ️ Claude Code nerve skill is not installed.\n");
return;
}
const content = readFileSync(filePath, "utf8");
if (!CLAUDE_BLOCK_RE.test(content)) {
process.stdout.write("ℹ️ Claude Code nerve skill is not installed.\n");
return;
}
const cleaned = content
.replace(CLAUDE_BLOCK_RE, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (cleaned.length === 0) {
rmSync(filePath, { force: true });
} else {
writeFileSync(filePath, `${cleaned}\n`, "utf8");
}
process.stdout.write("✅ Removed Claude Code nerve skill\n");
}
function printCursorStatusLine(projectDir: string): void {
const rulesPath = join(projectDir, ".cursorrules");
const label = `Cursor (${projectDir})`;
if (!existsSync(rulesPath)) {
process.stdout.write(` ${label}: ❌ not installed\n`);
return;
}
const ver = readCursorInjectVersion(projectDir);
if (ver === null) {
process.stdout.write(
` ${label}: ⚠️ installed (unknown version; run \`nerve agent inject cursor\`)\n`,
);
return;
}
if (ver === cliVersion()) {
process.stdout.write(` ${label}: ✅ v${ver}\n`);
} else {
process.stdout.write(
` ${label}: ⚠️ v${ver} → v${cliVersion()} available (run \`nerve agent inject cursor\`)\n`,
);
}
}
function printStatus(): void {
process.stdout.write(`nerve agent skills (CLI v${cliVersion()})\n\n`);
printCursorStatusLine(process.cwd());
process.stdout.write("\n");
// Claude Code
const claudeVer = readClaudeBlockVersion();
printAgentLine("Claude Code", claudeVer);
process.stdout.write("\n");
// Default profile
const defaultDir = getHermesSkillDir(null);
const defaultVer = readVersionFile(defaultDir);
printAgentLine("Hermes (default)", defaultVer);
// Named profiles
const profilesDir = join(homedir(), ".hermes", "profiles");
if (existsSync(profilesDir)) {
const profiles = readdirSync(profilesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const profile of profiles) {
const dir = getHermesSkillDir(profile);
const ver = readVersionFile(dir);
if (ver !== null) {
printAgentLine(`Hermes (${profile})`, ver);
}
}
}
process.stdout.write("\n");
}
function printAgentLine(label: string, version: string | null): void {
if (version === null) {
process.stdout.write(` ${label}: ❌ not installed\n`);
} else if (version === cliVersion()) {
process.stdout.write(` ${label}: ✅ v${version}\n`);
} else {
process.stdout.write(
` ${label}: ⚠️ v${version} → v${cliVersion()} available (run \`nerve agent update\`)\n`,
);
}
}
const injectCommand = defineCommand({
meta: {
name: "inject",
description: "Inject nerve skill into an AI agent",
},
args: {
target: {
type: "positional",
description: "Agent target: hermes | cursor | claude",
},
profile: {
type: "string",
description: "Hermes profile name (default: main profile)",
},
path: {
type: "string",
description: "Project directory for Cursor rules (default: cwd); only used with cursor",
},
},
run({ args }) {
const target = args.target;
if (target === "hermes") {
if (args.path != null && args.path !== "") {
process.stderr.write("❌ --path applies only to the cursor target\n");
process.exit(1);
}
injectHermes(args.profile ?? null);
return;
}
if (target === "cursor") {
if (args.profile != null && args.profile !== "") {
process.stderr.write("❌ --profile applies only to the hermes target\n");
process.exit(1);
}
const pathArg = args.path != null && args.path !== "" ? args.path : null;
injectCursor(resolveCursorProjectDir(pathArg));
return;
}
if (target === "claude") {
injectClaude();
return;
}
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
process.stderr.write(" Supported targets: hermes, cursor, claude\n");
process.exit(1);
},
});
const updateCommand = defineCommand({
meta: {
name: "update",
description: "Update all injected nerve skills to current CLI version",
},
run() {
let updated = 0;
// Default profile
const defaultDir = getHermesSkillDir(null);
if (existsSync(defaultDir)) {
injectHermes(null);
updated++;
}
// Named profiles
const profilesDir = join(homedir(), ".hermes", "profiles");
if (existsSync(profilesDir)) {
const profiles = readdirSync(profilesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const profile of profiles) {
const dir = getHermesSkillDir(profile);
if (existsSync(dir)) {
injectHermes(profile);
updated++;
}
}
}
if (updated === 0) {
process.stdout.write("ℹ️ No injected skills found. Run `nerve agent inject hermes` first.\n");
}
// Claude Code (always check, no profiles)
const claudeVer = readClaudeBlockVersion();
if (claudeVer !== null) {
injectClaude();
}
},
});
const removeCommand = defineCommand({
meta: {
name: "remove",
description: "Remove injected nerve skill from an AI agent",
},
args: {
target: {
type: "positional",
description: "Agent target: hermes | cursor",
},
profile: {
type: "string",
description: "Hermes profile name (default: main profile)",
},
path: {
type: "string",
description: "Project directory for Cursor rules (default: cwd); only used with cursor",
},
},
run({ args }) {
const target = args.target;
if (target === "hermes") {
if (args.path != null && args.path !== "") {
process.stderr.write("❌ --path applies only to the cursor target\n");
process.exit(1);
}
removeHermes(args.profile ?? null);
return;
}
if (target === "cursor") {
if (args.profile != null && args.profile !== "") {
process.stderr.write("❌ --profile applies only to the hermes target\n");
process.exit(1);
}
const pathArg = args.path != null && args.path !== "" ? args.path : null;
removeCursor(resolveCursorProjectDir(pathArg));
return;
}
if (target === "claude") {
removeClaude();
return;
}
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
process.stderr.write(" Supported targets: hermes, cursor, claude\n");
process.exit(1);
},
});
const statusCommand = defineCommand({
meta: {
name: "status",
description: "Show injection status of nerve skills across agents",
},
run() {
printStatus();
},
});
export const agentCommand = defineCommand({
meta: {
name: "agent",
description: "Manage nerve skill injection for AI agents",
},
subCommands: {
inject: injectCommand,
update: updateCommand,
remove: removeCommand,
status: statusCommand,
},
});
+15 -70
View File
@@ -31,8 +31,8 @@ export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
}
function buildWorkflowIndexTs(name: string): string {
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
import { END } from "@uncaged/nerve-core";
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/workflow";
import { END } from "@uncaged/workflow";
import { mainRole } from "./roles/main/index.js";
@@ -56,7 +56,7 @@ export default workflow;
}
function buildWorkflowMainRoleIndexTs(name: string): string {
return `import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
return `import type { RoleResult, ThreadContext } from "@uncaged/workflow";
/**
* Main role implement LLM calls, scripts, HTTP, etc.
@@ -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 };
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};
export async function compute(state: SenseState): Promise<{
state: SenseState;
trigger: null;
}> {
// TODO: implement sense logic
return {
signal: {
label: "${senseId}",
ts: Date.now(),
},
workflow: null,
state: { lastRun: Date.now() },
trigger: 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 {
+77 -53
View File
@@ -54,13 +54,12 @@ const PACKAGE_JSON = `${JSON.stringify(
dependencies: {
"@uncaged/nerve-core": "latest",
"@uncaged/nerve-daemon": "latest",
"drizzle-orm": "latest",
"@uncaged/workflow": "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",
},
@@ -127,6 +126,69 @@ node_modules/
knowledge.db
`;
/** Generated at workspace root so agents can \`cat AGENT.md\` instead of npm skill paths. */
const AGENT_MD = `# Nerve workspace — agent guide
This file is created by \`nerve init\`. Read it before implementing senses or workflows.
## Directory layout
| Path | Purpose |
|------|---------|
| \`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()\` + \`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) |
| \`dist/workflows/<name>/index.js\` | Bundled workflow (after build) |
There is **no** \`package.json\` or \`tsconfig.json\` inside individual senses or workflows.
## Naming
- **Workflows:** verb-first kebab-case (e.g. \`review-pull-request\`, \`deploy-staging\`). Avoid bare nouns like \`notifications\`.
- **Senses:** kebab-case descriptive nouns (e.g. \`cpu-usage\`).
## Workflow roles four-tuple pattern
Wire each role with \`createRole\` from \`@uncaged/nerve-workflow-utils\`:
1. **Adapter** \`AgentFn\` (LLM call)
2. **Prompt builder** \`async (ctx: ThreadContext) => string\`
3. **Meta schema** Zod object (routing / structured output from the model)
4. **Extractor config** how JSON meta is parsed from replies
Keep meta small (often one boolean per role). The **moderator** in \`WorkflowDefinition\` routes between role names.
## Build commands
Always run from the **workspace root**:
\`\`\`bash
pnpm run build
# or: npm run build
\`\`\`
Fix errors until this succeeds. New workflows must appear under \`workflows/<name>/\` and be registered in \`nerve.yaml\`; new senses under \`senses/<name>/\` with matching \`nerve.yaml\` entries.
## Coding style (Nerve conventions)
- Use \`type\`, not \`interface\`; prefer \`function\` over classes (except errors / library requirements).
- **Named exports only** no \`export default\` (exception: \`workflows/<name>/index.ts\` uses default export for the daemon loader).
- Nullable fields: \`T | null\`, not TypeScript optional \`?:\`.
- No dynamic \`import()\` in workspace code (bundling and tooling assume static imports).
- Use \`async\`/\`await\`; use a \`Result\` type for expected failures instead of control-flow try/catch.
## Extra references (optional)
- \`CONVENTIONS.md\` — project-specific overrides at repo root.
- \`.knowledge/*.md\` — deeper docs when working inside the Nerve monorepo.
- \`.cursor/skills/\` — Cursor Agent Skills (\`SKILL.md\` per skill).
`;
const NERVE_SKILLS_MDC = `---
description: >-
Where Agent Skills live in this Nerve workspace and how to use them with Cursor
@@ -153,58 +215,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;
trigger: null;
}> {
const [oneMin] = loadavg();
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
return { state: { samples: newSamples }, trigger: null };
}
`;
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");
@@ -355,19 +384,14 @@ 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);
writeFile(join(nerveRoot, "scripts", "build.mjs"), BUILD_MJS);
writeFile(join(nerveRoot, "biome.json"), BIOME_JSON);
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");
},
});
+3 -2
View File
@@ -1,14 +1,15 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DEFAULT_ENGINE_MAX_ROUNDS, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
import { isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
import { defineCommand } from "citty";
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";
+2 -6
View File
@@ -20,12 +20,8 @@ import type {
SenseInfo,
WorkflowStatus,
} from "@uncaged/nerve-core";
import {
DEFAULT_ENGINE_MAX_ROUNDS,
isPlainRecord,
isSenseInfo,
isWorkflowStatus,
} from "@uncaged/nerve-core";
import { isPlainRecord, isSenseInfo, isWorkflowStatus } from "@uncaged/nerve-core";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
import { getCliDaemonApiToken, getCliDaemonHost } from "./cli-global.js";
import { HttpTransport } from "./http-transport.js";
+2 -6
View File
@@ -6,12 +6,8 @@ import type {
SenseInfo,
WorkflowStatus,
} from "@uncaged/nerve-core";
import {
DEFAULT_ENGINE_MAX_ROUNDS,
isPlainRecord,
isSenseInfo,
isWorkflowStatus,
} from "@uncaged/nerve-core";
import { isPlainRecord, isSenseInfo, isWorkflowStatus } from "@uncaged/nerve-core";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "@uncaged/workflow";
function normalizeBaseUrl(host: string): string {
const t = host.trim();
-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`;
}
+12 -22
View File
@@ -4,18 +4,18 @@ Shared types and configuration parser for the [nerve](../../README.md) observati
## What's Inside
- **Type definitions**`Signal`, `SenseConfig`, `SenseInfo`, `SenseReflexConfig`, `ReflexConfig` (sense-only), `WorkflowConfig`, `NerveConfig`, and related types
- **Config parser**`parseNerveConfig(yaml)` validates and parses `nerve.yaml` into `NerveConfig` (rejects reflex entries that declare a `workflow` key; reflexes only schedule senses)
- **Sense → workflow routing**`parseSenseWorkflowDirective`, `routeSenseComputeOutput`, and types `ParsedSenseWorkflowDirective`, `SenseComputeRoute`
- **Type definitions**`SenseConfig`, `SenseInfo`, `SenseComputeFn`, `SenseModule`, `SenseTrigger`, `WorkflowConfig`, `NerveConfig`, and related types
- **Config parser**`parseNerveConfig(yaml)` validates and parses `nerve.yaml` into `NerveConfig` (top-level `reflexes` is rejected; use `interval` / `on` on each sense)
- **Sense triggers**`parseSenseTrigger` validates `{ command: string }` from sense compute results or worker IPC (`trigger` field on `compute-result`)
- **Daemon IPC protocol** — request/response types (`DaemonIpcRequest`, `DaemonIpcResponse`, …) and `parseDaemonIpcRequest` for newline-delimited JSON on the CLI ↔ daemon socket
- **Workflow automaton types**`START` / `END` sentinel constants, `WorkflowMessage`, `StartStep`, `RoleStep`, `ModeratorContext` (`start` + `steps`; empty `steps` on first moderator call), `Moderator` (single `context` argument), `WorkflowDefinition`, `Role`, `SenseResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS`
- **Workflow automaton types**`START` / `END` sentinel constants, `WorkflowMessage`, `StartStep`, `RoleStep`, `ModeratorContext` (`start` + `steps`; empty `steps` on first moderator call), `Moderator` (single `context` argument), `WorkflowDefinition`, `Role`, `RoleResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS`
- **Result type**`Result<T>` with `ok()` / `err()` helpers for explicit error handling (no thrown exceptions for parse paths)
## Usage
```typescript
import { parseNerveConfig, ok, err } from "@uncaged/nerve-core";
import type { NerveConfig, Signal, Result } from "@uncaged/nerve-core";
import type { NerveConfig, Result } from "@uncaged/nerve-core";
const result: Result<NerveConfig> = parseNerveConfig(yamlString);
if (result.ok) {
@@ -23,29 +23,19 @@ if (result.ok) {
}
```
### Sense return → signal vs workflow
### Sense trigger validation
```typescript
import { parseSenseWorkflowDirective, routeSenseComputeOutput } from "@uncaged/nerve-core";
import { parseSenseTrigger } from "@uncaged/nerve-core";
const directive = parseSenseWorkflowDirective("my-workflow|8|Hello from sense");
if (directive.ok) {
console.log(directive.value.workflowName, directive.value.maxRounds, directive.value.prompt);
}
const route = routeSenseComputeOutput({
metric: 42,
workflow: "my-workflow|8|Run now",
});
if (route.kind === "launch") {
// engine starts workflow; no Signal to the bus for this return
console.log(route.launch);
} else {
// normal signal with payload
console.log(route.payload);
const parsed = parseSenseTrigger({ command: "echo hello" });
if (parsed.ok) {
console.log(parsed.value.command);
}
```
Sense modules return `{ state, trigger }` from `compute(state)`; when `trigger` is non-null it must be exactly `{ command: string }` (non-empty after trim). The daemon validates worker IPC with `parseSenseTrigger`. Workflows are started only via CLI / daemon IPC, not from this field.
## Duration Format
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:
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { parseSenseTrigger } from "../sense.js";
describe("parseSenseTrigger", () => {
it("accepts a valid command trigger", () => {
const r = parseSenseTrigger({ command: "echo hi" });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ command: "echo hi" });
});
it("trims command", () => {
const r = parseSenseTrigger({ command: " echo hi " });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ command: "echo hi" });
});
it("rejects empty command", () => {
const r = parseSenseTrigger({ command: "" });
expect(r.ok).toBe(false);
});
it("rejects whitespace-only command", () => {
const r = parseSenseTrigger({ command: " " });
expect(r.ok).toBe(false);
});
it("rejects non-string command", () => {
const r = parseSenseTrigger({ command: 1 as unknown as string });
expect(r.ok).toBe(false);
});
it("rejects non-object", () => {
expect(parseSenseTrigger(null).ok).toBe(false);
expect(parseSenseTrigger("x").ok).toBe(false);
expect(parseSenseTrigger([]).ok).toBe(false);
});
it("rejects extra properties", () => {
const r = parseSenseTrigger({ command: "x", kind: "shell" });
expect(r.ok).toBe(false);
});
it("rejects empty object", () => {
const r = parseSenseTrigger({});
expect(r.ok).toBe(false);
});
});
@@ -1,116 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseWorkflowTrigger, routeSenseComputeOutput } from "../sense.js";
describe("parseWorkflowTrigger", () => {
it("accepts a valid trigger object", () => {
const r = parseWorkflowTrigger({
name: "my-wf",
maxRounds: 3,
prompt: "go",
dryRun: true,
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value).toEqual({ name: "my-wf", maxRounds: 3, prompt: "go", dryRun: true });
});
it("trims workflow name", () => {
const r = parseWorkflowTrigger({
name: " spaced ",
maxRounds: 1,
prompt: "",
dryRun: false,
});
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.name).toBe("spaced");
});
it("rejects empty name", () => {
const r = parseWorkflowTrigger({ name: "", maxRounds: 1, prompt: "x", dryRun: false });
expect(r.ok).toBe(false);
});
it("rejects non-integer maxRounds", () => {
const r = parseWorkflowTrigger({
name: "w",
maxRounds: 1.5,
prompt: "",
dryRun: false,
});
expect(r.ok).toBe(false);
});
it("rejects maxRounds < 1", () => {
const r = parseWorkflowTrigger({ name: "w", maxRounds: 0, prompt: "", dryRun: false });
expect(r.ok).toBe(false);
});
it("rejects non-boolean dryRun", () => {
const r = parseWorkflowTrigger({
name: "w",
maxRounds: 1,
prompt: "",
dryRun: "no" as unknown as boolean,
});
expect(r.ok).toBe(false);
});
});
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();
});
});
-6
View File
@@ -21,9 +21,3 @@ export class ExtractError extends Error {
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* Agent adapter ids referenced by tooling / docs (RFC-003).
* Workflows import adapter packages directly; echo may be used in tests via a small factory.
*/
export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const;
+24 -42
View File
@@ -1,21 +1,11 @@
import { parse } from "yaml";
import { type Result, err, isPlainRecord, ok, parseDurationStringToMs } from "./util.js";
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type SenseConfig = {
group: string;
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. */
on: string[];
};
/**
* Workflow queue/runtime limits parsed from nerve.yaml.
* Shapes match the standalone workflow package core must not depend on it (#320).
*/
export type DropOverflowConfig = {
concurrency: number;
overflow: "drop";
@@ -29,6 +19,20 @@ export type QueueOverflowConfig = {
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
/** Engine-wide fallback when nerve.yaml omits max_rounds (keep in sync with workflow package default). */
export const DEFAULT_ENGINE_MAX_ROUNDS = 100;
export type SenseConfig = {
group: string;
throttle: number | null;
timeout: number | null;
gracePeriod: number | null;
/** Polling interval (ms). When set, the sense is triggered periodically. */
interval: number | null;
/** Other sense names whose successful computes schedule this sense (kernel reverse-index). */
on: string[];
};
/** Optional HTTP control plane. When `port` is null, the HTTP server is not started. */
export type NerveApiConfig = {
port: number | null;
@@ -54,19 +58,14 @@ export type ExtractConfig = {
model: string;
};
/** Parameters for starting a workflow from a Sense compute result (or CLI trigger). */
export type WorkflowTrigger = {
name: string;
maxRounds: number;
prompt: string;
dryRun: boolean;
};
/**
* 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.
* Optional shell side effect after a successful sense `compute()`.
* Executed in the sense worker (`spawn` with `shell: true`, cwd = nerve root).
* Workflows are started only via CLI / daemon IPC, not from sense compute results.
*/
export type ComputeResult<T> = null | { signal: T; workflow: WorkflowTrigger | null };
export type SenseTrigger = {
command: string;
};
export type NerveConfig = {
/** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */
@@ -83,23 +82,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 +128,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 +147,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")
);
}
+4 -27
View File
@@ -1,35 +1,14 @@
export { DEFAULT_SENSE_SIGNAL_RETENTION } from "./config.js";
export type {
SenseConfig,
DropOverflowConfig,
QueueOverflowConfig,
WorkflowConfig,
NerveApiConfig,
AgentConfig,
ExtractConfig,
NerveConfig,
WorkflowTrigger,
ComputeResult,
SenseTrigger,
} from "./config.js";
export type { Signal, SenseInfo } from "./sense.js";
export type { SenseInfo } from "./sense.js";
export type { SenseComputeFn, SenseModule } from "./sense.js";
export { labelSenseTrigger, senseTriggerLabels } from "./sense.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartStep,
ThreadContext,
WorkflowContext,
AgentFn,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
} from "./workflow.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export { parseDurationStringToMs } from "./util.js";
export { senseTriggerLabels } from "./sense.js";
export type { Schema, ExtractFn } from "./agent.js";
export { ExtractError } from "./agent.js";
export type { Result } from "./util.js";
@@ -46,10 +25,8 @@ export { parseNerveConfig } from "./config.js";
export type { KnowledgeConfig } from "./config.js";
export { parseKnowledgeYaml } from "./config.js";
export { isPlainRecord } from "./util.js";
export { KNOWN_AGENT_ADAPTER_IDS } from "./agent.js";
export type { RoutedSenseOutput } from "./sense.js";
export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense.js";
export { parseSenseTrigger } from "./sense.js";
export { isSenseInfo, isWorkflowStatus } from "./daemon.js";
export type {
+20 -66
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, SenseTrigger } from "./config.js";
import { type Result, err, isPlainRecord, ok } from "./util.js";
export type Signal = {
id: number;
senseId: string;
payload: unknown;
timestamp: number;
};
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
export type SenseInfo = {
name: string;
@@ -17,8 +8,7 @@ export type SenseInfo = {
throttle: number | null;
timeout: number | null;
/** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */
triggers: string[];
lastSignalTimestamp: number | null;
triggers: ReadonlyArray<string>;
};
/**
@@ -26,25 +16,18 @@ export type SenseInfo = {
* `compute` export.
*
* Pure: no DB, no peers.
* Return `null` to stay silent, or `{ signal, workflow }` to emit a Signal
* (and optionally trigger a Workflow).
* The runtime handles persistence via `db.insert(table).values(result.signal)`.
* Returns the next sense state and an optional trigger (`trigger: null` means no side effect).
*/
export type SenseComputeFn<T = unknown> = () => Promise<ComputeResult<T>>;
export type SenseComputeFn<S = unknown> = (
state: S,
) => Promise<{ state: S; trigger: SenseTrigger | 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 {
@@ -86,48 +69,19 @@ export function senseTriggerLabels(
return [labelSenseTrigger({ interval: sc.interval, on: sc.on })];
}
/**
* Validates a structured workflow trigger object from Sense compute or IPC.
*/
export function parseWorkflowTrigger(value: unknown): Result<WorkflowTrigger> {
/** Validates `{ command: string }` from Sense compute or IPC (`trigger` field). */
export function parseSenseTrigger(value: unknown): Result<SenseTrigger> {
if (!isPlainRecord(value)) {
return err(new Error("workflow trigger must be a plain object"));
return err(new Error("sense trigger must be a plain object"));
}
const nameRaw = value.name;
if (typeof nameRaw !== "string" || nameRaw.trim().length === 0) {
return err(new Error('workflow trigger: "name" must be a non-empty string'));
}
const maxRounds = value.maxRounds;
if (typeof maxRounds !== "number" || !Number.isInteger(maxRounds) || maxRounds < 1) {
return err(new Error('workflow trigger: "maxRounds" must be an integer >= 1'));
}
const prompt = value.prompt;
if (typeof prompt !== "string") {
return err(new Error('workflow trigger: "prompt" must be a string'));
}
const dryRun = value.dryRun;
if (typeof dryRun !== "boolean") {
return err(new Error('workflow trigger: "dryRun" must be a boolean'));
}
return ok({ 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 });
for (const key of Object.keys(value)) {
if (key !== "command") {
return err(new Error(`sense trigger: unexpected property "${key}"`));
}
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 });
const command = value.command;
if (typeof command !== "string" || command.trim().length === 0) {
return err(new Error('sense trigger: "command" must be a non-empty string'));
}
return ok({ command: command.trim() });
}
+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
+2 -2
View File
@@ -22,13 +22,13 @@
"scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "rslib build",
"pretest": "pnpm --filter @uncaged/nerve-core run build",
"pretest": "pnpm --filter @uncaged/workflow run build:public-types && pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-store run build && pnpm --filter @uncaged/workflow run build",
"test": "vitest run"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/nerve-core": "workspace:*",
"@uncaged/nerve-store": "workspace:*",
"drizzle-orm": "1.0.0-beta.23-c10d10c",
"yaml": "^2.8.3"
},
"devDependencies": {
-1
View File
@@ -11,7 +11,6 @@ export default defineConfig({
entry: {
index: "src/index.ts",
"sense-worker": "src/sense-worker.ts",
"workflow-worker": "src/workflow-worker.ts",
"experimental-warning-suppression": "src/experimental-warning-suppression.ts",
},
},
@@ -10,7 +10,8 @@
import { EventEmitter } from "node:events";
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import type { WorkflowConfig } from "@uncaged/workflow";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockChild = EventEmitter & {
@@ -28,6 +29,11 @@ function makeMockChild(pid = 1): MockChild {
child.connected = true;
child.exitCode = null;
child.pid = pid;
setImmediate(() => {
if (child.connected) {
child.emit("message", { type: "ready" });
}
});
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
@@ -57,7 +63,7 @@ vi.mock("node:child_process", () => ({
}),
}));
const { createWorkflowManager } = await import("../workflow-manager.js");
const { createWorkflowManager } = await import("@uncaged/workflow");
function makeConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConfig {
return {
@@ -132,6 +138,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mgr.activeCount("my-wf")).toBe(2);
// Simulate unexpected exit (not shutdown)
@@ -159,6 +166,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mgr.activeCount("my-wf")).toBe(2);
const child = mockChildren[0];
@@ -183,6 +191,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mockChildren).toHaveLength(1);
const child = mockChildren[0];
@@ -216,6 +225,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const firstChild = mockChildren[0];
firstChild.exitCode = 1;
firstChild.connected = false;
@@ -260,6 +270,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
// Start one thread to fill the concurrency slot (so queued run stays queued on respawn)
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const firstChild = mockChildren[0];
firstChild.exitCode = 1;
firstChild.connected = false;
@@ -285,6 +296,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const child = mockChildren[0];
const startCall = (child.send as ReturnType<typeof vi.fn>).mock.calls[0];
@@ -322,6 +334,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const launch = { prompt: "build-docker for myrepo", maxRounds: 10, dryRun: false };
mgr.startWorkflow("my-wf", launch);
await vi.runAllTimersAsync();
const startedCall = logStore.upsertWorkflowRun.mock.calls.find(
(args: any[]) => (args[0] as { type: string }).type === "started",
@@ -357,6 +370,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
// Start one thread to fill the concurrency slot
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const firstChild = mockChildren[0];
// Crash once → respawn → crash again → second respawn
@@ -398,6 +412,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const firstChild = mockChildren[0];
firstChild.exitCode = 1;
firstChild.connected = false;
@@ -428,6 +443,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("crash-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
// Crash the worker 6 times in rapid succession (within CRASH_WINDOW_MS = 60s)
for (let i = 0; i < 6; i++) {
@@ -241,7 +241,6 @@ describe("daemon-ipc — list-senses", () => {
throttle: 5000,
timeout: 3000,
triggers: ["every 30s"],
lastSignalTimestamp: 1000,
},
{
name: "disk-usage",
@@ -249,7 +248,6 @@ describe("daemon-ipc — list-senses", () => {
throttle: 30000,
timeout: null,
triggers: [],
lastSignalTimestamp: null,
},
];
const listSenses = vi.fn(() => sensesData);
@@ -79,7 +79,7 @@ describe("createFileWatcher", () => {
await new Promise((r) => setTimeout(r, 100));
writeFileSync(
join(root, "senses", "cpu-usage", "index.js"),
"export async function compute() { return { signal: 42, workflow: null }; }",
"export const initialState = {}; export async function compute(state) { return { state, trigger: 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,
trigger: 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 + trigger: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,
trigger: 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",
trigger: null,
});
}, 10_000);
}
});
@@ -15,7 +15,8 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import type { WorkflowConfig } from "@uncaged/workflow";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type MockChild = EventEmitter & {
@@ -33,6 +34,11 @@ function makeMockChild(pid = 1): MockChild {
child.connected = true;
child.exitCode = null;
child.pid = pid;
setImmediate(() => {
if (child.connected) {
child.emit("message", { type: "ready" });
}
});
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
@@ -62,7 +68,7 @@ vi.mock("node:child_process", () => ({
}),
}));
const { createWorkflowManager } = await import("../workflow-manager.js");
const { createWorkflowManager } = await import("@uncaged/workflow");
const { createKernel } = await import("../kernel.js");
function makeWfConfig(workflows: Record<string, WorkflowConfig> = {}): NerveConfig {
@@ -114,6 +120,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mockChildren).toHaveLength(1);
// Remove workflow from config before drain completes
@@ -134,6 +141,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mgr.activeCount("my-wf")).toBe(2);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
@@ -165,6 +173,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mockChildren).toHaveLength(1);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
@@ -181,6 +190,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mockChildren).toHaveLength(1);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
@@ -198,6 +208,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
@@ -223,6 +234,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "first", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
await vi.runAllTimersAsync();
@@ -230,6 +242,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
// Start a new thread on the fresh worker
mgr.startWorkflow("my-wf", { prompt: "second", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const newChild = mockChildren[1];
const startCalls = (newChild.send as ReturnType<typeof vi.fn>).mock.calls.filter(
@@ -257,12 +270,13 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
vi.clearAllMocks();
});
it("does not send shutdown while a thread is still active", () => {
it("does not send shutdown while a thread is still active", async () => {
const logStore = makeLogStore();
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const child = mockChildren[0];
mgr.drainWhenIdle("my-wf");
@@ -282,6 +296,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const child = mockChildren[0];
const runId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { runId: string };
@@ -311,6 +326,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
mgr.startWorkflow("my-wf", { prompt: "a", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "b", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const child = mockChildren[0];
const sendMock = child.send as ReturnType<typeof vi.fn>;
const runIdA = (sendMock.mock.calls[0][0] as { runId: string }).runId;
@@ -355,6 +371,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const child = mockChildren[0];
const runId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { runId: string };
@@ -388,6 +405,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const child = mockChildren[0];
const runId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { runId: string };
@@ -414,6 +432,7 @@ describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "once", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const firstChild = mockChildren[0];
const runId = (firstChild.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as {
runId: string;
@@ -471,6 +490,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
// Trigger a workflow thread so a worker is spawned
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
// Manually call drainAndRespawn (simulating what kernel does on workflow file change)
const drainPromise = kernel.workflowManager.drainAndRespawn("my-wf", 1000);
@@ -511,6 +531,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
maxRounds: 10,
dryRun: false,
});
await vi.runAllTimersAsync();
expect(mockChildren).toHaveLength(1);
// Reload config without old-wf
@@ -551,6 +572,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
});
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const workersBefore = mockChildren.length;
// Reload with updated concurrency — should NOT spawn a new workflow worker
@@ -573,6 +595,7 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
// Can now start up to 5 concurrent threads (previously only 1)
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(kernel.workflowManager.activeCount("my-wf")).toBe(3);
const stopPromise = kernel.stop();
@@ -11,7 +11,6 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Signal } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -30,7 +29,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -82,7 +80,6 @@ describe("kernel integration — real child processes", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -91,7 +88,6 @@ describe("kernel integration — real child processes", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -100,7 +96,6 @@ describe("kernel integration — real child processes", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -116,31 +111,32 @@ describe("kernel integration — real child processes", () => {
expect(kernel.senseCount).toBe(3);
});
it("workers start and respond to compute messages with signals", async () => {
it("workers start and respond to compute messages with compute-result", async () => {
const config = makeConfig();
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
// Wait for all workers to be ready (event-based, not fixed delay)
await kernel.ready;
// Subscribe to the bus before triggering compute
const received: Signal[] = [];
const unsub = kernel.bus.subscribe((signal) => {
received.push(signal);
});
// Trigger a compute for "cpu-usage" through the kernel's triggerCompute
kernel.triggerCompute("cpu-usage");
// Poll until a signal arrives on the bus (event-driven, no fixed delay)
await pollUntil(() => received.length > 0, 10_000);
await pollUntil(
() =>
kernel!.logStore.query({
source: "sense",
type: "compute-complete",
refId: "cpu-usage",
}).length > 0,
10_000,
);
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
unsub();
const rows = kernel.logStore.query({
source: "sense",
type: "compute-complete",
refId: "cpu-usage",
});
expect(rows).toHaveLength(1);
}, 15_000);
it("graceful shutdown: stop() resolves after all workers exit", async () => {
@@ -151,7 +147,6 @@ describe("kernel integration — real child processes", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -160,7 +155,6 @@ describe("kernel integration — real child processes", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -180,31 +174,32 @@ describe("kernel integration — real child processes", () => {
await expect(stopPromise).resolves.toBeUndefined();
}, 10_000);
it("compute round-trip: worker receives compute and sends signal back through bus", async () => {
it("compute round-trip: worker receives compute and kernel logs compute-complete", async () => {
const config = makeConfig();
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
// Wait for all workers to be ready (event-based, not fixed delay)
await kernel.ready;
const received: Signal[] = [];
const unsub = kernel.bus.subscribe((signal) => {
received.push(signal);
});
// Trigger compute via the kernel — the kernel sends IPC to the worker,
// the mock worker responds with a signal message, and the kernel routes it to the bus.
kernel.triggerCompute("cpu-usage");
// Poll for the signal on the bus (no fixed delay)
await pollUntil(() => received.length > 0, 10_000);
await pollUntil(
() =>
kernel!.logStore.query({
source: "sense",
type: "compute-complete",
refId: "cpu-usage",
}).length > 0,
10_000,
);
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
unsub();
const rows = kernel.logStore.query({
source: "sense",
type: "compute-complete",
refId: "cpu-usage",
});
expect(rows).toHaveLength(1);
}, 15_000);
it("crash recovery: kernel respawns worker after unexpected exit and new worker is functional", async () => {
@@ -234,23 +229,24 @@ describe("kernel integration — real child processes", () => {
expect(newPid).not.toBeNull();
expect(newPid).not.toBe(originalPid);
// Wait a bit for the new worker to send its "ready" message and be fully up.
// Poll until the new worker responds to a compute message on the bus.
const postRespawnSignals: Signal[] = [];
const unsub = kernel.bus.subscribe((signal) => {
postRespawnSignals.push(signal);
});
// Trigger compute through the kernel to the new worker
kernel.triggerCompute("cpu-usage");
// Poll for the signal — verifies the new worker is fully functional
await pollUntil(() => postRespawnSignals.length > 0, 15_000);
await pollUntil(
() =>
kernel!.logStore.query({
source: "sense",
type: "compute-complete",
refId: "cpu-usage",
}).length > 0,
15_000,
);
expect(postRespawnSignals).toHaveLength(1);
expect(postRespawnSignals[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 });
unsub();
const rows = kernel.logStore.query({
source: "sense",
type: "compute-complete",
refId: "cpu-usage",
});
expect(rows.length).toBeGreaterThanOrEqual(1);
// Kernel should still stop gracefully after respawn
await kernel.stop();
@@ -86,7 +86,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -125,7 +124,6 @@ describe("kernel — getHealth", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -134,7 +132,6 @@ describe("kernel — getHealth", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -143,7 +140,6 @@ describe("kernel — getHealth", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -246,7 +242,6 @@ describe("kernel — reloadConfig", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -255,7 +250,6 @@ describe("kernel — reloadConfig", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -283,7 +277,6 @@ describe("kernel — reloadConfig", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -292,7 +285,6 @@ describe("kernel — reloadConfig", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -318,7 +310,6 @@ describe("kernel — reloadConfig", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -352,7 +343,6 @@ describe("kernel — reloadConfig", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -361,7 +351,6 @@ describe("kernel — reloadConfig", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -101,7 +101,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -153,7 +152,6 @@ describe("kernel.triggerSense()", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -162,7 +160,6 @@ describe("kernel.triggerSense()", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -199,7 +196,6 @@ describe("kernel.triggerSense()", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -208,7 +204,6 @@ describe("kernel.triggerSense()", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -1,8 +1,9 @@
/**
* 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 workflow runs are started via `workflowManager.startWorkflow` (CLI / IPC path);
* that sense compute-result with a shell trigger does not start workflows;
* that workflow events are logged; that reloadConfig handles workflow changes;
* and that graceful shutdown stops workflow workers.
*
* Uses mocked child_process.fork to avoid real subprocesses.
@@ -53,7 +54,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,
trigger: null,
});
});
}
});
@@ -117,7 +123,6 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -149,21 +154,10 @@ describe("kernel + workflowManager integration", () => {
rmSync(nerveRoot, { recursive: true, force: true });
});
describe("sense compute triggers workflow via return value", () => {
it("calls workflowManager.startWorkflow when a sense compute returns a workflow launch", async () => {
describe("workflowManager.startWorkflow", () => {
it("spawns a workflow worker and sends start-thread", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
},
workflows: { "my-workflow": { concurrency: 2, overflow: "drop" } },
});
@@ -174,27 +168,14 @@ 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",
sense: "cpu-usage",
payload: {
signal: { reason: "test" },
workflow: {
name: "my-workflow",
maxRounds: 10,
prompt: "run this workflow",
dryRun: false,
},
},
});
}
kernel.workflowManager.startWorkflow("my-workflow", {
prompt: "run this workflow",
maxRounds: 10,
dryRun: false,
});
await vi.runAllTimersAsync();
// A workflow worker should be spawned and a start-thread message sent
const workflowWorker = mockChildren.find((c) =>
(c.send as ReturnType<typeof vi.fn>).mock.calls.some(
(args: unknown[]) =>
@@ -210,20 +191,9 @@ describe("kernel + workflowManager integration", () => {
await stopPromise;
});
it("passes prompt and maxRounds from the workflow field to the workflow", async () => {
it("passes prompt and maxRounds on start-thread", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
},
workflows: { "alert-workflow": { concurrency: 1, overflow: "drop" } },
});
@@ -234,25 +204,14 @@ 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",
sense: "cpu-usage",
payload: {
signal: { level: "critical" },
workflow: {
name: "alert-workflow",
maxRounds: 5,
prompt: "handle critical alert",
dryRun: false,
},
},
});
}
kernel.workflowManager.startWorkflow("alert-workflow", {
prompt: "handle critical alert",
maxRounds: 5,
dryRun: false,
});
await vi.runAllTimersAsync();
// Find the start-thread call and verify triggerPayload
const startThreadCall = mockChildren
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
.find(
@@ -275,8 +234,10 @@ describe("kernel + workflowManager integration", () => {
await vi.runAllTimersAsync();
await stopPromise;
});
});
it("logs sense signal before workflow-launch when both are present", async () => {
describe("sense compute-result triggers", () => {
it("logs compute-complete before shell-launch when shell trigger is present", async () => {
const logStore = makeLogStore();
const config = makeConfig({
workflows: { "order-wf": { concurrency: 1, overflow: "drop" } },
@@ -292,35 +253,30 @@ 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 },
trigger: { command: "echo order-test" },
});
}
await vi.runAllTimersAsync();
const senseEntries = logStore.append.mock.calls
.map((c) => c[0] as { source: string; type: string; refId: string | null })
.filter((e) => e.source === "sense" && e.refId === "cpu-usage");
const typeOrder = senseEntries.map((e) => e.type);
const sigAt = typeOrder.indexOf("signal");
const launchAt = typeOrder.indexOf("workflow-launch");
expect(sigAt).toBeGreaterThanOrEqual(0);
expect(launchAt).toBeGreaterThan(sigAt);
const completeAt = typeOrder.indexOf("compute-complete");
const shellAt = typeOrder.indexOf("shell-launch");
expect(completeAt).toBeGreaterThanOrEqual(0);
expect(shellAt).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 trigger null", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
@@ -329,7 +285,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -338,7 +293,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -353,17 +307,16 @@ 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,
trigger: null,
});
}
// No workflow should have been started
const workflowWorkerSpawned = mockChildren.some((c) =>
(c.send as ReturnType<typeof vi.fn>).mock.calls.some(
(args: unknown[]) =>
@@ -378,23 +331,56 @@ describe("kernel + workflowManager integration", () => {
await vi.runAllTimersAsync();
await stopPromise;
});
it("logs shell-launch and does not start a workflow for shell triggers", async () => {
const logStore = makeLogStore();
const config = makeConfig({ workflows: {} });
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
await flushSenseWorkerForkMicrotasks(kernel);
await vi.runAllTimersAsync();
const workerPool = mockChildren[0];
if (workerPool) {
workerPool.emit("message", {
type: "compute-result",
sense: "cpu-usage",
state: {},
trigger: {
command: "echo nerve-shell-test",
},
});
}
await vi.runAllTimersAsync();
const shellLaunch = logStore.append.mock.calls
.map((c) => c[0] as { source: string; type: string })
.find((e) => e.type === "shell-launch");
expect(shellLaunch).toBeDefined();
const startThread = mockChildren
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
.some(
([msg]) =>
msg !== null &&
typeof msg === "object" &&
(msg as Record<string, unknown>).type === "start-thread",
);
expect(startThread).toBe(false);
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
await stopPromise;
});
});
describe("workflow events are logged", () => {
it("logs a 'started' event when workflow thread is triggered via sense compute", async () => {
it("logs a 'started' event when workflow thread is started via workflowManager", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
},
workflows: { "log-test-workflow": { concurrency: 2, overflow: "drop" } },
});
@@ -405,23 +391,13 @@ 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",
sense: "cpu-usage",
payload: {
signal: { note: "log" },
workflow: {
name: "log-test-workflow",
maxRounds: 10,
prompt: "test prompt",
dryRun: false,
},
},
});
}
kernel.workflowManager.startWorkflow("log-test-workflow", {
prompt: "test prompt",
maxRounds: 10,
dryRun: false,
});
await vi.runAllTimersAsync();
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
expect.objectContaining({ source: "workflow", type: "started" }),
@@ -438,17 +414,6 @@ describe("kernel + workflowManager integration", () => {
it("new workflows are available after reloadConfig", async () => {
const logStore = makeLogStore();
const initialConfig = makeConfig({
senses: {
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
},
workflows: {},
maxRounds: 10,
});
@@ -460,7 +425,6 @@ describe("kernel + workflowManager integration", () => {
await flushSenseWorkerForkMicrotasks(kernel);
await vi.runAllTimersAsync();
// Reload with a workflow added
const newConfig: NerveConfig = {
senses: {
"cpu-usage": {
@@ -468,7 +432,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -480,23 +443,13 @@ 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",
sense: "cpu-usage",
payload: {
signal: { phase: "reload" },
workflow: {
name: "new-workflow",
maxRounds: 10,
prompt: "reload test",
dryRun: false,
},
},
});
}
kernel.workflowManager.startWorkflow("new-workflow", {
prompt: "reload test",
maxRounds: 10,
dryRun: false,
});
await vi.runAllTimersAsync();
const startThreadCall = mockChildren
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
@@ -518,17 +471,6 @@ describe("kernel + workflowManager integration", () => {
it("old workflows are removed after reloadConfig", async () => {
const logStore = makeLogStore();
const initialConfig = makeConfig({
senses: {
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
},
workflows: { "old-workflow": { concurrency: 1, overflow: "drop" } },
});
@@ -539,7 +481,6 @@ describe("kernel + workflowManager integration", () => {
await flushSenseWorkerForkMicrotasks(kernel);
await vi.runAllTimersAsync();
// Reload with the workflow removed
const newConfig: NerveConfig = {
senses: {
"cpu-usage": {
@@ -547,7 +488,6 @@ describe("kernel + workflowManager integration", () => {
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
@@ -559,28 +499,17 @@ describe("kernel + workflowManager integration", () => {
};
kernel.reloadConfig(newConfig);
// Clear send history
for (const c of mockChildren) {
(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",
sense: "cpu-usage",
payload: {
signal: { stale: true },
workflow: {
name: "old-workflow",
maxRounds: 10,
prompt: "should not work",
dryRun: false,
},
},
});
}
kernel.workflowManager.startWorkflow("old-workflow", {
prompt: "should not work",
maxRounds: 10,
dryRun: false,
});
await vi.runAllTimersAsync();
const startThreadCall = mockChildren
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
@@ -603,17 +532,6 @@ describe("kernel + workflowManager integration", () => {
it("stop() resolves after workflow workers exit", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
},
workflows: { "shutdown-test": { concurrency: 1, overflow: "drop" } },
});
@@ -624,23 +542,13 @@ 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",
sense: "cpu-usage",
payload: {
signal: { shutdownCase: true },
workflow: {
name: "shutdown-test",
maxRounds: 10,
prompt: "test",
dryRun: false,
},
},
});
}
kernel.workflowManager.startWorkflow("shutdown-test", {
prompt: "test",
maxRounds: 10,
dryRun: false,
});
await vi.runAllTimersAsync();
const stopPromise = kernel.stop();
await vi.runAllTimersAsync();
@@ -669,17 +577,6 @@ describe("kernel + workflowManager integration", () => {
it("getHealth includes activeWorkflows count", async () => {
const logStore = makeLogStore();
const config = makeConfig({
senses: {
"cpu-usage": {
group: "system",
throttle: null,
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
},
},
workflows: { "health-wf": { concurrency: 2, overflow: "drop" } },
});
+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,
trigger: 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,
trigger: 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,
trigger: 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 }, trigger: 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 }, trigger: 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, trigger: 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 }, trigger: 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,
trigger: null,
});
expect(onWorkerMessage).toHaveBeenCalledWith({
type: "compute-result",
sense: "s",
state: 1,
trigger: null,
});
});
it("sendCompute delivers to the worker for that group", async () => {
@@ -1,7 +1,7 @@
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createWorkerRuntime } from "@uncaged/workflow";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkerRuntime } from "../worker-runtime.js";
const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
const echoWorkerPath = join(fixturesDir, "echo-worker.js");
@@ -16,6 +16,7 @@ function baseConfig(script: string) {
onMessage: vi.fn(),
onReady: vi.fn(),
onExit: vi.fn(),
onCrashLimitReached: null,
respawn: {
enabled: true,
maxCrashes: 6,
@@ -84,7 +85,7 @@ describe("createWorkerRuntime", () => {
const rt = track(createWorkerRuntime(baseConfig(echoWorkerPath)));
await rt.start("k");
expect(rt.has("k")).toBe(true);
await rt.evict("k");
await rt.evict("k", null);
expect(rt.has("k")).toBe(false);
await rt.shutdown();
});
@@ -94,7 +95,7 @@ describe("createWorkerRuntime", () => {
await rt.start("k");
const before = rt.pid("k");
expect(before).not.toBeNull();
await rt.drain("k");
await rt.drain("k", null);
const after = rt.pid("k");
expect(after).not.toBeNull();
expect(after).not.toBe(before);
@@ -26,6 +26,11 @@ function makeMockChild(pid = 1): MockChild {
child.connected = true;
child.exitCode = null;
child.pid = pid;
setImmediate(() => {
if (child.connected) {
child.emit("message", { type: "ready" });
}
});
child.send = vi.fn((msg: unknown) => {
if (
msg !== null &&
@@ -56,7 +61,7 @@ vi.mock("node:child_process", () => ({
}));
// Import after mock is set up
const { createWorkflowManager } = await import("../workflow-manager.js");
const { createWorkflowManager } = await import("@uncaged/workflow");
// ---------------------------------------------------------------------------
// Helpers
@@ -110,7 +115,7 @@ describe("WorkflowManager", () => {
});
describe("startWorkflow under concurrency limit dispatches thread", () => {
it("forks a worker and sends start-thread when active < concurrency", () => {
it("forks a worker and sends start-thread when active < concurrency", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-workflow": { concurrency: 2, overflow: "drop" },
@@ -118,6 +123,7 @@ describe("WorkflowManager", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mockChildren).toHaveLength(1);
expect(mockChildren[0].send).toHaveBeenCalledWith(
@@ -126,7 +132,7 @@ describe("WorkflowManager", () => {
expect(mgr.activeCount("my-workflow")).toBe(1);
});
it("reuses the same worker for a second thread under the limit", () => {
it("reuses the same worker for a second thread under the limit", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-workflow": { concurrency: 3, overflow: "drop" },
@@ -135,6 +141,7 @@ describe("WorkflowManager", () => {
mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
// Only one forked child — worker is reused
expect(mockChildren).toHaveLength(1);
@@ -142,7 +149,7 @@ describe("WorkflowManager", () => {
expect(mgr.activeCount("my-workflow")).toBe(2);
});
it("logs a 'started' event for each dispatched thread", () => {
it("logs a 'started' event for each dispatched thread", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"my-workflow": { concurrency: 2, overflow: "drop" },
@@ -150,6 +157,7 @@ describe("WorkflowManager", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
expect.objectContaining({ source: "workflow", type: "started" }),
@@ -159,7 +167,7 @@ describe("WorkflowManager", () => {
});
describe("startWorkflow at limit with drop overflow drops the request", () => {
it("does NOT send start-thread when at concurrency limit with overflow=drop", () => {
it("does NOT send start-thread when at concurrency limit with overflow=drop", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"drop-wf": { concurrency: 1, overflow: "drop" },
@@ -169,6 +177,7 @@ describe("WorkflowManager", () => {
mgr.startWorkflow("drop-wf", { prompt: "first", maxRounds: 10, dryRun: false });
// now at limit — second call should be dropped
mgr.startWorkflow("drop-wf", { prompt: "second", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mgr.activeCount("drop-wf")).toBe(1);
expect(mgr.queueLength("drop-wf")).toBe(0);
@@ -254,7 +263,7 @@ describe("WorkflowManager", () => {
});
describe("completing a thread dequeues the next one", () => {
it("dispatches the next queued thread when the active thread sends completed", () => {
it("dispatches the next queued thread when the active thread sends completed", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"queue-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
@@ -263,6 +272,7 @@ describe("WorkflowManager", () => {
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
expect(mgr.activeCount("queue-wf")).toBe(1);
expect(mgr.queueLength("queue-wf")).toBe(1);
@@ -289,7 +299,7 @@ describe("WorkflowManager", () => {
);
});
it("dispatches next queued thread when active thread sends failed", () => {
it("dispatches next queued thread when active thread sends failed", async () => {
const logStore = makeLogStore();
const config = makeConfig({
"queue-wf": { concurrency: 1, overflow: "queue", maxQueue: 5 },
@@ -298,6 +308,7 @@ describe("WorkflowManager", () => {
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
const child = mockChildren[0];
const firstRunId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0].runId as string;
@@ -325,6 +336,7 @@ describe("WorkflowManager", () => {
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10, dryRun: false });
await vi.runAllTimersAsync();
// Two distinct workers should have been forked
expect(mockChildren).toHaveLength(2);
@@ -1,9 +0,0 @@
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
/**
* Echo adapter (`type: "echo"`) returns the assembled prompt unchanged.
* Used for tests and dry-run wiring before real adapters exist.
*/
export function createEchoAgent(_config: AgentConfig): AgentFn {
return async (_ctx: ThreadContext, prompt: string) => prompt;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import type { HealthInfo, SenseInfo, WorkflowStatus } from "@uncaged/nerve-core";
import type { WorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "@uncaged/workflow";
export type DaemonHandlerBundle = {
health: () => HealthInfo;
+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');
+5 -16
View File
@@ -4,7 +4,7 @@ export type {
HealthRequestMessage,
HealthResponseMessage,
ParentToWorkerMessage,
SignalMessage,
ComputeResultMessage,
ErrorMessage,
ReadyMessage,
WorkerToParentMessage,
@@ -12,19 +12,10 @@ export type {
ResumeThreadMessage,
ThreadEventMessage,
WorkflowErrorMessage,
ThreadWorkflowMessageMessage,
ThreadWorkflowMessage,
} 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";
@@ -54,7 +45,5 @@ export type {
WorkflowRunStatus,
} from "@uncaged/nerve-store";
export { createWorkflowManager } from "./workflow-manager.js";
export type { WorkflowManager } from "./workflow-manager.js";
export { createEchoAgent } from "./agent-adapters/echo.js";
export { createWorkflowManager } from "@uncaged/workflow";
export type { WorkflowManager } from "@uncaged/workflow";
+72 -273
View File
@@ -1,10 +1,30 @@
/**
* IPC message types for parent (kernel) sense worker communication.
* Protocol per RFC §5.2: hub-and-spoke, all messages through engine.
*
* Workflow worker IPC types and parsers live in `@uncaged/workflow`.
*/
import type { Result, WorkflowTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
import type { Result, SenseTrigger } from "@uncaged/nerve-core";
import { err, isPlainRecord, ok, parseSenseTrigger } from "@uncaged/nerve-core";
import type {
KillThreadMessage,
ResumeThreadMessage,
StartThreadMessage,
ThreadEventMessage,
ThreadWorkflowMessage,
WorkflowErrorMessage,
} from "@uncaged/workflow";
import { parseWorkflowParentMessage, parseWorkflowWorkerToParentMessage } from "@uncaged/workflow";
export type {
KillThreadMessage,
ResumeThreadMessage,
StartThreadMessage,
ThreadEventMessage,
ThreadWorkflowMessage,
WorkflowErrorMessage,
} from "@uncaged/workflow";
/** Parent → Worker: trigger one compute cycle for a sense */
export type ComputeMessage = {
@@ -22,40 +42,6 @@ export type HealthRequestMessage = {
type: "health-request";
};
// ---------------------------------------------------------------------------
// Workflow IPC messages (RFC-002 §5.2)
// ---------------------------------------------------------------------------
/** Parent → Workflow Worker: start a new thread */
export type StartThreadMessage = {
type: "start-thread";
runId: string;
workflow: string;
prompt: string;
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
maxRounds: number;
/** When true, roles may skip side effects (thread-level hint on the start frame). */
dryRun: boolean;
};
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
export type ResumeThreadMessage = {
type: "resume-thread";
runId: string;
/** Serialised WorkflowMessage history to rebuild chain (must begin with `__start__`). */
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
/** Safety-valve: max moderator rounds for this thread. */
maxRounds: number;
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
dryRun: boolean;
};
/** Parent → Workflow Worker: kill a specific running thread */
export type KillThreadMessage = {
type: "kill-thread";
runId: string;
};
/** Union of all messages the parent sends to a worker */
export type ParentToWorkerMessage =
| ComputeMessage
@@ -65,18 +51,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; optional shell trigger). */
export type ComputeResultMessage = {
type: "compute-result";
sense: string;
payload: unknown;
};
/** Worker → Parent: sense compute result includes a workflow to start */
export type SenseWorkflowTriggerMessage = {
type: "sense-workflow-trigger";
sense: string;
workflow: WorkflowTrigger;
state: unknown;
trigger: SenseTrigger | null;
};
/** Worker → Parent: compute threw or returned an unexpected error */
@@ -98,56 +78,15 @@ export type HealthResponseMessage = {
inFlightCount: number;
};
// ---------------------------------------------------------------------------
// Workflow Worker → Parent messages (RFC-002 §5.2)
// ---------------------------------------------------------------------------
/** Valid lifecycle event types for a workflow thread. */
export type ThreadEventType =
| "queued"
| "started"
| "step_complete"
| "completed"
| "failed"
| "killed";
/**
* Workflow Worker Parent: a thread lifecycle event.
*/
export type ThreadEventMessage = {
type: "thread-event";
runId: string;
eventType: ThreadEventType;
payload: unknown;
};
/** Workflow Worker → Parent: a thread encountered an unrecoverable error. */
export type WorkflowErrorMessage = {
type: "workflow-error";
runId: string;
error: string;
/** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */
exitCode: number;
};
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
export type ThreadWorkflowMessageMessage = {
type: "thread-workflow-message";
runId: string;
/** The WorkflowMessage produced by the role — persisted for crash recovery. */
message: { role: string; content: string; meta: unknown; timestamp: number };
};
/** Union of all messages a worker sends to the parent */
export type WorkerToParentMessage =
| SignalMessage
| ComputeResultMessage
| ErrorMessage
| ReadyMessage
| HealthResponseMessage
| ThreadEventMessage
| WorkflowErrorMessage
| ThreadWorkflowMessageMessage
| SenseWorkflowTriggerMessage;
| ThreadWorkflowMessage;
const PARENT_MSG_TYPES = new Set([
"compute",
@@ -158,24 +97,6 @@ const PARENT_MSG_TYPES = new Set([
"kill-thread",
]);
function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'start-thread' message missing string 'runId'";
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
return null;
}
function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
if (typeof obj.runId !== "string") return "'resume-thread' message missing string 'runId'";
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
if (typeof obj.maxRounds !== "number")
return "'resume-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
return null;
}
function parseParentCompute(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
if (typeof obj.sense !== "string") {
return err(new Error("IPC 'compute' message missing string 'sense' field"));
@@ -183,40 +104,6 @@ function parseParentCompute(obj: Record<string, unknown>): Result<ParentToWorker
return ok({ type: "compute", sense: obj.sense });
}
function parseParentStartThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
const errMsg = validateStartThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
// Field types are validated above; `Record<string, unknown>` values stay `unknown` to TypeScript.
return ok({
type: "start-thread",
runId: obj.runId,
workflow: obj.workflow,
prompt: obj.prompt,
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as StartThreadMessage);
}
function parseParentResumeThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
const errMsg = validateResumeThreadMsg(obj);
if (errMsg !== null) return err(new Error(errMsg));
// Elements are validated as plain objects by the kernel; trust the wire shape here.
return ok({
type: "resume-thread",
runId: obj.runId,
messages: obj.messages as ResumeThreadMessage["messages"],
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as ResumeThreadMessage);
}
function parseParentKillThread(obj: Record<string, unknown>): Result<ParentToWorkerMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("'kill-thread' message missing string 'runId'"));
}
return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage);
}
/** Validate and parse an unknown IPC message received from the parent process. */
export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage> {
if (!isPlainRecord(raw)) {
@@ -237,27 +124,46 @@ export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage>
case "health-request":
return ok({ type: "health-request" });
case "start-thread":
return parseParentStartThread(obj);
case "resume-thread":
return parseParentResumeThread(obj);
case "kill-thread":
return parseParentKillThread(obj);
case "kill-thread": {
const wf = parseWorkflowParentMessage(raw);
if (!wf.ok) {
return wf;
}
return ok(wf.value as ParentToWorkerMessage);
}
default:
return err(new Error(`Unhandled IPC message type: "${obj.type}"`));
}
}
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 (!("trigger" in obj)) {
return err(new Error("Worker 'compute-result' message missing 'trigger' field"));
}
const triggerRaw = obj.trigger;
if (triggerRaw !== null && !isPlainRecord(triggerRaw)) {
return err(new Error("Worker 'compute-result' trigger must be an object or null"));
}
let trigger: SenseTrigger | null;
if (triggerRaw === null) {
trigger = null;
} else {
const parsed = parseSenseTrigger(triggerRaw);
if (!parsed.ok) return err(parsed.error);
trigger = parsed.value;
}
return ok({
type: "signal",
type: "compute-result",
sense: obj.sense,
payload: obj.payload,
state: obj.state,
trigger,
});
}
@@ -290,132 +196,22 @@ function parseHealthResponseMsg(obj: Record<string, unknown>): Result<WorkerToPa
});
}
function isThreadEventType(value: string): value is ThreadEventType {
switch (value) {
case "queued":
case "started":
case "step_complete":
case "completed":
case "failed":
case "killed":
return true;
default:
return false;
}
}
function parseThreadEventMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-event' message missing string 'runId' field"));
}
if (typeof obj.eventType !== "string" || !isThreadEventType(obj.eventType)) {
return err(
new Error(`Worker 'thread-event' message has invalid 'eventType': "${obj.eventType}"`),
);
}
if (!("payload" in obj)) {
return err(new Error("Worker 'thread-event' message missing 'payload' field"));
}
return ok({
type: "thread-event",
runId: obj.runId,
eventType: obj.eventType,
payload: obj.payload,
});
}
function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'runId' field"));
}
if (typeof obj.error !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
}
const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1;
return ok({
type: "workflow-error",
runId: obj.runId,
error: obj.error,
exitCode,
});
}
const WORKFLOW_WORKER_MSG_TYPES = new Set([
"thread-event",
"workflow-error",
"thread-workflow-message",
]);
const WORKER_MSG_TYPES = new Set([
"signal",
"compute-result",
"error",
"ready",
"health-response",
"thread-event",
"workflow-error",
"thread-workflow-message",
"sense-workflow-trigger",
]);
function parseThreadWorkflowMessageMsg(
obj: Record<string, unknown>,
): Result<WorkerToParentMessage> {
if (typeof obj.runId !== "string") {
return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field"));
}
if (!isPlainRecord(obj.message)) {
return err(new Error("Worker 'thread-workflow-message' missing object 'message' field"));
}
const msg = obj.message;
if (typeof msg.role !== "string") {
return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field"));
}
if (typeof msg.content !== "string") {
return err(
new Error("Worker 'thread-workflow-message' message missing string 'content' field"),
);
}
if (typeof msg.timestamp !== "number") {
return err(
new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"),
);
}
return ok({
type: "thread-workflow-message",
runId: obj.runId,
message: {
role: msg.role,
content: msg.content,
meta: "meta" in msg ? msg.meta : undefined,
timestamp: msg.timestamp,
},
});
}
function parseSenseWorkflowTriggerMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
if (typeof obj.sense !== "string") {
return err(new Error("Worker 'sense-workflow-trigger' message missing string 'sense' field"));
}
if (!isPlainRecord(obj.workflow)) {
return err(
new Error("Worker 'sense-workflow-trigger' message missing object 'workflow' field"),
);
}
const wf = obj.workflow;
if (typeof wf.name !== "string")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing string 'name'"));
if (typeof wf.maxRounds !== "number")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing number 'maxRounds'"));
if (typeof wf.prompt !== "string")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing string 'prompt'"));
if (typeof wf.dryRun !== "boolean")
return err(new Error("Worker 'sense-workflow-trigger' workflow missing boolean 'dryRun'"));
return ok({
type: "sense-workflow-trigger",
sense: obj.sense,
workflow: {
name: wf.name,
maxRounds: wf.maxRounds,
prompt: wf.prompt,
dryRun: wf.dryRun,
},
});
}
/** Validate and parse an unknown IPC message received from a worker process. */
export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage> {
if (!isPlainRecord(raw)) {
@@ -428,12 +224,15 @@ 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 (WORKFLOW_WORKER_MSG_TYPES.has(obj.type)) {
const wf = parseWorkflowWorkerToParentMessage(raw);
if (!wf.ok) {
return wf;
}
return ok(wf.value as WorkerToParentMessage);
}
if (obj.type === "compute-result") return parseComputeResultMsg(obj);
if (obj.type === "error") return parseErrorMsg(obj);
if (obj.type === "health-response") return parseHealthResponseMsg(obj);
if (obj.type === "thread-event") return parseThreadEventMsg(obj);
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj);
if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj);
if (obj.type === "sense-workflow-trigger") return parseSenseWorkflowTriggerMsg(obj);
return ok({ type: "ready" });
}
+1 -1
View File
@@ -9,7 +9,7 @@ import type { NerveConfig } from "@uncaged/nerve-core";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { LogStore } from "@uncaged/nerve-store";
import type { WorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "@uncaged/workflow";
export type KernelFileWatchDeps = {
nerveRoot: string;
+23 -72
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,13 +12,14 @@ import {
type HealthInfo,
type NerveConfig,
type SenseInfo,
type Signal,
type SenseTrigger,
senseTriggerLabels,
} from "@uncaged/nerve-core";
import { routeSenseComputeOutput } from "@uncaged/nerve-core";
import { createLogStore } from "@uncaged/nerve-store";
import type { LogStore } from "@uncaged/nerve-store";
import { createWorkflowManager } from "@uncaged/workflow";
import type { WorkflowManager } from "@uncaged/workflow";
import { createDaemonHandlers } from "./daemon-handlers.js";
import { createDaemonIpcServer } from "./daemon-ipc.js";
import type { DaemonIpcServer } from "./daemon-ipc.js";
@@ -36,11 +37,7 @@ 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";
export type KernelHealth = {
uptime: number;
@@ -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,54 +145,26 @@ 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, trigger: SenseTrigger | 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, {
prompt: workflow.prompt,
maxRounds: workflow.maxRounds,
dryRun: workflow.dryRun,
});
if (trigger !== null) {
logStore.append({
source: "sense",
type: "workflow-launch",
type: "shell-launch",
refId: senseName,
payload: JSON.stringify(workflow),
payload: JSON.stringify(trigger),
timestamp: Date.now(),
});
}
scheduler.onComputeComplete(senseName);
scheduler.onSenseCompleted(senseName);
}
function handleWorkerMessage(raw: unknown): void {
@@ -235,8 +196,8 @@ export function createKernel(
return;
}
if (msg.type === "signal") {
handleSenseWorkerSignal(msg.sense, msg.payload);
if (msg.type === "compute-result") {
handleComputeResult(msg.sense, msg.trigger);
}
}
@@ -270,7 +231,7 @@ export function createKernel(
senseWorkerPool.sendCompute(group, senseName);
}
scheduler = createSenseScheduler(config, bus, triggerFn, {
scheduler = createSenseScheduler(config, triggerFn, {
logStore,
});
@@ -306,7 +267,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 +349,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 +420,6 @@ export function createKernel(
stop,
groups,
senseCount,
bus,
logStore,
workflowManager,
ready,
+32 -170
View File
@@ -1,172 +1,46 @@
import { mkdirSync, readFileSync, readdirSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { drizzle } from "drizzle-orm/node-sqlite";
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
import type { ComputeResult, Result, SenseComputeFn } from "@uncaged/nerve-core";
import { DEFAULT_SENSE_SIGNAL_RETENTION, err, isPlainRecord, ok } from "@uncaged/nerve-core";
/** A Drizzle DB instance (schema-generic) */
export type DrizzleDB = NodeSQLiteDatabase<Record<string, never>>;
import type { Result, SenseComputeFn, SenseTrigger } 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
)`,
if (!existsSync(statePath)) return initialState;
const raw = readFileSync(statePath, "utf8");
return JSON.parse(raw);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(
`[sense-runtime] warning: failed to read state from "${statePath}": ${msg} — using initialState\n`,
);
return ok(undefined);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to create _migrations table: ${msg}`));
return initialState;
}
}
function listMigrationFiles(migrationsDir: string): Result<string[]> {
try {
const files = readdirSync(migrationsDir)
.filter((f) => f.endsWith(".sql"))
.sort();
return ok(files);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to read migrations directory "${migrationsDir}": ${msg}`));
}
}
function applyMigrationFile(sqlite: DatabaseSync, file: string, filePath: string): Result<void> {
let sql: string;
try {
sql = readFileSync(filePath, "utf8");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to read migration file "${filePath}": ${msg}`));
}
const insertJournal = sqlite.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)");
sqlite.exec("BEGIN IMMEDIATE");
try {
sqlite.exec(sql);
insertJournal.run(file, Date.now());
sqlite.exec("COMMIT");
return ok(undefined);
} catch (e) {
try {
sqlite.exec("ROLLBACK");
} catch {
// ignore secondary errors during rollback
}
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Migration "${file}" failed: ${msg}`));
}
export function writeState(statePath: string, state: unknown): void {
const dir = dirname(statePath);
mkdirSync(dir, { recursive: true });
const tmp = join(dir, `.${Date.now()}.tmp`);
writeFileSync(tmp, JSON.stringify(state, null, 2));
renameSync(tmp, statePath);
}
/**
* Run all *.sql migration files in the given directory against a
* `node:sqlite` DatabaseSync, in lexicographic order.
* Tracks applied migrations in _migrations table to avoid re-running.
*/
export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Result<void> {
const tableResult = ensureMigrationsTable(sqlite);
if (!tableResult.ok) return tableResult;
const filesResult = listMigrationFiles(migrationsDir);
if (!filesResult.ok) return filesResult;
const migrationRows = sqlite.prepare("SELECT name FROM _migrations").all();
const applied = new Set<string>(
migrationRows
.filter((r): r is { name: string } => isPlainRecord(r) && typeof r.name === "string")
.map((r) => r.name),
);
for (const file of filesResult.value) {
if (applied.has(file)) continue;
const result = applyMigrationFile(sqlite, file, join(migrationsDir, file));
if (!result.ok) return result;
}
return ok(undefined);
}
/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */
const SIGNAL_INSERTS_PER_PRUNE = 100;
/**
* Open (or create) the SQLite file at `dbPath`, run all migrations in
* `migrationsDir`, and wrap with Drizzle ORM.
*/
export function openSenseDb(
dbPath: string,
migrationsDir: string,
retention: number = DEFAULT_SENSE_SIGNAL_RETENTION,
): Result<{ sqlite: DatabaseSync; db: DrizzleDB; persistSignal: (payload: unknown) => void }> {
let sqlite: DatabaseSync;
try {
mkdirSync(dirname(dbPath), { recursive: true });
sqlite = new DatabaseSync(dbPath);
sqlite.exec("PRAGMA journal_mode=WAL");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return err(new Error(`Failed to open database "${dbPath}": ${msg}`));
}
const migResult = runMigrations(sqlite, migrationsDir);
if (!migResult.ok) return migResult;
// Auto-create _signals table for signal persistence (all senses get this)
sqlite.exec(
`CREATE TABLE IF NOT EXISTS _signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
timestamp INTEGER NOT NULL
)`,
);
const insertStmt = sqlite.prepare("INSERT INTO _signals (payload, timestamp) VALUES (?, ?)");
const pruneStmt = sqlite.prepare(
"DELETE FROM _signals WHERE id <= (SELECT id FROM _signals ORDER BY id DESC LIMIT 1 OFFSET ?)",
);
let insertsSincePrune = 0;
function persistSignal(payload: unknown): void {
const json = JSON.stringify(payload);
insertStmt.run(json, Date.now());
insertsSincePrune += 1;
if (insertsSincePrune >= SIGNAL_INSERTS_PER_PRUNE) {
insertsSincePrune = 0;
pruneStmt.run(retention);
}
}
// Drizzle infers a schema-specific DB type; senses are schema-agnostic at this layer.
const db = drizzle({ client: sqlite }) as DrizzleDB;
return ok({ sqlite, db, persistSignal });
}
/**
* Dynamically import the compute function and table from a sense's index.ts/js.
* The module must export a named `compute` function and a named `table` (SQLiteTable).
* Dynamically import `compute` and `initialState` from a sense's index.ts/js.
* The module must export named `compute` and `initialState`.
*/
export async function loadSenseModule(
senseIndexPath: string,
): Promise<Result<{ compute: SenseComputeFn; table: SQLiteTable }>> {
): Promise<Result<{ compute: SenseComputeFn; initialState: unknown }>> {
let mod: unknown;
try {
@@ -183,33 +57,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; trigger: SenseTrigger | null }>> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
@@ -224,16 +89,13 @@ export async function executeCompute(
: null;
try {
const computePromise = runtime.compute();
const computePromise = runtime.compute(runtime.state);
const result = timeoutPromise
? await Promise.race([computePromise, timeoutPromise])
: await computePromise;
if (result !== null) {
// Cast required: DrizzleDB is schema-agnostic; the sense module guarantees shape compatibility.
await runtime.db.insert(runtime.table).values(result.signal as Record<string, unknown>);
runtime.persistSignal(result.signal);
}
writeState(runtime.statePath, result.state);
runtime.state = result.state;
return ok(result);
} catch (e) {
+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 };
}
+52 -31
View File
@@ -8,24 +8,24 @@
*
* 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
*/
import "./experimental-warning-suppression.js";
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import type { NerveConfig, SenseTrigger } from "@uncaged/nerve-core";
import type { WorkerToParentMessage } from "./ipc.js";
import { parseParentMessage } from "./ipc.js";
import { executeCompute, loadSenseModule, openSenseDb } from "./sense-runtime.js";
import { executeCompute, loadSenseModule, readState } from "./sense-runtime.js";
import type { SenseRuntime } from "./sense-runtime.js";
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
import { ignoreSessionBroadcastSignals } from "./worker-signals.js";
// ---------------------------------------------------------------------------
// IPC helpers
@@ -41,8 +41,38 @@ 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; trigger: SenseTrigger | null },
): void {
send({ type: "compute-result", sense, state: value.state, trigger: value.trigger });
}
function executeShellTriggerIfNeeded(nerveRoot: string, trigger: SenseTrigger | null): void {
if (trigger === null) return;
const child = spawn(trigger.command, {
shell: true,
cwd: nerveRoot,
detached: true,
stdio: ["ignore", "ignore", "pipe"],
});
child.on("error", (err) => {
process.stderr.write(`[sense-worker] shell trigger failed: ${err.message}\n`);
});
if (child.stderr) {
let stderrBuf = "";
child.stderr.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString();
});
child.on("close", (code) => {
if (code !== null && code !== 0 && stderrBuf.length > 0) {
process.stderr.write(
`[sense-worker] shell trigger exited with code ${code}: ${stderrBuf.trimEnd()}\n`,
);
}
});
}
child.unref();
}
function sendError(sense: string, error: string): void {
@@ -72,31 +102,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,
};
}
@@ -138,6 +160,7 @@ async function runCompute(
runtime: SenseRuntime,
timeoutMs: number,
gracePeriodMs: number | null,
nerveRoot: string,
): Promise<void> {
try {
const result = await executeCompute(runtime, timeoutMs);
@@ -149,10 +172,8 @@ 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);
}
executeShellTriggerIfNeeded(nerveRoot, result.value.trigger);
sendComputeResult(senseName, result.value);
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
sendError(senseName, errMsg);
@@ -169,6 +190,7 @@ function handleMessage(
group: string,
senseConfigs: Map<string, { timeout: number | null; gracePeriod: number | null }>,
inFlight: Map<string, Promise<void>>,
nerveRoot: string,
): void {
const parseResult = parseParentMessage(raw);
if (!parseResult.ok) {
@@ -205,7 +227,7 @@ function handleMessage(
const previous = inFlight.get(msg.sense) ?? Promise.resolve();
const next = previous
.then(() => runCompute(msg.sense, runtime, timeoutMs, gracePeriodMs))
.then(() => runCompute(msg.sense, runtime, timeoutMs, gracePeriodMs, nerveRoot))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendError(msg.sense, errMsg);
@@ -236,8 +258,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);
@@ -267,7 +288,7 @@ async function bootstrap(nerveRoot: string, group: string): Promise<void> {
sendReady();
process.on("message", (raw: unknown) => {
handleMessage(raw, runtimes, group, senseConfigs, inFlight);
handleMessage(raw, runtimes, group, senseConfigs, inFlight, nerveRoot);
});
}

Some files were not shown because too many files have changed in this diff Show More