Compare commits
361 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8e42c838b | |||
| eb7de9954f | |||
| fc7fc9158c | |||
| be1f86044e | |||
| 5e054facb2 | |||
| e789c7bb34 | |||
| 06b91c2e63 | |||
| b7200ce51c | |||
| 3eab2e29f5 | |||
| 10c4cf4148 | |||
| 5db80c99a0 | |||
| 49f3d91d1b | |||
| 4d75c8683f | |||
| 99b0e58fb6 | |||
| a1b1d5eaf1 | |||
| 1849789c02 | |||
| 7ce46e7735 | |||
| 0455f928f5 | |||
| 11cedfb5a5 | |||
| ed1bc4e25f | |||
| dc4454d23e | |||
| 7c256620c5 | |||
| 14898e1827 | |||
| 082d2e72f2 | |||
| fbf63e0266 | |||
| 7d89e8ab61 | |||
| e67ddc58d8 | |||
| 06b1e3d785 | |||
| f828ebc28b | |||
| 809a11afe3 | |||
| 4dffcb636b | |||
| c34ec46416 | |||
| d2bb0275dc | |||
| 005739f6bc | |||
| fbe1cc8eba | |||
| ba286a2f27 | |||
| c98e14e9e6 | |||
| 011345e114 | |||
| d9c86c49ae | |||
| 0d78df89b1 | |||
| 0140cdd952 | |||
| bfadfffd40 | |||
| e6093c35db | |||
| de8c7c5150 | |||
| f799cee51f | |||
| d13b59e787 | |||
| 975f15c66d | |||
| 3e51335d91 | |||
| 9c832b0e21 | |||
| 2387b73141 | |||
| 8824421f26 | |||
| b27a6aced8 | |||
| bfd8fe729a | |||
| 748df10e6a | |||
| 3ef9cfcb27 | |||
| 8c9adf08c5 | |||
| 08e8020cb6 | |||
| e287e07dab | |||
| 239dfffb28 | |||
| 6ccb33bf40 | |||
| 0c95a9d716 | |||
| aa64ea86ca | |||
| 431627019a | |||
| 915584ff11 | |||
| 3644cce2c8 | |||
| 9855eba894 | |||
| 554933d43b | |||
| 9c11cd53e6 | |||
| c08e7f085d | |||
| 4a4a03a2bc | |||
| bfb5b9b17d | |||
| 45f5dbe89e | |||
| a566cdabf8 | |||
| 9b4ab6225a | |||
| dfb3c9ec18 | |||
| 77500ee6dd | |||
| accc7c59fd | |||
| 97840e25ab | |||
| 526ca68c99 | |||
| 3d02ea20ad | |||
| 07f1a3d146 | |||
| ede59ebcc2 | |||
| 7de75b5df7 | |||
| 4be465918c | |||
| 732669fab5 | |||
| 7bb6990dc5 | |||
| ce5462cb59 | |||
| 84334b7b09 | |||
| b7d9a37981 | |||
| 18584641bd | |||
| 03e9d20501 | |||
| 623fb3cd3a | |||
| 62434847c4 | |||
| 3d89fc4a7a | |||
| a1dda1d731 | |||
| 1218b5ddbd | |||
| 136aafa209 | |||
| 88bd30a1e4 | |||
| 36e5aed1b1 | |||
| fe90b492c0 | |||
| 7a4e16381c | |||
| aecced587c | |||
| 3950f0e278 | |||
| ea07c2c667 | |||
| 7afed2aa0d | |||
| ce79dbea7e | |||
| 773a23bf9c | |||
| 2e739bef6e | |||
| a0a91d1699 | |||
| 1a4f94c913 | |||
| 984389eb2b | |||
| 8205255c6a | |||
| 814d94f9de | |||
| 1efdc4dcd1 | |||
| 8a1d61985d | |||
| 1933934340 | |||
| e56a01d88a | |||
| 5a7246cb98 | |||
| bda0c69261 | |||
| 0388c6010a | |||
| 03decf0751 | |||
| c765becc91 | |||
| a03ab64c3e | |||
| c45921cd83 | |||
| facb25a959 | |||
| 7ee7c4503a | |||
| b4c78a62aa | |||
| 2529c68062 | |||
| 5744a61716 | |||
| 7cd6f6fa2b | |||
| 787f864732 | |||
| ea7e064177 | |||
| e159a9b7ca | |||
| 6808228c07 | |||
| b269f76b33 | |||
| 27fe50e4b8 | |||
| 2b2895e5be | |||
| 0d54d7fc77 | |||
| 07165b15cc | |||
| ef40512977 | |||
| d0cc8c0840 | |||
| 453294a465 | |||
| c2fd1691bd | |||
| 81663ad524 | |||
| 5899a858ac | |||
| bbbebf18f3 | |||
| d13fbe08db | |||
| c6c3e0142d | |||
| 3bf8421c83 | |||
| 03e103d400 | |||
| e0a9c6a471 | |||
| 09098ef4b2 | |||
| f9c591fdf2 | |||
| 8f3322bc48 | |||
| 57e4d992e2 | |||
| fcd3fe760c | |||
| 0a0121d2ca | |||
| d70e74afde | |||
| e467271cbc | |||
| 40accf3b7c | |||
| 02514972b0 | |||
| b05225fa2a | |||
| 63a54d4641 | |||
| 2fdb6a5edd | |||
| aa88a61e80 | |||
| d555eb4bae | |||
| 3927411ec7 | |||
| cba24d727c | |||
| 0241f0fd3e | |||
| 961b657d7e | |||
| e9042fb403 | |||
| c45a2f36d2 | |||
| 6076a1e5a4 | |||
| 035682bcea | |||
| 7703304ae5 | |||
| 8caf9d681d | |||
| cad51d306b | |||
| c3a03b280d | |||
| aa2e7a290f | |||
| 490dfd5157 | |||
| bdf5ba8b5c | |||
| 082b83adbd | |||
| 2f908489e1 | |||
| 9b027a44f6 | |||
| 991089a228 | |||
| 0ac38c6c83 | |||
| a54cc703c9 | |||
| 4dde101515 | |||
| be8ee3cc2e | |||
| 39d2472a91 | |||
| 44f20b3fb0 | |||
| cdeb5ebd61 | |||
| a834083a0b | |||
| a56dbadea2 | |||
| 715cb8583f | |||
| 2447a78f00 | |||
| acdf244426 | |||
| 62996d299b | |||
| 1f67552ff5 | |||
| 81571b5349 | |||
| 624da3d3d7 | |||
| df71c84eb4 | |||
| bb271e832a | |||
| 452dc26afa | |||
| 8bae382a3c | |||
| 83163d9974 | |||
| 913f9ed57d | |||
| 69e50d8339 | |||
| a4073415b1 | |||
| 203cd8d3c9 | |||
| efd15d4b3c | |||
| e5bdcf9474 | |||
| 2c262fc8e3 | |||
| 6d74260201 | |||
| abe205f96c | |||
| 8f1389defe | |||
| 45fdf3ff9f | |||
| 8e4f191f3f | |||
| c3671d86cf | |||
| 0d0b139890 | |||
| ce20d73ab6 | |||
| 7c999a0689 | |||
| 111b7e2734 | |||
| 01d7435c4a | |||
| 889bbbb474 | |||
| 418ae6a073 | |||
| c6f56155c8 | |||
| 3ce9e3a846 | |||
| 0fff8ef954 | |||
| beada2ae09 | |||
| 47d23bc1a7 | |||
| 3dc835e1de | |||
| 4da2c87a77 | |||
| 529cceba06 | |||
| 020a1bfe85 | |||
| 7ce3970027 | |||
| fcde29ed1c | |||
| 611bc48751 | |||
| 70bea92133 | |||
| 6f2cddd695 | |||
| c4dc707eb0 | |||
| a7ce8401ce | |||
| e9e6df2f5a | |||
| b3b0dad2bb | |||
| e0ce1d995c | |||
| 0a4a2330dc | |||
| d3088c623b | |||
| a7e6caf6e7 | |||
| d4dcd9722f | |||
| 3082568b85 | |||
| 830b0aa762 | |||
| 777d51cc73 | |||
| 06a957d62a | |||
| b2c379cbfd | |||
| 7cb7112ed6 | |||
| 48c81c2e19 | |||
| dd3d4315c4 | |||
| 788ebc6779 | |||
| 8807b0ac6a | |||
| 5b65afdc4b | |||
| f5cb72db50 | |||
| e433e7c2a9 | |||
| 47cc49eab4 | |||
| 65012fbb53 | |||
| 8d00f9cba1 | |||
| ef38b121f7 | |||
| 9bf0b2abb8 | |||
| d93f5c8fa2 | |||
| fa210ec3e0 | |||
| f72b64d481 | |||
| b033a98553 | |||
| 68071ffa1e | |||
| f08ad802b0 | |||
| dcfb00128d | |||
| 9cdac05f2c | |||
| 24a8ec927d | |||
| 554a79775c | |||
| ceb5998fa3 | |||
| 49b5099065 | |||
| 01d2185495 | |||
| 5cedc6a33d | |||
| c291d3a69a | |||
| 7960f5af8b | |||
| 5be14d0d8b | |||
| 0e0eb4eec6 | |||
| cf2b0ac223 | |||
| 1b5a52ea4d | |||
| a084205b47 | |||
| 57550ccfdb | |||
| 37588df402 | |||
| 85dd11c84d | |||
| d80a414530 | |||
| 7f780f0642 | |||
| 33e0d9a705 | |||
| 418d8ee0c8 | |||
| 719c4c1449 | |||
| c8bf4bf547 | |||
| 9b93c4a4d9 | |||
| ca14c5f51d | |||
| 1979e0e16c | |||
| 9102c6698a | |||
| b15fc993f2 | |||
| 6cc8833b2a | |||
| fc76b862ad | |||
| 787e791aba | |||
| 96188c8cda | |||
| f1458f8353 | |||
| 781f571474 | |||
| 640f170de8 | |||
| 119b1f3722 | |||
| 96ea4b46ff | |||
| 57881533a8 | |||
| a62a993a82 | |||
| 3f22eb4664 | |||
| b5913263e4 | |||
| d3ecd2a492 | |||
| 8763440436 | |||
| f270804002 | |||
| 404ee3e34f | |||
| cbc6db6b7d | |||
| b1f6c775ce | |||
| 4ada5ef335 | |||
| 978b1680a3 | |||
| ac34b798c2 | |||
| 00c9b7e406 | |||
| 8b216e3f01 | |||
| 7ded3a758a | |||
| 3257237ba7 | |||
| 2be11ac81a | |||
| 5ed4dfdde3 | |||
| 282a802f06 | |||
| c8e6409837 | |||
| 877da470d7 | |||
| 01f54d14c5 | |||
| 6a689c4094 | |||
| e66a376a77 | |||
| 10f942b577 | |||
| 76b547d37a | |||
| 1b2ff37097 | |||
| 4add0d88c6 | |||
| a8404dc096 | |||
| 891db36152 | |||
| 569c034b49 | |||
| 85fa282d2e | |||
| b75a112c95 | |||
| 606eff6d70 | |||
| 97305bd9af | |||
| 3f2c9df75d | |||
| 1511cfd595 | |||
| 362dc94582 | |||
| 9e7de3b4e0 | |||
| 7320761277 | |||
| 262c77175f | |||
| ae80aef6b4 | |||
| 8d92928951 | |||
| 49ed65a330 | |||
| b7dfe42a96 | |||
| a887fc04ca | |||
| d5931a9e19 | |||
| f12f187d8d | |||
| 1512113a01 |
@@ -0,0 +1,187 @@
|
||||
---
|
||||
description: Nerve project coding conventions — style, patterns, and toolchain
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Nerve Coding Conventions
|
||||
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
↑ ↑
|
||||
"what to observe" "what to do"
|
||||
```
|
||||
|
||||
**Nerve** is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
|
||||
|
||||
### Key Terms
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **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 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
|
||||
|
||||
- **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(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
|
||||
|
||||
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
### Functional-first
|
||||
|
||||
Use `function` + `type`, not `class` + `interface`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type WorkflowLaunch = {
|
||||
senseName: string;
|
||||
workflowName: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
|
||||
return { senseName, workflowName, ts: Date.now() };
|
||||
}
|
||||
|
||||
// ❌ Bad — no class, no interface
|
||||
class WorkflowLaunch implements IWorkflowLaunch { ... }
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| `type` over `interface` | All type definitions use `type` |
|
||||
| `function` over `class` | Pure functions + closures, no class |
|
||||
| No `this` | Functions must not depend on `this` context |
|
||||
| No inheritance | No `extends`, `implements`, `abstract` |
|
||||
| Composition over inheritance | Use function composition |
|
||||
| Immutability first | Use `Readonly<T>`, `as const`, avoid mutation |
|
||||
| No optional properties | Use `T \| null` instead of `?:` — see below |
|
||||
|
||||
### Exceptions
|
||||
|
||||
Classes are allowed when:
|
||||
- Required by a third-party library (e.g. Drizzle's `sqliteTable`)
|
||||
- Error subclasses (`class NerveError extends Error`)
|
||||
|
||||
### No Optional Properties
|
||||
|
||||
Never use `?:`. All nullable fields must be explicit `T | null`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type SenseConfig = {
|
||||
group: string;
|
||||
throttle: string | null;
|
||||
timeout: string | null;
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
type SenseConfig = {
|
||||
group: string;
|
||||
throttle?: string;
|
||||
timeout?: string;
|
||||
};
|
||||
```
|
||||
|
||||
For mutually exclusive fields, use discriminated unions:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type WorkflowConfig =
|
||||
| { concurrency: number; overflow: "drop" }
|
||||
| { concurrency: number; overflow: "queue"; maxQueue: number };
|
||||
```
|
||||
|
||||
## Modules & Exports
|
||||
|
||||
- Always named exports, never default exports
|
||||
- One module = one responsibility, filename = purpose
|
||||
|
||||
```typescript
|
||||
// ✅ Named exports only
|
||||
export function startEngine(config: EngineConfig): Engine { ... }
|
||||
export type EngineConfig = { ... };
|
||||
|
||||
// ❌ No default exports
|
||||
export default function startEngine() { ... }
|
||||
```
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Style | Example |
|
||||
|------|-------|---------|
|
||||
| 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` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use `Result` type for expected failures
|
||||
- `throw` only for unrecoverable bugs (programmer errors)
|
||||
- No try-catch for flow control
|
||||
|
||||
```typescript
|
||||
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
function parseSenseConfig(raw: unknown): Result<SenseConfig> { ... }
|
||||
```
|
||||
|
||||
## Async
|
||||
|
||||
- Always `async/await`, never `.then()` chains
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **pnpm** | Package manager |
|
||||
| **TypeScript** | Type checking (strict mode) |
|
||||
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
||||
| **tsup** | Bundling |
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
pnpm run check # biome check (lint + format)
|
||||
pnpm run format # biome format --write
|
||||
pnpm run build # full build
|
||||
pnpm test # run tests
|
||||
```
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
nerve/
|
||||
packages/
|
||||
core/ # @nerve/core — shared types and utils
|
||||
cli/ # @nerve/cli — CLI entry point
|
||||
daemon/ # @nerve/daemon — engine runtime
|
||||
docs/ # RFCs, conventions
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config (composite project references)
|
||||
```
|
||||
|
||||
- `core` is the shared layer; `cli` and `daemon` both depend on it
|
||||
- `cli` and `daemon` must NOT depend on each other
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: core | cli | daemon | rfc-001 | ...
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
description: Ban dynamic import() in production code — use static imports instead
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# No Dynamic Import in Production Code
|
||||
|
||||
## Rule
|
||||
|
||||
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
|
||||
Always use static top-level `import` statements.
|
||||
|
||||
## Why
|
||||
|
||||
- Static imports enable tree-shaking and bundler optimizations
|
||||
- They make dependencies explicit and discoverable at a glance
|
||||
- Dynamic imports of Node built-ins or project modules add unnecessary async overhead
|
||||
|
||||
## 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
|
||||
|
||||
When suppressing, add a comment directly above:
|
||||
|
||||
```ts
|
||||
// Dynamic import required: user module path resolved at runtime
|
||||
const mod = await import(senseIndexPath);
|
||||
```
|
||||
|
||||
## Test Files
|
||||
|
||||
Test files (`__tests__/**`) are exempt — dynamic import after `vi.mock()` is standard vitest practice.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Nerve Coding Conventions
|
||||
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
↑ ↑
|
||||
"what to observe" "what to do"
|
||||
```
|
||||
|
||||
**Nerve** is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
|
||||
|
||||
### Key Terms
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **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 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
|
||||
|
||||
- **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(state) → Workflow (when triggered) + Log. Logs are the end of the chain.
|
||||
|
||||
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
### Functional-first
|
||||
|
||||
Use `function` + `type`, not `class` + `interface`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type WorkflowLaunch = {
|
||||
senseName: string;
|
||||
workflowName: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
|
||||
return { senseName, workflowName, ts: Date.now() };
|
||||
}
|
||||
|
||||
// ❌ Bad — no class, no interface
|
||||
class WorkflowLaunch implements IWorkflowLaunch { ... }
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| `type` over `interface` | All type definitions use `type` |
|
||||
| `function` over `class` | Pure functions + closures, no class |
|
||||
| No `this` | Functions must not depend on `this` context |
|
||||
| No inheritance | No `extends`, `implements`, `abstract` |
|
||||
| Composition over inheritance | Use function composition |
|
||||
| Immutability first | Use `Readonly<T>`, `as const`, avoid mutation |
|
||||
| No optional properties | Use `T \| null` instead of `?:` — see below |
|
||||
|
||||
### Exceptions
|
||||
|
||||
Classes are allowed when:
|
||||
- Required by a third-party library (e.g. Drizzle's `sqliteTable`)
|
||||
- Error subclasses (`class NerveError extends Error`)
|
||||
|
||||
### No Optional Properties
|
||||
|
||||
Never use `?:`. All nullable fields must be explicit `T | null`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type SenseConfig = {
|
||||
group: string;
|
||||
throttle: string | null;
|
||||
timeout: string | null;
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
type SenseConfig = {
|
||||
group: string;
|
||||
throttle?: string;
|
||||
timeout?: string;
|
||||
};
|
||||
```
|
||||
|
||||
For mutually exclusive fields, use discriminated unions:
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type WorkflowConfig =
|
||||
| { concurrency: number; overflow: "drop" }
|
||||
| { concurrency: number; overflow: "queue"; maxQueue: number };
|
||||
```
|
||||
|
||||
## Modules & Exports
|
||||
|
||||
- Always named exports, never default exports
|
||||
- One module = one responsibility, filename = purpose
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Style | Example |
|
||||
|------|-------|---------|
|
||||
| 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` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use `Result` type for expected failures
|
||||
- `throw` only for unrecoverable bugs (programmer errors)
|
||||
- No try-catch for flow control
|
||||
|
||||
```typescript
|
||||
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
```
|
||||
|
||||
## Async
|
||||
|
||||
- Always `async/await`, never `.then()` chains
|
||||
|
||||
## No Dynamic Import
|
||||
|
||||
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
||||
|
||||
Exceptions (must include a comment):
|
||||
1. `sense-runtime.ts` — user module paths known only at runtime
|
||||
2. `workflow-worker.ts` — user module paths known only at runtime
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **pnpm** | Package manager |
|
||||
| **TypeScript** | Type checking (strict mode) |
|
||||
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
||||
| **tsup** | Bundling |
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
pnpm run check # biome check (lint + format)
|
||||
pnpm run format # biome format --write
|
||||
pnpm run build # full build
|
||||
pnpm test # run tests
|
||||
```
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
nerve/
|
||||
packages/
|
||||
core/ # @nerve/core — shared types and utils
|
||||
cli/ # @nerve/cli — CLI entry point
|
||||
daemon/ # @nerve/daemon — engine runtime
|
||||
docs/ # RFCs, conventions
|
||||
```
|
||||
|
||||
- `core` is the shared layer; `cli` and `daemon` both depend on it
|
||||
- `cli` and `daemon` must NOT depend on each other
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: core | cli | daemon | rfc-001 | ...
|
||||
```
|
||||
@@ -2,3 +2,5 @@ node_modules
|
||||
dist
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
knowledge.db
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
pnpm check
|
||||
pnpm -r test
|
||||
@@ -0,0 +1,171 @@
|
||||
# Adapter Process Isolation
|
||||
|
||||
Describes sandboxing, process isolation, resource limits, and timeout enforcement for adapter invocations in the Nerve workflow system.
|
||||
|
||||
## Process Isolation Model
|
||||
|
||||
Adapters run in a **two-tier isolation** model:
|
||||
|
||||
1. **Workflow Worker Process** — Each workflow runs in a dedicated Node.js worker process (`workflow-worker.ts`) forked from the main daemon
|
||||
2. **Adapter Child Process** — Each adapter spawns CLI tools as child processes via `spawnSafe()` with `shell: false`
|
||||
|
||||
## Resource Limits & Timeouts
|
||||
|
||||
### Adapter-Level Timeouts
|
||||
|
||||
- **Default timeout**: 300 seconds (300,000ms) for both cursor and hermes adapters
|
||||
- **Configurable** via `AgentConfig.timeout` in adapter factory functions
|
||||
- **Wall-clock enforcement** using `setTimeout()` — kills child process with `SIGTERM` on timeout
|
||||
- **AbortSignal support** — external cancellation triggers immediate `SIGTERM`
|
||||
|
||||
### Timeout Behavior
|
||||
|
||||
```ts
|
||||
// Timeout resolution priority (packages/core/src/spawn-safe.ts):
|
||||
// 1. Explicit timeoutMs value
|
||||
// 2. AbortSignal presence → no internal timer (relies on external abort)
|
||||
// 3. DEFAULT_TIMEOUT_MS (300_000) fallback
|
||||
```
|
||||
|
||||
- Child process terminated with `SIGTERM` on timeout/abort
|
||||
- Returns `{ kind: "timeout", stdout, stderr }` error result
|
||||
- **No grace period** — immediate kill
|
||||
- **No SIGKILL escalation** — relies entirely on `SIGTERM` effectiveness
|
||||
|
||||
#### SIGTERM Limitations
|
||||
|
||||
If a child process **ignores or blocks `SIGTERM`** (e.g., signal handlers, blocked delivery):
|
||||
|
||||
- **No fallback to `SIGKILL`** — process may remain alive indefinitely
|
||||
- **No escalation timer** — spawnSafe() does not implement progressive signal escalation
|
||||
- **Potential zombie/orphan risk** — unresponsive processes continue consuming resources
|
||||
- **OS-level cleanup only** — relies on parent process death or OS reaping mechanisms
|
||||
|
||||
## Sandboxing Characteristics
|
||||
|
||||
### What's Isolated
|
||||
|
||||
- **File system**: Child process runs in specified `cwd` (workflow working directory)
|
||||
- **Environment**: Controlled env vars via `nerveCommandEnv()` + optional overrides
|
||||
- **Network**: No explicit restrictions (inherits parent process network access)
|
||||
- **Process tree**: Child processes are direct children, not containerized
|
||||
|
||||
### What's NOT Sandboxed
|
||||
|
||||
- **No resource quotas** (CPU, memory, disk I/O limits)
|
||||
- **No filesystem chroot/containers** — full filesystem access within user permissions
|
||||
- **No network isolation** — can make arbitrary network calls
|
||||
- **No syscall filtering** — no seccomp or similar restrictions
|
||||
|
||||
#### Runtime Resource Enforcement
|
||||
|
||||
**No active resource monitoring or constraints**:
|
||||
|
||||
- **No cgroups** (Linux) — no CPU, memory, or I/O limits enforced
|
||||
- **No job objects** (Windows) — no resource quotas or process tree limits
|
||||
- **No worker_threads resource tracking** — Node.js worker processes run unrestricted
|
||||
- **Pure timeout-based enforcement** — only wall-clock time limits via `setTimeout()`
|
||||
- **OS-scheduled resource sharing** — relies entirely on operating system process scheduling
|
||||
|
||||
Adapters can consume unlimited:
|
||||
- **CPU time** (until timeout)
|
||||
- **Memory** (until OOM)
|
||||
- **Disk I/O** (no quotas)
|
||||
- **Network bandwidth** (no throttling)
|
||||
- **File descriptors** (until ulimit)
|
||||
|
||||
#### Environment Variable Security
|
||||
|
||||
The `nerveCommandEnv()` function provides **minimal sanitization**:
|
||||
|
||||
```ts
|
||||
// spawn-safe.ts lines 47-55
|
||||
export function nerveCommandEnv(): SpawnEnv {
|
||||
const home = homedir();
|
||||
const pnpmHome = join(home, ".local/share/pnpm");
|
||||
return {
|
||||
...process.env, // ← Full parent environment inherited
|
||||
PNPM_HOME: pnpmHome,
|
||||
PATH: `${pnpmHome}:${process.env.PATH ?? ""}`,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- **No filtering of sensitive keys** — `NODE_OPTIONS`, `LD_PRELOAD`, `PYTHONPATH` passed through unchanged
|
||||
- **Full environment inheritance** — all parent process environment variables copied
|
||||
- **Injection risk** — malicious env vars (e.g., `NODE_OPTIONS=--require=evil.js`) affect Node.js child processes
|
||||
- **Path manipulation** — sensitive PATH entries remain accessible to adapters
|
||||
|
||||
## Security Model
|
||||
|
||||
### Execution Context
|
||||
|
||||
- Uses `shell: false` to prevent shell injection attacks
|
||||
- Arguments passed as separate array elements (not shell-parsed)
|
||||
- PATH includes `~/.local/share/pnpm` for tool discovery
|
||||
- Inherits parent process user/group permissions
|
||||
|
||||
#### File Descriptor Management
|
||||
|
||||
```ts
|
||||
// spawn-safe.ts line 122
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
```
|
||||
|
||||
- **stdin closed**: Child receives no input (`stdio[0]: "ignore"`)
|
||||
- **stdout/stderr captured**: Piped to parent for collection (`stdio[1,2]: "pipe"`)
|
||||
- **No explicit fd closing**: Node.js default behavior — inherits other file descriptors
|
||||
- **Parent sockets/pipes accessible**: Child can access parent's open network connections, database handles, etc.
|
||||
- **Security risk**: Adapter processes may access unintended parent file descriptors
|
||||
|
||||
### Attack Surface
|
||||
|
||||
- CLI tools have **full user-level filesystem access**
|
||||
- Can spawn additional processes (not tracked/limited)
|
||||
- Network requests unrestricted
|
||||
- Resource consumption relies on OS-level limits
|
||||
|
||||
## Worker Process Management
|
||||
|
||||
### Workflow Isolation
|
||||
|
||||
- Each workflow type gets dedicated worker process
|
||||
- Worker processes handle multiple concurrent threads (runIds)
|
||||
- Kill flags enable per-thread cancellation without killing worker
|
||||
- Graceful shutdown waits up to 10 seconds for in-flight operations
|
||||
|
||||
#### Cross-RunId Contamination Risks
|
||||
|
||||
**Shared mutable state** poses contamination risks between concurrent runIds:
|
||||
|
||||
- **`process.env` mutations**: Environment changes affect all subsequent runIds in same worker
|
||||
- **`require.cache` pollution**: Module cache shared across all runIds — side effects persist
|
||||
- **Global variables**: Any global state mutations from one runId visible to others
|
||||
- **`process.cwd()` changes**: Working directory changes affect entire worker process
|
||||
- **File descriptors**: Open files/sockets shared between runId executions
|
||||
|
||||
**No runId-specific scoping** implemented:
|
||||
- Worker reuses single Node.js process for efficiency
|
||||
- Each role execution sees cumulative environment from previous runIds
|
||||
- **Mitigation relies on adapter discipline** — clean implementations avoid global mutations
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Adapter failures don't crash the worker process
|
||||
- Timeout/abort errors are isolated to specific role execution
|
||||
- Worker process survives adapter failures and continues serving other threads
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
# Example nerve.yaml configuration for timeout overrides
|
||||
workflows:
|
||||
my-workflow:
|
||||
roles:
|
||||
coder:
|
||||
adapter:
|
||||
type: cursor
|
||||
timeout: 600000 # 10 minutes in milliseconds
|
||||
```
|
||||
|
||||
Timeout configuration happens at the adapter creation level, not as a system-wide sandbox policy.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Agent Adapters (RFC-003)
|
||||
|
||||
Adapter = capability. Role = scenario. Workflows declare adapters directly via import.
|
||||
|
||||
## AgentFn Protocol
|
||||
|
||||
```ts
|
||||
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>
|
||||
```
|
||||
|
||||
- Input: thread context (`{ threadId, start, steps }`) + system prompt (role identity)
|
||||
- Output: **single-shot `Promise<string>`** — no streaming support
|
||||
- Adapter handles tool-specific details internally
|
||||
|
||||
### Streaming Limitations
|
||||
|
||||
The `AgentFn` protocol does **not** support streaming responses (`AsyncIterable<string>` or `ReadableStream`). It's strictly limited to single-shot `Promise<string>` returns.
|
||||
|
||||
For long-running or incremental agent outputs:
|
||||
- CLI tools buffer full output until completion
|
||||
- Timeout enforcement via `timeoutMs` (default 300s)
|
||||
- No intermediate results exposed to workflow logic
|
||||
- Progress indication happens at the CLI tool level only
|
||||
|
||||
## Available Adapters
|
||||
|
||||
| Package | Adapter | Tool |
|
||||
|---------|---------|------|
|
||||
| `@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) |
|
||||
|
||||
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
|
||||
|
||||
Adapters are passed directly to `createRole`:
|
||||
|
||||
```ts
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
|
||||
|
||||
const coder = createRole(cursorAdapter, prompt, schema, extractConfig);
|
||||
```
|
||||
|
||||
No registry, no config indirection. TypeScript catches missing adapters at compile time.
|
||||
|
||||
## Extract Layer
|
||||
|
||||
Parses agent raw string → typed meta. Configured in `nerve.yaml`:
|
||||
|
||||
```yaml
|
||||
extract:
|
||||
provider: dashscope
|
||||
model: qwen-plus
|
||||
```
|
||||
|
||||
Two-level merge: global → role override. Retry once on parse failure (feeds error back to LLM), then throw `ExtractError`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
When adapters' underlying CLI tools (e.g., `cursor-agent` or `hermes`) fail, errors are surfaced **synchronously via rejection** with no fallback/retry logic:
|
||||
|
||||
- **Missing/unavailable tool**: `spawn_failed` error when CLI binary not found in `$PATH`
|
||||
- **Non-zero exit code**: `non_zero_exit` error with captured stdout/stderr
|
||||
- **Timeout**: `timeout` error when execution exceeds configured `timeoutMs`
|
||||
- **Abort signal**: `aborted` error when `AbortSignal` triggers cancellation
|
||||
|
||||
All errors are immediately thrown as `Error` instances with descriptive messages (e.g., `"cursor-agent: exitCode=7 stdout=... stderr=..."`). No automatic retries or fallback adapters.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Nerve Architecture
|
||||
|
||||
Observation engine for autonomous agents — sense the world, react to changes, run workflows.
|
||||
|
||||
## Core Pipeline
|
||||
|
||||
```
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
```
|
||||
|
||||
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger Senses (prevents feedback loops).
|
||||
|
||||
## Two Extension Points
|
||||
|
||||
| Extension | Question | Nature |
|
||||
|-----------|----------|--------|
|
||||
| **Sense** | What to observe & when to react | `compute(state)` stateful function + YAML config (interval / on) |
|
||||
| **Workflow** | What to do | Roles + Moderator |
|
||||
|
||||
Senses own both the "what" (compute logic) and the "when" (config-driven scheduling). A Sense can trigger a Workflow by returning `{ state, workflow: { name, prompt, maxRounds, dryRun } }`.
|
||||
|
||||
## Process Isolation
|
||||
|
||||
- One worker per Sense group (long-lived)
|
||||
- One worker per Workflow type (on-demand)
|
||||
- Workers never talk to each other
|
||||
- All user code runs in isolated Workers; kernel never loads user code directly
|
||||
- **`WorkerRuntime`** (`packages/daemon/src/worker-runtime.ts`) centralizes fork lifecycle for both sense groups (`worker-pool.ts`) and workflow types (`workflow-manager.ts`); see `.knowledge/worker-isolation.md`
|
||||
|
||||
## Storage Systems
|
||||
|
||||
- **Log Store** — SQLite with WAL mode for audit trails and workflow state
|
||||
- **Sense State** — JSON files per sense (`data/senses/<name>.json`), atomically written
|
||||
- **Knowledge Store** — Vector search index for project context
|
||||
- **Blob Store** — Content-addressable storage for large artifacts
|
||||
@@ -0,0 +1,86 @@
|
||||
# Nerve CLI
|
||||
|
||||
`nerve` — CLI entry point for nerve workspace management.
|
||||
|
||||
## Workspace Lifecycle
|
||||
|
||||
```bash
|
||||
nerve init # scaffold a new workspace (nerve.yaml, senses/, workflows/)
|
||||
nerve init --force # reinitialize workspace even if ~/.uncaged-nerve/ exists (preserves data/)
|
||||
nerve init --from <git-url> # clone existing workspace from git repository
|
||||
nerve validate # validate nerve.yaml config
|
||||
nerve dev # run kernel foreground (development, Ctrl+C to stop)
|
||||
nerve start # start daemon (background)
|
||||
nerve stop # stop daemon
|
||||
nerve status # check daemon health (uptime, senses, workflows)
|
||||
nerve daemon # restart daemon (stop + start)
|
||||
```
|
||||
|
||||
### Init Behavior
|
||||
|
||||
**Default `nerve init`**: Creates workspace at `~/.uncaged-nerve/`. If this directory already exists and is non-empty, **exits with error** requiring `--force` flag.
|
||||
|
||||
**Force mode `nerve init --force`**: Reinitializes workspace even if `~/.uncaged-nerve/` exists. **Preserves `data/` directory** (containing sense state files and logs) but overwrites all config files.
|
||||
|
||||
**Git clone `nerve init --from <url>`**: Clones existing repository to `~/.uncaged-nerve/`. Requires empty target directory.
|
||||
|
||||
## Sense Management
|
||||
|
||||
```bash
|
||||
nerve create sense <name> # scaffold a new sense (compute + initialState)
|
||||
nerve sense list # list configured senses
|
||||
nerve sense trigger <name> # manually trigger a sense compute
|
||||
```
|
||||
|
||||
## Workflow Management
|
||||
|
||||
```bash
|
||||
nerve create workflow <name> # scaffold a new workflow
|
||||
nerve workflow trigger <name> --prompt "..." [--max-rounds N] [--dry-run]
|
||||
nerve workflow list # list configured workflows
|
||||
nerve thread # list active (queued/started) workflow threads
|
||||
```
|
||||
|
||||
## Knowledge
|
||||
|
||||
```bash
|
||||
nerve knowledge sync # chunk files per knowledge.yaml, compute embeddings → knowledge.db
|
||||
nerve knowledge query "text" # search indexed knowledge (cosine similarity)
|
||||
nerve knowledge query -g "text" # global search across all indexed repos
|
||||
nerve knowledge query --repo /path "text" # search specific repo
|
||||
```
|
||||
|
||||
## Logs & Store
|
||||
|
||||
```bash
|
||||
nerve logs # view daemon logs (last 50 lines)
|
||||
nerve logs -f # follow logs (tail -f style)
|
||||
nerve logs -n 200 # last N lines
|
||||
nerve store archive # archive old log entries to JSONL
|
||||
```
|
||||
|
||||
## Remote
|
||||
|
||||
```bash
|
||||
nerve remote add <name> <url> # add a remote daemon endpoint
|
||||
nerve status --remote <name> # check remote daemon health
|
||||
```
|
||||
|
||||
## Workspace Layout
|
||||
|
||||
```
|
||||
my-agent/
|
||||
nerve.yaml # senses, workflows, extract config
|
||||
knowledge.yaml # knowledge index config (optional)
|
||||
senses/
|
||||
cpu-usage/
|
||||
src/index.ts # compute(state) + initialState export
|
||||
workflows/
|
||||
cleanup/
|
||||
src/index.ts # workflow definition
|
||||
data/
|
||||
senses/ # sense state JSON files (auto-generated)
|
||||
logs.db # log store (auto-generated)
|
||||
knowledge.db # generated by nerve knowledge sync
|
||||
.knowledge/ # curated knowledge cards
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
# Nerve Coding Conventions
|
||||
|
||||
## Functional-First
|
||||
|
||||
- `type` over `interface`, `function` over `class`
|
||||
- No `this`, no inheritance, composition over inheritance
|
||||
- Immutability first: `Readonly<T>`, `as const`
|
||||
|
||||
## No Optional Properties
|
||||
|
||||
Never use `?:`. Use `T | null` for nullable fields. Use discriminated unions for mutually exclusive fields.
|
||||
|
||||
```ts
|
||||
// ✅ Good
|
||||
type Config = { throttle: string | null }
|
||||
|
||||
// ❌ Bad
|
||||
type Config = { throttle?: string }
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- `Result<T, E>` for expected failures
|
||||
- `throw` only for programmer errors (bugs)
|
||||
- No try-catch for flow control
|
||||
|
||||
### Result<T, E> Type
|
||||
|
||||
Defined in `@uncaged/nerve-core` (`packages/core/src/result.ts`):
|
||||
|
||||
```ts
|
||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
```
|
||||
|
||||
**Discriminated union** with tagged `ok` field. Helper functions:
|
||||
- `ok(value)` → `{ ok: true, value }`
|
||||
- `err(error)` → `{ ok: false, error }`
|
||||
|
||||
**Exhaustive handling**: Pattern is `if (!result.ok) { handle error }` then access `result.value`.
|
||||
No compiler enforcement - relies on manual discipline and TypeScript's flow control analysis.
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Style |
|
||||
|------|-------|
|
||||
| Files | `kebab-case.ts` |
|
||||
| Types | `PascalCase` |
|
||||
| Functions/vars | `camelCase` |
|
||||
| Constants | `UPPER_SNAKE` |
|
||||
|
||||
## Exports
|
||||
|
||||
- Always named exports, never default
|
||||
- One module = one responsibility
|
||||
|
||||
### Module Naming Conventions
|
||||
|
||||
**Primary exports** use descriptive, unambiguous names:
|
||||
- Functions: `createXxx()`, `parseXxx()`, `xxxAgent()` (e.g., `createCursorAdapter`, `cursorAgent`)
|
||||
- Types: Domain-specific prefixes (e.g., `CursorAgentOptions`, `SenseComputeFn`, `ThreadContext`)
|
||||
- Constants: `UPPER_SNAKE_CASE` with context (e.g., `DEFAULT_ENGINE_MAX_ROUNDS`, `CURSOR_ADAPTER_DEFAULT_MS`)
|
||||
|
||||
**Avoiding ambiguity**:
|
||||
- Package-scoped naming: `@uncaged/nerve-adapter-cursor` exports `cursorAgent`, `createCursorAdapter`
|
||||
- Factory pattern: `createXxxAdapter()` for configurable instances, `xxxAdapter` for defaults
|
||||
- Descriptive type prefixes prevent collision (e.g., `CursorAgentOptions` vs `HermesAgentOptions`)
|
||||
|
||||
## Async
|
||||
|
||||
- Always `async/await`, never `.then()` chains
|
||||
- Use `AbortSignal` for cancellation: `AbortController` to create signals, pass to long-running operations
|
||||
- `spawn-safe.ts` and adapter functions accept `abortSignal: AbortSignal | null` parameter
|
||||
- On abort: child processes receive `SIGTERM`, async operations should check `signal.aborted`
|
||||
- No enforced Biome/Vitest rules for AbortSignal usage (manual discipline required)
|
||||
|
||||
## No Dynamic Import
|
||||
|
||||
Static `import` only. Exceptions: `sense-runtime.ts`, `workflow-worker.ts` (runtime module paths).
|
||||
|
||||
## Toolchain
|
||||
|
||||
pnpm + TypeScript (strict) + Biome (lint/format) + Vitest (test)
|
||||
|
||||
```bash
|
||||
pnpm run check # biome check
|
||||
pnpm test # vitest
|
||||
pnpm run build # full build
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# Knowledge Layer (RFC-003 Phase 6)
|
||||
|
||||
Local-first, repo-scoped knowledge base for project context.
|
||||
|
||||
## Files
|
||||
|
||||
- `knowledge.yaml` — repo root, defines include/exclude globs
|
||||
- `knowledge.db` — SQLite, stores chunks + embeddings
|
||||
- `.knowledge/` — curated knowledge cards (indexed by sync)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
nerve knowledge sync # chunk files, compute embeddings, write to knowledge.db
|
||||
nerve knowledge query "query" # search by cosine similarity (or word overlap fallback)
|
||||
nerve knowledge query -g "query" # global search across all indexed repos
|
||||
nerve knowledge query --repo /path "query" # search specific repo
|
||||
```
|
||||
|
||||
## Embedding
|
||||
|
||||
- **Default model**: Dashscope text-embedding-v3 (1024 dimensions)
|
||||
- **Remote service**: configured via `EMBED_SERVICE_URL` env var (self-hosted Cloudflare Worker + KV cache)
|
||||
- **Model configuration**: No mechanism to specify alternate models — hardcoded to text-embedding-v3 in remote service
|
||||
- **Vector dimensions**: Fixed at 1024 (Float32Array, stored as 4096-byte Buffer blobs in SQLite)
|
||||
- **Cache**: content-addressable (sha256 of model+text), never expires
|
||||
- **Fallback**: word-overlap scoring when embed service not configured
|
||||
|
||||
### Configuration
|
||||
|
||||
The embedding model is **not configurable** through `knowledge.yaml` or other config files. The remote service at `embed.shazhou.workers.dev` uses Dashscope text-embedding-v3 exclusively. To use different models, you would need to:
|
||||
|
||||
1. Deploy your own embedding service compatible with the same API
|
||||
2. Point `EMBED_SERVICE_URL` to your service
|
||||
3. Ensure vector dimensions match (1024) or modify knowledge database schema
|
||||
|
||||
## Chunking
|
||||
|
||||
- Markdown: split by headings, large sections split further by paragraphs (max 24)
|
||||
- TypeScript/JS: split by function declarations, fallback to paragraphs
|
||||
- Other files: single chunk
|
||||
|
||||
## Env Config
|
||||
|
||||
```
|
||||
EMBED_SERVICE_URL=https://embed.shazhou.workers.dev
|
||||
EMBED_AUTH_TOKEN=<token>
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
# Nerve Monorepo Structure
|
||||
|
||||
```
|
||||
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, 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
|
||||
adapter-hermes/ # @uncaged/nerve-adapter-hermes — hermes chat CLI adapter
|
||||
khala/ # Khala — Sense marketplace (future)
|
||||
skills/ # nerve-managed skills
|
||||
docs/ # RFCs, conventions
|
||||
.knowledge/ # curated knowledge cards (this directory)
|
||||
```
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
- `core` is the shared layer — everyone depends on it
|
||||
- `cli` and `daemon` must NOT depend on each other
|
||||
- Adapter packages depend only on `core`
|
||||
- `workflow-utils` depends on `core`
|
||||
@@ -0,0 +1,67 @@
|
||||
# Sense
|
||||
|
||||
A stateful `compute(state)` function that samples or derives external data. Returns new state and an optional workflow trigger.
|
||||
|
||||
## Contract
|
||||
|
||||
Each sense module (`src/index.ts`) must export:
|
||||
|
||||
```ts
|
||||
type MyState = { lastRun: number | null };
|
||||
|
||||
export const initialState: MyState = { lastRun: null };
|
||||
|
||||
export async function compute(state: MyState): Promise<{
|
||||
state: MyState;
|
||||
workflow: WorkflowTrigger | null;
|
||||
}> {
|
||||
// ... observe external world, derive new state
|
||||
return { state: { lastRun: Date.now() }, workflow: null };
|
||||
}
|
||||
```
|
||||
|
||||
**Function Signature:**
|
||||
- `compute(state: S)` — receives previous state (or `initialState` on first run)
|
||||
- Returns `{ state: S; workflow: WorkflowTrigger | null }`
|
||||
- `workflow: null` → no workflow triggered
|
||||
- `workflow: { name, maxRounds, prompt, dryRun }` → triggers a workflow
|
||||
|
||||
**State Persistence:**
|
||||
- State stored as JSON at `data/senses/<name>.json`
|
||||
- Read on worker startup; if missing or corrupt, `initialState` is used
|
||||
- Written atomically (temp file + rename) after each successful compute
|
||||
- Memory state updated only after disk write succeeds
|
||||
|
||||
**Error Handling:**
|
||||
- Exceptions caught by worker, logged as errors (state unchanged)
|
||||
- State payload must be JSON-serializable
|
||||
- Invalid workflow triggers rejected by daemon (workflow not started, compute still succeeds)
|
||||
|
||||
**Timeout & Scheduling Semantics:**
|
||||
- Timeout priority: explicit config → AbortSignal → DEFAULT_TIMEOUT_MS (30s)
|
||||
- Enforced via `Promise.race()` with timeout promise
|
||||
- Grace period can trigger `process.exit(1)` after timeout (kills worker group)
|
||||
- Interval translation: YAML config values used directly as milliseconds in `setInterval()`
|
||||
- Jitter control: throttle mechanism prevents rapid-fire, single deferred trigger per throttle window
|
||||
|
||||
## Config (nerve.yaml)
|
||||
|
||||
```yaml
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system # senses in same group share a worker
|
||||
throttle: 10s # min interval between computes
|
||||
timeout: 30s # max compute duration
|
||||
grace_period: 5s # wait before first compute
|
||||
interval: 30s # periodic trigger (optional)
|
||||
on: [disk-pressure] # trigger when another sense completes (optional)
|
||||
```
|
||||
|
||||
## Manual Trigger Context
|
||||
|
||||
**`nerve sense trigger <name>`** sends IPC message to running daemon. The compute runs in the sense's worker process with:
|
||||
|
||||
- **State**: Read from `data/senses/<name>.json` (or `initialState` if missing)
|
||||
- **Environment**: Inherits daemon process environment
|
||||
- **Isolation**: Runs in forked child process (worker) with full filesystem access within user permissions
|
||||
- **Persistence**: State written to JSON file after successful compute
|
||||
@@ -0,0 +1,31 @@
|
||||
# Sense compute → workflow (RFC #308)
|
||||
|
||||
Stateful senses no longer emit signals or pass outputs through `routeSenseComputeOutput`. The worker runs `compute(state)` and returns `{ state, workflow }`.
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Sense worker: compute(state) → { state, workflow }
|
||||
↓
|
||||
persist state JSON (data/senses/<name>.json)
|
||||
↓
|
||||
IPC compute-result → kernel
|
||||
↓
|
||||
workflow !== null → parseWorkflowTrigger (validation) → workflowManager.startWorkflow
|
||||
scheduler.onSenseCompleted(senseName) → dependents with `on: [senseName]`
|
||||
```
|
||||
|
||||
## Workflow trigger shape
|
||||
|
||||
When `workflow` is non-null it must be a plain object validated by `parseWorkflowTrigger()` in `packages/core/src/sense.ts`:
|
||||
|
||||
- `name`: non-empty string
|
||||
- `maxRounds`: integer ≥ 1
|
||||
- `prompt`: string
|
||||
- `dryRun`: boolean
|
||||
|
||||
Invalid triggers are rejected by the daemon when handling the worker message (workflow is not started).
|
||||
|
||||
## Scheduling
|
||||
|
||||
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).
|
||||
@@ -0,0 +1,131 @@
|
||||
# Storage Layer
|
||||
|
||||
Nerve uses multiple storage systems designed for different data types and access patterns.
|
||||
|
||||
## Core Storage Components
|
||||
|
||||
### 1. Log Store (`logs.db`)
|
||||
Append-only audit trail implemented in SQLite with WAL mode.
|
||||
|
||||
**Schema:**
|
||||
- `logs` — all system events (signals, workflow transitions, sense outputs)
|
||||
- `meta` — key-value store for system metadata
|
||||
- `workflow_runs` — materialized view of workflow execution state
|
||||
|
||||
**Key Features:**
|
||||
- Atomic workflow state updates via transactions
|
||||
- Thread message persistence for crash recovery
|
||||
- Configurable log archival to JSONL files
|
||||
- Full-text search across log entries
|
||||
|
||||
### 2. Sense State Files
|
||||
Each sense persists its state as a JSON file.
|
||||
|
||||
**Characteristics:**
|
||||
- One JSON file per sense at `data/senses/<name>.json`
|
||||
- Atomically written (temp file + rename) after each successful compute
|
||||
- Read on worker startup; `initialState` used if missing or corrupt
|
||||
- No cross-sense data sharing
|
||||
|
||||
### 3. Knowledge Store (`knowledge.db`)
|
||||
Vector-enabled search index for project context.
|
||||
|
||||
**Contents:**
|
||||
- Chunked source files with embeddings
|
||||
- Curated knowledge cards from `.knowledge/`
|
||||
- Semantic search capabilities
|
||||
- Global vs. repo-scoped search modes
|
||||
|
||||
### 4. Blob Store (CAS)
|
||||
Content-addressable storage for large artifacts.
|
||||
|
||||
**Design:**
|
||||
- SHA-256 based file naming
|
||||
- Automatic deduplication
|
||||
- Used for workflow artifacts and large payloads
|
||||
|
||||
## Consistency & Isolation Mechanisms
|
||||
|
||||
### SQLite WAL Mode
|
||||
All SQLite databases use `PRAGMA journal_mode=WAL` for:
|
||||
- **Writer-reader concurrency** — readers don't block writers
|
||||
- **Atomic writes** — each transaction is fully applied or rolled back
|
||||
- **Crash recovery** — WAL provides consistent state after crashes
|
||||
|
||||
### Transaction Management
|
||||
|
||||
#### Log Store Transactions
|
||||
Uses `BEGIN IMMEDIATE` transactions (`packages/store/src/log-store.ts`):
|
||||
```typescript
|
||||
function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
||||
db.exec("BEGIN IMMEDIATE"); // Exclusive write lock
|
||||
try {
|
||||
const result = fn();
|
||||
db.exec("COMMIT");
|
||||
return result;
|
||||
} catch (e) {
|
||||
db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Operations:**
|
||||
- `upsertWorkflowRun()` — atomically writes log entry + workflow state
|
||||
- `archiveLogs()` — transactional export + delete + watermark update
|
||||
|
||||
#### Sense State Isolation
|
||||
- Each sense has its own JSON state file
|
||||
- No cross-sense coordination required
|
||||
- State files are independent and self-contained
|
||||
|
||||
### Process-Level Isolation
|
||||
|
||||
#### Worker Process Architecture
|
||||
- **One worker per sense group** — prevents data races within group
|
||||
- **One worker per workflow type** — isolated execution contexts
|
||||
- **No shared memory** — all communication via IPC messages
|
||||
|
||||
#### Concurrency Control
|
||||
Workflow manager enforces limits per workflow:
|
||||
```yaml
|
||||
workflows:
|
||||
my-workflow:
|
||||
concurrency: 2 # Max parallel threads
|
||||
overflow: "queue" # or "drop"
|
||||
maxQueue: 10 # Queue depth limit
|
||||
```
|
||||
|
||||
### Consistency Guarantees & Failure Modes
|
||||
|
||||
**Strong Consistency (Single Database)**:
|
||||
1. **Within Log Store** — ACID transactions with immediate consistency
|
||||
2. **Within Sense DB** — WAL mode ensures atomic commits per database
|
||||
3. **Workflow State** — `upsertWorkflowRun()` atomically updates log + materialized view
|
||||
|
||||
**No Cross-Database Consistency**:
|
||||
- No distributed transactions across multiple SQLite files
|
||||
- Log Store and Sense Databases can temporarily diverge during failures
|
||||
- State persistence and workflow triggering are separate, non-atomic operations
|
||||
|
||||
**Failure Recovery Mechanisms**:
|
||||
- **Sense worker crash**: State rebuilt from JSON state file on respawn (or `initialState` if corrupt)
|
||||
- **Workflow worker crash**: Thread state recovered from log store message history
|
||||
- **Kernel crash**: All workers respawned, state recovered from persistent stores
|
||||
- **Log Store corruption**: WAL recovery on database open
|
||||
- **Sense state corruption**: Falls back to `initialState` with stderr warning
|
||||
|
||||
**Rollback Scenarios**:
|
||||
- **Log write failure**: Transaction rolled back, no state changes persisted
|
||||
- **Sense compute failure**: Error logged, no signal/workflow emitted
|
||||
- **Workflow failure**: Thread marked as failed in materialized view
|
||||
- **IPC failure**: Worker respawned, pending operations lost (not rolled back)
|
||||
|
||||
## Archive Strategy
|
||||
|
||||
Logs older than retention window (default 30 days) are:
|
||||
1. Exported to `data/archive/logs/YYYY-MM-DD.jsonl`
|
||||
2. Deleted from active database
|
||||
3. Watermark updated to prevent re-processing
|
||||
|
||||
This keeps the active database size bounded while preserving audit trails.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Worker Isolation
|
||||
|
||||
Nerve's worker architecture ensures complete isolation between different types of user code while maintaining system stability.
|
||||
|
||||
## Process Architecture
|
||||
|
||||
```
|
||||
Kernel (Main Process)
|
||||
├── Sense Worker (Group A) ── sense-1, sense-2
|
||||
├── Sense Worker (Group B) ── sense-3, sense-4
|
||||
├── Workflow Worker (cleanup) ── cleanup workflow instances
|
||||
└── Workflow Worker (review) ── review workflow instances
|
||||
```
|
||||
|
||||
### WorkerRuntime (RFC-006)
|
||||
|
||||
Forked worker processes are managed by **`WorkerRuntime`** (`worker-runtime.ts`): one Node child per logical key, cold start, optional respawn after crash, drain/evict, and coordinated shutdown over IPC. **`worker-pool.ts`** (sense groups) and **`workflow-manager.ts`** (workflow types) both configure and delegate to `createWorkerRuntime` instead of owning ad-hoc fork logic.
|
||||
|
||||
Worker **entrypoints** (`sense-worker.ts`, `workflow-worker.ts`) import lightweight helpers only — e.g. `worker-signals.ts` for session broadcast signal handling — so they do not pull in the parent-side runtime module.
|
||||
|
||||
## Isolation Boundaries
|
||||
|
||||
### 1. Sense Workers
|
||||
- **One worker per sense group** (configured in `nerve.yaml`)
|
||||
- Groups share a child process but have isolated execution contexts
|
||||
- Crash in one sense doesn't affect other groups
|
||||
- Each group has its own JSON state files
|
||||
|
||||
### 2. Workflow Workers
|
||||
- **One worker per workflow type** (spawned on-demand)
|
||||
- Multiple threads of the same workflow share a worker process
|
||||
- Concurrency limits enforced at the workflow level
|
||||
- Workers terminate when no active threads remain
|
||||
|
||||
### 3. Kernel Protection
|
||||
- **User code never runs in kernel process**
|
||||
- All `compute()` and workflow role functions run in workers
|
||||
- Kernel only handles IPC, scheduling, and coordination
|
||||
- System remains stable even with infinite loops or crashes in user code
|
||||
|
||||
## Worker Lifecycle
|
||||
|
||||
### Sense Workers
|
||||
```
|
||||
nerve daemon start → spawn worker per group → long-lived process
|
||||
→ hot reload on file changes
|
||||
→ respawn on crash
|
||||
```
|
||||
|
||||
### Workflow Workers
|
||||
```
|
||||
workflow trigger → check existing worker → reuse or spawn
|
||||
→ execute thread
|
||||
→ terminate when idle
|
||||
```
|
||||
|
||||
## Communication Patterns
|
||||
|
||||
### Kernel ↔ Sense Worker
|
||||
- IPC via child process stdio
|
||||
- JSON-formatted messages
|
||||
- Worker reports compute results back to kernel
|
||||
- Bidirectional: kernel can request immediate computes
|
||||
|
||||
### Kernel ↔ Workflow Worker
|
||||
- Similar IPC protocol
|
||||
- Workflow definition loaded in worker
|
||||
- Role execution results streamed back
|
||||
- Thread state managed in kernel
|
||||
|
||||
## Resource Limits & Control
|
||||
|
||||
### Timeout Enforcement
|
||||
Configurable timeouts per sense (in `nerve.yaml`):
|
||||
```yaml
|
||||
senses:
|
||||
my-sense:
|
||||
timeout: 30000 # Execution timeout (ms)
|
||||
gracePeriod: 5000 # Grace period before hard kill
|
||||
```
|
||||
|
||||
**Timeout Implementation:**
|
||||
- `AbortController` for async operations
|
||||
- `Promise.race()` between compute and timeout
|
||||
- Grace period triggers `process.exit(1)` to kill entire worker group
|
||||
|
||||
### Memory & CPU Limits
|
||||
**No Application-Level Resource Quotas**:
|
||||
- No memory caps, CPU throttling, or disk I/O limits enforced by Nerve
|
||||
- Workers can consume arbitrary system resources until OS limits
|
||||
- No cgroup/container isolation — full filesystem access within user permissions
|
||||
- No syscall filtering (no seccomp restrictions)
|
||||
|
||||
**OS-Level Constraints Only**:
|
||||
- Process memory limited by system `ulimit -m`
|
||||
- CPU usage bounded by scheduler only
|
||||
- Network requests unrestricted
|
||||
- Can spawn additional processes (not tracked by Nerve)
|
||||
|
||||
### Concurrency Control
|
||||
|
||||
#### Sense Workers
|
||||
- One active compute per sense at a time (serialized via promise chains)
|
||||
- No memory sharing between sense groups
|
||||
- Crash isolation: one sense crash doesn't affect other groups
|
||||
|
||||
#### Workflow Workers
|
||||
Per-workflow limits configured in `nerve.yaml`:
|
||||
```yaml
|
||||
workflows:
|
||||
my-workflow:
|
||||
concurrency: 2 # Max parallel threads
|
||||
overflow: "drop" # or "queue"
|
||||
maxQueue: 10 # Queue size limit
|
||||
```
|
||||
|
||||
### Process Management
|
||||
|
||||
#### Signal Handling
|
||||
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("SIGTERM", () => {});
|
||||
```
|
||||
|
||||
#### Graceful Shutdown & State Handoff
|
||||
**Sense Workers**:
|
||||
- IPC `shutdown` message → `process.exit(0)` (immediate)
|
||||
- No graceful termination period for senses
|
||||
- State rebuilt from JSON state files on respawn (no handoff needed)
|
||||
|
||||
**Workflow Workers**:
|
||||
- IPC `shutdown` → wait for in-flight threads to complete
|
||||
- Drain timeout: `WORKER_SHUTDOWN_TIMEOUT_MS` (10s)
|
||||
- If threads don't complete → `SIGKILL` force termination
|
||||
- Thread state preserved in log store for crash recovery
|
||||
|
||||
**State Handoff Mechanism**:
|
||||
- No explicit state transfer between old/new workers
|
||||
- Sense workers: JSON state files contain full state
|
||||
- Workflow workers: Log store contains thread message history
|
||||
- Kernel coordinates recovery via `recoverThreadsForWorker()`
|
||||
|
||||
## Failure Handling
|
||||
|
||||
### Worker Crashes
|
||||
- **Sense workers**: Automatic respawn after 1s delay, state rebuilt from JSON
|
||||
- **Workflow workers**: Crash recovery from log store thread messages
|
||||
- **Kernel protection**: Main process continues, marks affected runs as crashed
|
||||
- **Crash limits**: Max 5 crashes per workflow in 60s window (prevents infinite respawn)
|
||||
|
||||
### Resource Exhaustion
|
||||
- **Memory**: Worker process killed by OS, kernel respawns automatically
|
||||
- **Compute timeout**: Grace period → hard kill → respawn
|
||||
- **Infinite loops**: Timeout enforcement prevents hanging indefinitely
|
||||
|
||||
This architecture allows Nerve to run untrusted or experimental code safely while maintaining system availability.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Workflow Engine
|
||||
|
||||
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
|
||||
- **Thread** — one execution instance, unique `runId`
|
||||
- **Role** — executes actions (has side effects). `(ctx: ThreadContext) → Promise<RoleResult<M>>`
|
||||
- **Moderator** — pure routing function. `(ctx: ThreadContext) → next role | END`
|
||||
|
||||
## Thread Lifecycle
|
||||
|
||||
```
|
||||
trigger → queued → started → step_complete ↺ → completed
|
||||
↓
|
||||
failed / crashed
|
||||
```
|
||||
|
||||
## Concurrency Config (nerve.yaml)
|
||||
|
||||
```yaml
|
||||
workflows:
|
||||
cleanup:
|
||||
concurrency: 1
|
||||
overflow: drop # discard if already running
|
||||
code-review:
|
||||
concurrency: 3
|
||||
overflow: queue
|
||||
max_queue: 20 # queue limit, oldest discarded
|
||||
```
|
||||
|
||||
## createRole Helper
|
||||
|
||||
`createRole` builds a `Role<M>` from an adapter, prompt, Zod schema, and extract config:
|
||||
|
||||
```ts
|
||||
import { createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
|
||||
import { z } from "zod";
|
||||
|
||||
const coderSchema = z.object({ plan: z.string(), files: z.array(z.string()) });
|
||||
|
||||
const coder = createRole(cursorAdapter, coderPrompt, coderSchema, {
|
||||
provider: { baseUrl: "...", apiKey: "...", model: "qwen-plus" },
|
||||
});
|
||||
|
||||
// Use in WorkflowDefinition
|
||||
const workflow: WorkflowDefinition<MyMeta> = {
|
||||
name: "develop",
|
||||
roles: { coder, reviewer },
|
||||
moderator,
|
||||
};
|
||||
```
|
||||
|
||||
- `adapter: AgentFn` — direct function reference
|
||||
- `prompt: string | ((ctx: ThreadContext) => Promise<string>)` — static or dynamic
|
||||
- `meta: z.ZodType<M>` — Zod schema, directly (no wrapper needed)
|
||||
- `extract: LlmExtractorConfig` — provider for structured extraction
|
||||
|
||||
## Runtime Enforcement Mechanisms
|
||||
|
||||
### Role Authority & Validation
|
||||
|
||||
**Role Function Lookup**:
|
||||
- Roles accessed via `def.roles[nextRole]` dictionary lookup
|
||||
- Unknown roles trigger immediate workflow error (`Unknown role: ${nextRole}`)
|
||||
- No dynamic role registration during execution
|
||||
|
||||
**Result Validation** (`validateRoleResult()`):
|
||||
```typescript
|
||||
// Required return shape from every role function
|
||||
{ content: string, meta: Record<string, unknown> }
|
||||
```
|
||||
- `content` must be string (non-string → workflow error)
|
||||
- `meta` must be plain object (array/null/primitive → workflow error)
|
||||
- Validation failure terminates thread immediately
|
||||
|
||||
### Moderator Authority & Routing Control
|
||||
|
||||
**Next Role Selection**:
|
||||
- Moderator must return role name from `roles` keys OR `END` symbol
|
||||
- Called after every role completion (receives full context)
|
||||
- No validation of role name until execution attempt
|
||||
- Pure function constraint: cannot perform side effects
|
||||
|
||||
**Causal Chain Integrity**:
|
||||
- Moderator receives immutable **ThreadContext**: `{ threadId, start, steps }`
|
||||
- Steps array contains ALL role outputs in chronological order
|
||||
- No role can modify prior steps or start metadata
|
||||
- Thread context built from log store on crash recovery
|
||||
|
||||
### Unauthorized Command Event Prevention
|
||||
|
||||
**Message Flow Control**:
|
||||
- Role functions have NO direct access to kernel IPC
|
||||
- All outputs flow through `sendWorkflowMessage()` wrapper
|
||||
- Worker process validates messages before kernel transmission
|
||||
- No direct log store database access from roles
|
||||
|
||||
**Process Isolation**:
|
||||
- Roles execute in forked worker processes (not kernel)
|
||||
- File system access limited to user permissions
|
||||
- No network isolation (roles can make arbitrary HTTP calls)
|
||||
- Worker has read/write access to workflow workspace only
|
||||
|
||||
### Concurrent Thread Management
|
||||
|
||||
**Kill Flag Implementation**:
|
||||
```typescript
|
||||
type KillFlag = { value: boolean };
|
||||
// Checked before role execution and after completion
|
||||
if (killFlag.value) {
|
||||
sendThreadEvent(runId, "killed", { exitCode: 137 });
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
**Concurrency Enforcement**:
|
||||
- Workflow manager enforces per-workflow limits in kernel
|
||||
- Excess threads queued/dropped per overflow policy
|
||||
- No role can spawn additional threads (no access to workflow manager)
|
||||
@@ -0,0 +1,224 @@
|
||||
# Nerve Coding Conventions
|
||||
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense(state) → { newState, workflow? } → Workflow → Log
|
||||
↑ ↑
|
||||
"what to observe" "what to do"
|
||||
```
|
||||
|
||||
**Nerve** is a lightweight observation engine daemon for autonomous agents. It continuously observes external state, reacts to changes via declarative rules, and orchestrates multi-step workflows.
|
||||
|
||||
### Key Terms
|
||||
|
||||
| Concept | What it is |
|
||||
|---------|-----------|
|
||||
| **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 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(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
|
||||
|
||||
Use `function` + `type`, not `class` + `interface`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type WorkflowLaunch = {
|
||||
senseName: string;
|
||||
workflowName: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
|
||||
return { senseName, workflowName, ts: Date.now() };
|
||||
}
|
||||
|
||||
// ❌ Bad — no class, no interface
|
||||
class WorkflowLaunch implements IWorkflowLaunch { ... }
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| `type` over `interface` | All type definitions use `type` |
|
||||
| `function` over `class` | Pure functions + closures, no class |
|
||||
| No `this` | Functions must not depend on `this` context |
|
||||
| No inheritance | No `extends`, `implements`, `abstract` |
|
||||
| Composition over inheritance | Use function composition |
|
||||
| Immutability first | Use `Readonly<T>`, `as const`, avoid mutation |
|
||||
| No optional properties | Use `T \| null` instead of `?:` — see below |
|
||||
|
||||
### Exceptions
|
||||
|
||||
Classes are allowed when:
|
||||
- Required by a third-party library (e.g. Drizzle's `sqliteTable`)
|
||||
- Error subclasses (`class NerveError extends Error`)
|
||||
|
||||
### No Optional Properties
|
||||
|
||||
Never use `?:`. All nullable fields must be explicit `T | null`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type SenseConfig = {
|
||||
group: string;
|
||||
throttle: string | null;
|
||||
timeout: string | null;
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
type SenseConfig = {
|
||||
group: string;
|
||||
throttle?: string;
|
||||
timeout?: string;
|
||||
};
|
||||
```
|
||||
|
||||
For mutually exclusive fields, use discriminated unions:
|
||||
|
||||
```typescript
|
||||
// ✅ Good — sense modules return explicit next state + optional workflow trigger
|
||||
type SenseComputeReturn<S> = {
|
||||
state: S;
|
||||
workflow: WorkflowTrigger | null;
|
||||
};
|
||||
```
|
||||
|
||||
### Workflow Naming
|
||||
|
||||
Workflow identifiers — `WorkflowDefinition.name`, the directory under `workflows/`, and keys under `workflows:` in `nerve.yaml` — must use **verb-first** kebab-case phrases so the name reads as an action.
|
||||
|
||||
- ✅ `solve-issue`, `extract-knowledge`, `develop-sense`
|
||||
- ❌ `knowledge-extraction`, `issue-solver`
|
||||
|
||||
### Workflow authoring (user modules)
|
||||
|
||||
Roles and moderators take **ThreadContext** (`threadId`, `start`, `steps`) — not separate `StartStep` / message arrays.
|
||||
|
||||
```typescript
|
||||
import type { RoleResult, ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
type MyMeta = { round: number };
|
||||
|
||||
async function planner(ctx: ThreadContext): Promise<RoleResult<MyMeta>> {
|
||||
void ctx.start;
|
||||
void ctx.steps;
|
||||
return { content: "plan", meta: { round: ctx.steps.length } };
|
||||
}
|
||||
|
||||
const workflow: WorkflowDefinition<Record<"planner", MyMeta>> = {
|
||||
name: "example",
|
||||
roles: { planner },
|
||||
moderator(ctx: ThreadContext<Record<"planner", MyMeta>>) {
|
||||
return ctx.steps.length === 0 ? "planner" : END;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Modules & Exports
|
||||
|
||||
- Always named exports, never default exports
|
||||
- One module = one responsibility, filename = purpose
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Style | Example |
|
||||
|------|-------|---------|
|
||||
| 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` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use `Result` type for expected failures
|
||||
- `throw` only for unrecoverable bugs (programmer errors)
|
||||
- No try-catch for flow control
|
||||
|
||||
```typescript
|
||||
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
```
|
||||
|
||||
## Async
|
||||
|
||||
- Always `async/await`, never `.then()` chains
|
||||
|
||||
## No Dynamic Import
|
||||
|
||||
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
||||
|
||||
Exceptions (must include a comment):
|
||||
1. `sense-runtime.ts` — user module paths known only at runtime
|
||||
2. `workflow-worker.ts` — user module paths known only at runtime
|
||||
|
||||
Test files (`__tests__/**`) are exempt.
|
||||
|
||||
## Toolchain
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **pnpm** | Package manager |
|
||||
| **TypeScript** | Type checking (strict mode) |
|
||||
| **Biome** | Lint + format (replaces ESLint + Prettier) |
|
||||
| **tsup** | Bundling |
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
pnpm run check # biome check (lint + format)
|
||||
pnpm run format # biome format --write
|
||||
pnpm run build # full build
|
||||
pnpm test # run tests
|
||||
```
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
nerve/
|
||||
packages/
|
||||
core/ # @nerve/core — shared types and utils
|
||||
cli/ # @nerve/cli — CLI entry point
|
||||
daemon/ # @nerve/daemon — engine runtime
|
||||
docs/ # RFCs, conventions
|
||||
```
|
||||
|
||||
- `core` is the shared layer; `cli` and `daemon` both depend on it
|
||||
- `cli` and `daemon` must NOT depend on each other
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
type: feat | fix | refactor | docs | chore | test
|
||||
scope: core | cli | daemon | rfc-001 | ...
|
||||
```
|
||||
@@ -1,3 +1,198 @@
|
||||
# nerve
|
||||
|
||||
Observation engine — Sense, Reflex, Workflow
|
||||
**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** (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(state) → { newState, workflow? } → Workflow → Log
|
||||
↑
|
||||
scheduling: interval / on (per sense in nerve.yaml)
|
||||
```
|
||||
|
||||
| Concept | Metaphor | Role |
|
||||
|---------|----------|------|
|
||||
| **Sense** | 👁️ Perception | Stateful `compute(state)` returning `{ state, workflow }`. State lives in `data/senses/<name>.json`. |
|
||||
| **Schedule** | ⏱️ When | Each sense entry sets optional `interval` (periodic) and `on: [other senses]` (run after those senses complete a compute). |
|
||||
| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started when `workflow` is non-null in the compute result, or via CLI/daemon IPC. |
|
||||
| **Log** | 📝 Record | Immutable audit trail. **Cannot** schedule senses or workflows (prevents feedback loops). |
|
||||
|
||||
**Sense → Workflow:** when `workflow` is a structured object `{ name, maxRounds, prompt, dryRun }`, the kernel validates it (`@uncaged/nerve-core` `parseWorkflowTrigger`) and starts that workflow. Use `workflow: null` when no run should start.
|
||||
|
||||
Two extension points for **what to observe (+ when)** vs **multi-step action** — scheduling is declarative config on each sense, not a separate YAML section.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, workflow trigger validation, daemon IPC protocol |
|
||||
| [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows |
|
||||
| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, sense workers, sense scheduler, workflow manager, file watcher, IPC |
|
||||
| [`@uncaged/nerve-cli`](./packages/cli) | CLI (`nerve`) — init, validate, daemon, dev, logs, sense, store, workflow |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Requirements: Node.js ≥ 22.5, pnpm
|
||||
pnpm add -g @uncaged/nerve-cli
|
||||
|
||||
# Initialize a workspace
|
||||
mkdir my-agent && cd my-agent
|
||||
nerve init
|
||||
|
||||
# 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 scheduling on each sense in nerve.yaml
|
||||
cat > nerve.yaml << 'EOF'
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system
|
||||
throttle: 10s
|
||||
interval: 30s
|
||||
EOF
|
||||
|
||||
# Run
|
||||
nerve dev # foreground (development)
|
||||
nerve daemon start # background (production)
|
||||
nerve status # check health
|
||||
nerve logs # view logs
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
`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)
|
||||
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system # senses in the same group share a worker process
|
||||
throttle: 10s # min interval between computes
|
||||
timeout: 30s # max compute duration
|
||||
grace_period: 5s # wait before first compute after startup
|
||||
interval: 30s # periodic trigger
|
||||
|
||||
derived-example:
|
||||
group: system
|
||||
throttle: null
|
||||
timeout: 30s
|
||||
grace_period: null
|
||||
on:
|
||||
- cpu-usage # run after cpu-usage completes a compute
|
||||
|
||||
workflows:
|
||||
cleanup:
|
||||
concurrency: 1
|
||||
overflow: drop # discard if already running
|
||||
code-review:
|
||||
concurrency: 3
|
||||
overflow: queue
|
||||
max_queue: 20
|
||||
```
|
||||
|
||||
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/src/index.ts`):
|
||||
|
||||
```typescript
|
||||
export const initialState = { checked: false };
|
||||
|
||||
export async function compute(state: typeof initialState) {
|
||||
const full = await diskNearlyFull();
|
||||
if (!full) return { state: { ...state, checked: true }, workflow: null };
|
||||
return {
|
||||
state: { ...state, checked: true },
|
||||
workflow: {
|
||||
name: "cleanup",
|
||||
maxRounds: 10,
|
||||
prompt: "Disk partition nearly full",
|
||||
dryRun: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Kernel │
|
||||
│ │
|
||||
│ ┌──────────────┐ watches nerve.yaml / senses / workflows │
|
||||
│ │ File Watcher ├──────────────────────────────────────────┐ │
|
||||
│ └──────────────┘ │ │
|
||||
│ ┌──────────────┐ CLI ↔ newline JSON (trigger-workflow, │ │
|
||||
│ │ Daemon IPC │ trigger-sense, list-senses) │ │
|
||||
│ └──────┬───────┘ ▼ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ │ Worker │ │ Worker │ │ Worker │ (1 per│
|
||||
│ │ │ (group A)│ │ (group B)│ │ (group C)│ group) │
|
||||
│ │ │ sense-1 │ │ sense-3 │ │ sense-5 │ │
|
||||
│ │ │ sense-2 │ │ sense-4 │ │ │ │
|
||||
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ │ └──────────────┼──────────────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────┐ │
|
||||
│ │ │Sense Scheduler │
|
||||
│ │ │(interval + on) │
|
||||
│ │ └──────┬───────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌───────────────────┐ │
|
||||
│ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │
|
||||
│ └───────────────────┘ (logs.db, …) │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Worker pool** — one child process per sense group; isolation between groups.
|
||||
- **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.
|
||||
- **Log / blob storage** — implemented in `@uncaged/nerve-store` (WAL SQLite, JSONL archive, CAS blobs).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **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
|
||||
- **pnpm** workspaces for monorepo management
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://git.shazhou.work/uncaged/nerve.git
|
||||
cd nerve
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm -r test # run all tests
|
||||
```
|
||||
|
||||
## Design Documents
|
||||
|
||||
- [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)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
+14
-1
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"include": ["tsup.config.ts"],
|
||||
"include": ["tsup.config.ts", "*/rslib.config.ts", "packages/khala/src/index.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
@@ -27,6 +27,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"include": ["**/__tests__/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linter": {
|
||||
|
||||
+26
-23
@@ -8,25 +8,29 @@
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type Signal = {
|
||||
senseId: string
|
||||
value: unknown
|
||||
type WorkflowLaunch = {
|
||||
senseName: string
|
||||
workflowName: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
function createSignal(senseId: string, value: unknown): Signal {
|
||||
return { senseId, value, ts: Date.now() }
|
||||
function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch {
|
||||
return { senseName, workflowName, ts: Date.now() }
|
||||
}
|
||||
|
||||
// ❌ Bad
|
||||
interface ISignal {
|
||||
senseId: string
|
||||
value: unknown
|
||||
interface IWorkflowLaunch {
|
||||
senseName: string
|
||||
workflowName: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
class Signal implements ISignal {
|
||||
constructor(public senseId: string, public value: unknown, public ts: number) {}
|
||||
class WorkflowLaunch implements IWorkflowLaunch {
|
||||
constructor(
|
||||
public senseName: string,
|
||||
public workflowName: string,
|
||||
public ts: number,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -65,17 +69,16 @@ type SenseConfig = {
|
||||
当多个字段互斥时,用 discriminated union 代替一堆 optional:
|
||||
|
||||
```typescript
|
||||
// ✅ Good — 编译器保证 sense 和 workflow 不会同时出现
|
||||
type ReflexConfig =
|
||||
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
|
||||
| { kind: "workflow"; workflow: string; on: string[] | null }
|
||||
// ✅ Good — 编译器保证两种 overflow 形态互斥且字段完整
|
||||
type WorkflowConfig =
|
||||
| { concurrency: number; overflow: "drop" }
|
||||
| { concurrency: number; overflow: "queue"; maxQueue: number }
|
||||
|
||||
// ❌ Bad — sense 和 workflow 都 optional,运行时才知道到底填了哪个
|
||||
type ReflexConfig = {
|
||||
sense?: string;
|
||||
workflow?: string;
|
||||
interval?: string;
|
||||
on?: string[];
|
||||
// ❌ Bad — 字段一堆 optional,运行时才知道到底填了哪种并发策略
|
||||
type WorkflowConfig = {
|
||||
concurrency?: number;
|
||||
overflow?: string;
|
||||
maxQueue?: number;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -103,9 +106,9 @@ export default function startEngine() { ... }
|
||||
|
||||
| 类型 | 风格 | 示例 |
|
||||
|------|------|------|
|
||||
| 文件 | kebab-case | `signal-bus.ts` |
|
||||
| 类型 | PascalCase | `SignalBus` |
|
||||
| 函数/变量 | camelCase | `createSignalBus` |
|
||||
| 文件 | kebab-case | `sense-scheduler.ts` |
|
||||
| 类型 | PascalCase | `SenseScheduler` |
|
||||
| 函数/变量 | camelCase | `createSenseScheduler` |
|
||||
| 常量 | UPPER_SNAKE | `MAX_RETRY_COUNT` |
|
||||
| 泛型参数 | 单字母或描述性 | `T`, `TValue` |
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Nerve 单仓死代码分析报告
|
||||
|
||||
**分析日期**: 2026-04-30
|
||||
**范围**: `packages/core`, `daemon`, `store`, `cli`, `khala`, `workflow-utils`, `workflow-meta`, `adapter-cursor`, `adapter-hermes` 的 TypeScript 源码与 `package.json` 依赖。
|
||||
**方法**: 全仓 `ripgrep` 交叉验证 `import` / `export` 路径;未运行 Knip/TS 死代码专项工具。
|
||||
**说明**: 未包含 `role-reviewer` / `role-committer` / `skills` 等包(你列出的范围外),但 `workflow-meta` 对其中部分有依赖,相关结论已注明。
|
||||
|
||||
---
|
||||
|
||||
## 方法限制(读前必读)
|
||||
|
||||
| 限制 | 影响 |
|
||||
|------|------|
|
||||
| **动态 `import(url)`** | `cli` 的 `loadDaemonModule` 在运行时加载 `@uncaged/nerve-daemon`,静态分析无法把 `createKernel` 等映射到具体导出。 |
|
||||
| **已发布包的公共 API** | 大量导出仅被仓外消费者使用;本报告中的「仓内未引用」≠「应删除」。 |
|
||||
| **构建入口** | Rslib 多入口(如 `sense-worker`、`workflow-worker`、`daemon-bootstrap`)视为存活入口,不作为孤儿文件。 |
|
||||
| **内部未导出函数** | 未做逐函数调用图;「死函数」仅列出高置信个例。 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 未使用导出(仓内无 `import`)
|
||||
|
||||
以下符号在 **monorepo 内** 没有任何文件从 `@uncaged/nerve-core` / `@uncaged/nerve-workflow-utils` / `@uncaged/nerve-daemon` 等包根导出处引用(测试与定义自身除外)。
|
||||
|
||||
### 1.1 `@uncaged/nerve-core`
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/core/src/index.ts` → `agent.js` | `KNOWN_AGENT_ADAPTER_IDS` | **高** | **调查**:若 CLI/校验应约束 adapter id,可接入;否则可从公共 API 移除或保留作文档性常量并注明。 |
|
||||
| `packages/core/src/index.ts` → `sense.js` | `labelSenseTrigger`(`senseTriggerLabels` 在 `cli`/`daemon` 有使用) | **高(相对仓内)** | **保留或收敛导出**:仅 `senseTriggerLabels` 对外即可;`labelSenseTrigger` 可作为内部函数若不需要单独暴露。 |
|
||||
| `packages/core/src/index.ts` → `util.js` | `parseDurationStringToMs`(仅 `config.ts` 内部使用) | **高** | **调查**:无需在 `index` 再导出则改为非导出或移除 re-export,减少 API 面。 |
|
||||
| `packages/core/src/index.ts` → `sense.js` | 类型 `SenseModule` | **中** | **保留**:用户文档/外部 Sense 模块常用;仓内未按名引用属正常。 |
|
||||
| `packages/core/src/index.ts` → `config.js` | 单独导出的类型 `NerveApiConfig`(仅 `config.ts` 与 `index` 出现) | **低** | **保留**:通常随 `NerveConfig` 一并使用;单独 export 多为 TS 公共类型便利。 |
|
||||
|
||||
### 1.2 `@uncaged/nerve-workflow-utils`
|
||||
|
||||
下列项在 **`packages/*/src/**/*.ts` 中无人从包入口导入**(`workflow-meta`、`role-committer` 等主要只用 `createRole`、`decorateRole`、`onFail`、`withDryRun`、`LlmExtractorConfig`)。
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/workflow-utils/src/index.ts` | `createLlmAdapter` | **高** | **保留(对外 API)** 或标注文档;仓内无调用。 |
|
||||
| 同上 | `llmExtract`、`llmExtractWithRetry` | **高** | **保留**:高级用法 / 文档示例;内部与测试使用。 |
|
||||
| 同上 | `mergeExtractConfig`、`ExtractConfigLayer` | **高** | **调查**:若 RFC 分层配置仍在推进则保留;否则评估移除导出。 |
|
||||
| 同上 | `assertZodMetaSchemas`、`createLlmExtractFn`、`extractMetaOrThrow`、`zodMeta`、`ZodMetaSchema` | **高** | **保留(对外 API)**;当前仅 `workflow-utils` 测试与内部 `create-role` 链路使用 `extractMetaOrThrow`。 |
|
||||
| 同上 | `readNerveYaml`、`nerveAgentContext` 及相关类型 | **高** | **调查**:若已无 YAML 上下文注入场景可删导出;否则保留给 Agent 工具链。 |
|
||||
| 同上 | `spawnSafe`、`nerveCommandEnv` 及 `Spawn*` 类型(从 core 再导出) | **高** | **删除再导出或改为文档链接**:仓内均直接从 `@uncaged/nerve-core` 引用 spawn。 |
|
||||
| 同上 | `isDryRun` | **高** | **删除或实现**:见 §3「死函数」。 |
|
||||
| 同上 | `LlmMessage`、`MetaExtractConfig`、`LlmChatError`、`LlmError`、`LlmProvider` 等类型再导出 | **中** | **保留**:供外部精细类型标注;仓内未单独 import。 |
|
||||
|
||||
### 1.3 `@uncaged/nerve-daemon`
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/daemon/src/index.ts` → `agent-adapters/echo.js` | `createEchoAgent` | **高** | **调查**:无任何测试或运行时引用;若设计保留 `type: "echo"` 适配器,应在 workflow/agent 装载路径接线或删除导出与文件。 |
|
||||
| `packages/daemon/src/index.ts`(及其它导出) | 多数 IPC / runtime 符号 | **低** | **保留**:动态加载与外部集成无法静态判定;仅 `createEchoAgent` 可确定为当前静态图下的死角。 |
|
||||
|
||||
### 1.4 `@uncaged/nerve-adapter-cursor` / `@uncaged/nerve-adapter-hermes`
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/adapter-cursor/src/index.ts` | `cursorAdapter`、`createCursorAdapter`(以及 `cursorAgent` 仅被 `workflow-utils/src/role-cursor.ts` 使用) | **中** | **保留**:默认实例与工厂供下游与工作流仓使用;仓内业务包未直接 import `cursorAdapter` 属预期。 |
|
||||
| `packages/adapter-hermes/src/index.ts` | `hermesAdapter`、`createHermesAdapter`(`hermesAgent` 经 `workflow-utils` 间接使用) | **中** | 同上。 |
|
||||
|
||||
### 1.5 `@uncaged/nerve-cli`
|
||||
|
||||
| 路径 | 未使用项 | 置信度 | 建议 |
|
||||
|------|----------|--------|------|
|
||||
| `packages/cli/src/index.ts` | `getNerveRoot`、`loadDaemonModule` 等程序化导出 | **高(仓内)** | **保留**:无任何 workspace 包引用 `@uncaged/nerve-cli`;面向 CLI 二次开发者。 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 孤儿文件(非入口、未被其它源码引用)
|
||||
|
||||
在当前 Rslib / `cli` 入口定义下,**未发现**「零 import、且非 entry / 非测试」的 `.ts` 业务文件:
|
||||
|
||||
- `workflow-utils` 下的 `role-cursor.ts`、`role-hermes.ts`、`role-llm.ts`、`role-react.ts` 虽**未出现在包 `index.ts`**,但被 `packages/workflow-utils/src/__tests__/role-factories.test.ts` 直接引用,**不是孤儿文件**。
|
||||
- `daemon` 的 `sense-worker.ts`、`workflow-worker.ts` 等为 **显式构建入口**。
|
||||
|
||||
**置信度**: 对全表扫描为 **中**(未跑依赖图工具;动态路径可能掩盖极少数脚本引用)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 死函数(内部未导出且未被调用)
|
||||
|
||||
| 路径 | 说明 | 置信度 | 建议 |
|
||||
|------|------|--------|------|
|
||||
| `packages/workflow-utils/src/role-types.ts` | `isDryRun(_start: StartStep)`:导出在 `index.ts`,但全仓无调用(函数体恒返回 `false` 且标注 deprecated) | **高** | **删除**或改为内部常量;若需向后兼容则保留并文档标注「保留占位」。 |
|
||||
|
||||
其它内部函数未系统逐文件枚举。
|
||||
|
||||
---
|
||||
|
||||
## 4. 未使用的 npm 依赖(package.json)
|
||||
|
||||
在声明范围内通过源码 `import` 抽查:**未发现明显「完全未使用」的生产依赖**。
|
||||
|
||||
| 包 | 依赖 | 结论 |
|
||||
|----|------|------|
|
||||
| `cli` | `citty`、`yaml`、`picomatch` | 均有对应源码引用。 |
|
||||
| `khala` | `hono`、`jsonata`、`ulidx`、`@uncaged/nerve-core` | 均有引用。 |
|
||||
| `core` / `daemon` | `yaml`、`drizzle-orm` | 均有引用。 |
|
||||
| `workflow-utils` / `workflow-meta` | `zod`、workspace 包 | 均有引用。 |
|
||||
|
||||
**置信度**: **中**(未对 peer、可选路径、CLI `bin` 专用依赖做自动化扫描)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 陈旧测试夹具 / 未引用辅助文件
|
||||
|
||||
未发现独立的「fixture 目录」明显失联;`cli` 下 `e2e-harness`、`__tests__` 内 helper 均有对应测试引用。
|
||||
|
||||
**置信度**: **低**(未枚举每个 `__tests__` 资源文件)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 重构遗留 / 文档漂移
|
||||
|
||||
| 项目 | 位置 | 说明 | 置信度 | 建议 |
|
||||
|------|------|------|--------|------|
|
||||
| ~~已更名 API 仍出现在 README~~ | `packages/core/README.md` | (已修正)文档与 stateful sense、`parseWorkflowTrigger` 对齐;`routeSenseComputeOutput` 已移除 | — | 关闭 |
|
||||
| Hermes 选项合并注释 | `packages/workflow-utils/src/shared/hermes-agent.ts` | 注释称 absorbed from `hermes-options.ts`,该文件已不存在 | **中** | **清理注释**,避免误导。 |
|
||||
| `KNOWN_AGENT_ADAPTER_IDS` 含 `codex` | `packages/core/src/agent.ts` | 仓内无 `codex` 适配器包;与常量未被引用叠加 | **中** | **对齐产品**:实现适配器或从列表移除。 |
|
||||
|
||||
未发现用户提到的 `worker-fork-support` 等字符串副本(全仓无匹配)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 汇总建议优先级
|
||||
|
||||
1. **高优先级调查**: `createEchoAgent` 与 `KNOWN_AGENT_ADAPTER_IDS` — 要么接入运行时,要么删减以免维护假象。
|
||||
2. **API 面收敛**: `parseDurationStringToMs`、`labelSenseTrigger` 若无意对外,可从 `core` 公共导出移除。
|
||||
3. **`workflow-utils`**: 评估 `isDryRun` 删除;`spawnSafe` 等从 `workflow-utils` 再导出是否仍有必要。
|
||||
4. ~~**文档**: 修正 `packages/core/README.md` 中 Sense→Workflow 路由 API 名称。~~(已完成)
|
||||
|
||||
---
|
||||
|
||||
## 8. 重新验证命令示例
|
||||
|
||||
后续可在本地采用工具增强置信度(非本次执行):
|
||||
|
||||
```bash
|
||||
pnpm add -D -w knip
|
||||
pnpm exec knip
|
||||
```
|
||||
|
||||
或在各包对 `@uncaged/nerve-*` 的导出做面向消费者的契约测试,避免误删对外 API。
|
||||
@@ -0,0 +1,558 @@
|
||||
# Khala MVP Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build Khala — a Cloudflare Workers + D1 + Durable Objects cloud workflow orchestrator that lets agents coordinate multi-agent workflows as a stateless worker pool.
|
||||
|
||||
**Architecture:** Khala is a CF Worker that receives events from agents via REST API. Each workflow thread runs in a Durable Object with a JSONata moderator. Agents poll a task queue for unclaimed turns, execute locally, and POST results back. Thread messages are stored in D1.
|
||||
|
||||
**Tech Stack:** Cloudflare Workers, D1 (SQLite), Durable Objects, Hono (routing), JSONata (moderator engine), TypeScript
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Project Scaffolding
|
||||
|
||||
### Task 0.1: Create khala package
|
||||
|
||||
**Objective:** Set up the `packages/khala` CF Worker project with wrangler, Hono, and D1 binding.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/package.json`
|
||||
- Create: `packages/khala/wrangler.toml`
|
||||
- Create: `packages/khala/tsconfig.json`
|
||||
- Create: `packages/khala/src/index.ts`
|
||||
|
||||
**Step 1: Create package.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@uncaged/khala",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.7.0",
|
||||
"jsonata": "^2.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250410.0",
|
||||
"vitest": "^4.1.5",
|
||||
"wrangler": "^4.14.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create wrangler.toml**
|
||||
|
||||
```toml
|
||||
name = "khala"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "khala"
|
||||
database_id = "placeholder"
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
{ name = "THREAD", class_name = "ThreadDO" }
|
||||
]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["ThreadDO"]
|
||||
```
|
||||
|
||||
**Step 3: Create minimal Hono entrypoint**
|
||||
|
||||
```typescript
|
||||
// src/index.ts
|
||||
import { Hono } from "hono";
|
||||
|
||||
export type Env = {
|
||||
DB: D1Database;
|
||||
THREAD: DurableObjectNamespace;
|
||||
};
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.get("/health", (c) => c.json({ ok: true }));
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
**Step 4: Create tsconfig.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Install dependencies and verify**
|
||||
|
||||
```bash
|
||||
cd packages/khala && pnpm install
|
||||
pnpm exec wrangler types # generates worker-configuration.d.ts
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/khala/
|
||||
git commit -m "chore(khala): scaffold CF Worker package"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: D1 Schema & Data Layer
|
||||
|
||||
### Task 1.1: Create D1 migration — core tables
|
||||
|
||||
**Objective:** Define D1 schema for agents, threads, messages, and task queue.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/migrations/0001_initial.sql`
|
||||
|
||||
**SQL:**
|
||||
|
||||
```sql
|
||||
-- Agent registry
|
||||
CREATE TABLE agents (
|
||||
id TEXT PRIMARY KEY, -- agent name (e.g. "tuanzi")
|
||||
token_hash TEXT NOT NULL, -- bcrypt/sha256 hash of agent token
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Workflow threads
|
||||
CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY, -- ulid
|
||||
workflow TEXT NOT NULL, -- workflow name (e.g. "code-review")
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | completed | failed
|
||||
initiator TEXT NOT NULL, -- agent id or external caller
|
||||
result TEXT, -- final result JSON (set on completion)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Thread messages (append-only)
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT NOT NULL REFERENCES threads(id),
|
||||
role TEXT NOT NULL, -- role name or "__moderator__"
|
||||
content TEXT NOT NULL,
|
||||
meta TEXT, -- JSON
|
||||
step INTEGER NOT NULL, -- 0-indexed step number
|
||||
agent_id TEXT, -- which agent executed this turn
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_messages_thread ON messages(thread_id, step);
|
||||
|
||||
-- Task queue
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY, -- ulid
|
||||
thread_id TEXT NOT NULL REFERENCES threads(id),
|
||||
role TEXT NOT NULL,
|
||||
instruction TEXT NOT NULL, -- turn instruction from moderator
|
||||
status TEXT NOT NULL DEFAULT 'open', -- open | claimed | completed | expired
|
||||
claim_id TEXT, -- set when claimed
|
||||
claimed_by TEXT, -- agent id
|
||||
claimed_at TEXT,
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tasks_status ON tasks(status, created_at);
|
||||
```
|
||||
|
||||
**Step 1: Write the migration file**
|
||||
|
||||
**Step 2: Apply locally**
|
||||
|
||||
```bash
|
||||
cd packages/khala
|
||||
pnpm exec wrangler d1 migrations apply khala --local
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/khala/migrations/
|
||||
git commit -m "feat(khala): D1 schema — agents, threads, messages, tasks"
|
||||
```
|
||||
|
||||
### Task 1.2: Create data access functions
|
||||
|
||||
**Objective:** Type-safe D1 query functions for all tables.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/src/db.ts`
|
||||
- Create: `packages/khala/src/types.ts`
|
||||
|
||||
**types.ts:**
|
||||
|
||||
```typescript
|
||||
export type Agent = {
|
||||
id: string;
|
||||
token_hash: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Thread = {
|
||||
id: string;
|
||||
workflow: string;
|
||||
status: "active" | "completed" | "failed";
|
||||
initiator: string;
|
||||
result: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id: number;
|
||||
thread_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
meta: string | null;
|
||||
step: number;
|
||||
agent_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
thread_id: string;
|
||||
role: string;
|
||||
instruction: string;
|
||||
status: "open" | "claimed" | "completed" | "expired";
|
||||
claim_id: string | null;
|
||||
claimed_by: string | null;
|
||||
claimed_at: string | null;
|
||||
timeout_seconds: number;
|
||||
created_at: string;
|
||||
};
|
||||
```
|
||||
|
||||
**db.ts:** Query functions — `createThread`, `appendMessage`, `createTask`, `claimTask`, `completeTask`, `getOpenTasks`, `getThreadMessages`, etc. Each is a plain function taking `D1Database` as first arg.
|
||||
|
||||
**Step 1: Write types.ts**
|
||||
|
||||
**Step 2: Write db.ts with all query functions**
|
||||
|
||||
Key functions:
|
||||
- `createThread(db, workflow, initiator) → Thread`
|
||||
- `appendMessage(db, threadId, role, content, meta, step, agentId) → Message`
|
||||
- `createTask(db, threadId, role, instruction, timeoutSeconds) → Task`
|
||||
- `claimTask(db, taskId, agentId) → { ok: true, claimId } | { ok: false }`
|
||||
- `completeTask(db, taskId, claimId) → boolean`
|
||||
- `expireTimedOutTasks(db) → number` (count expired)
|
||||
- `getOpenTasks(db, limit) → Task[]`
|
||||
- `getThreadMessages(db, threadId, opts?) → Message[]` (opts: role, since, step, last)
|
||||
|
||||
Use `ulid()` for IDs (add `ulidx` dependency).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/khala/src/
|
||||
git commit -m "feat(khala): data access layer — types and D1 queries"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Auth & Agent Registry
|
||||
|
||||
### Task 2.1: Agent auth middleware
|
||||
|
||||
**Objective:** Bearer token auth for agents. Hash-based token verification.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/src/auth.ts`
|
||||
- Modify: `packages/khala/src/index.ts`
|
||||
|
||||
**auth.ts:**
|
||||
|
||||
```typescript
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import type { Env } from "./index.ts";
|
||||
|
||||
export const agentAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
|
||||
const header = c.req.header("Authorization");
|
||||
if (!header?.startsWith("Bearer ")) {
|
||||
return c.json({ error: "missing token" }, 401);
|
||||
}
|
||||
const token = header.slice(7);
|
||||
const hash = await sha256(token);
|
||||
const agent = await c.env.DB.prepare(
|
||||
"SELECT id FROM agents WHERE token_hash = ?"
|
||||
).bind(hash).first<{ id: string }>();
|
||||
if (!agent) {
|
||||
return c.json({ error: "invalid token" }, 401);
|
||||
}
|
||||
c.set("agentId", agent.id);
|
||||
await next();
|
||||
});
|
||||
|
||||
async function sha256(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input);
|
||||
const buf = await crypto.subtle.digest("SHA-256", data);
|
||||
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
```
|
||||
|
||||
### Task 2.2: Admin routes for agent management
|
||||
|
||||
**Objective:** Admin API to register/remove agents (protected by admin secret in env).
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/src/routes/admin.ts`
|
||||
|
||||
**Routes:**
|
||||
- `POST /admin/agents` — body `{ id, token }` → hash token, insert agent
|
||||
- `DELETE /admin/agents/:id` — remove agent
|
||||
- `GET /admin/agents` — list agents (no tokens)
|
||||
|
||||
Protected by `ADMIN_SECRET` env var check.
|
||||
|
||||
**Commit:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(khala): agent auth middleware and admin API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Workflow Engine (Durable Object)
|
||||
|
||||
### Task 3.1: Workflow registry
|
||||
|
||||
**Objective:** Load workflow definitions from a simple in-memory registry (hardcoded for MVP, later from D1 or KV).
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/src/workflows.ts`
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
export type CloudRole = {
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type CloudWorkflowDef = {
|
||||
name: string;
|
||||
roles: Record<string, CloudRole>;
|
||||
moderator: string; // JSONata expression
|
||||
};
|
||||
|
||||
// For MVP: hardcoded registry
|
||||
const registry = new Map<string, CloudWorkflowDef>();
|
||||
|
||||
export function registerWorkflow(def: CloudWorkflowDef): void {
|
||||
registry.set(def.name, def);
|
||||
}
|
||||
|
||||
export function getWorkflow(name: string): CloudWorkflowDef | null {
|
||||
return registry.get(name) ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3.2: ThreadDO — Durable Object
|
||||
|
||||
**Objective:** Each workflow thread runs as a Durable Object. The DO manages the moderator state machine, creates tasks, and processes responses.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/src/thread-do.ts`
|
||||
|
||||
**Key behavior:**
|
||||
|
||||
1. `POST /start` — Initialize thread: save workflow def, run moderator to get first turn, create task
|
||||
2. `POST /response` — Agent posts turn result: validate claim_id, append message, run moderator for next turn or END
|
||||
3. `GET /messages` — Query thread messages with filters
|
||||
|
||||
**Moderator execution:**
|
||||
- Build context from messages (start frame + role steps)
|
||||
- Evaluate JSONata expression → returns `{ role: "reviewer" }` or `{ role: "__end__" }`
|
||||
- If not END, create new task in queue
|
||||
- If END, mark thread completed, set result
|
||||
|
||||
**Important:** The DO holds workflow state in memory during the request but persists everything to D1. The DO itself uses `ctx.storage` only for the thread ID mapping.
|
||||
|
||||
### Task 3.3: Wire ThreadDO into worker
|
||||
|
||||
**Objective:** Export the DO class, add routes that proxy to the DO.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/khala/src/index.ts`
|
||||
|
||||
**Routes:**
|
||||
- `POST /workflows/:name/threads` — Create thread → instantiate DO → start
|
||||
- `POST /threads/:id/response` — Forward to DO
|
||||
- `GET /threads/:id/messages` — Forward to DO (or query D1 directly)
|
||||
- `GET /threads/:id` — Thread status
|
||||
|
||||
**Commit:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(khala): ThreadDO workflow engine with JSONata moderator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Task Queue API
|
||||
|
||||
### Task 4.1: Task queue endpoints
|
||||
|
||||
**Objective:** Agents poll for work and claim tasks.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/src/routes/tasks.ts`
|
||||
|
||||
**Routes (all require agentAuth):**
|
||||
- `GET /tasks` — List open tasks (optionally filter by workflow)
|
||||
- `POST /tasks/:id/claim` — Claim a task → returns `{ claimId, role, instruction, threadId }`
|
||||
- `POST /tasks/:id/release` — Release a claimed task back to queue
|
||||
|
||||
**Claim logic:**
|
||||
- Atomic: UPDATE ... WHERE status = 'open' → if rowsWritten = 0, already claimed
|
||||
- Returns claim_id (ulid) for optimistic lock on response
|
||||
|
||||
### Task 4.2: Task timeout sweep
|
||||
|
||||
**Objective:** Periodically expire timed-out tasks back to open.
|
||||
|
||||
**Implementation:** CF Worker Cron Trigger (every 1 minute) that calls `expireTimedOutTasks(db)`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/khala/src/index.ts` (add scheduled handler)
|
||||
- Modify: `packages/khala/wrangler.toml` (add cron trigger)
|
||||
|
||||
```toml
|
||||
[triggers]
|
||||
crons = ["* * * * *"]
|
||||
```
|
||||
|
||||
**Commit:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(khala): task queue API with claim/release and timeout sweep"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Agent-Side Integration (KhalaSense)
|
||||
|
||||
### Task 5.1: Khala client library
|
||||
|
||||
**Objective:** A small client that agents use to interact with Khala.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/core/src/khala-client.ts`
|
||||
|
||||
**API:**
|
||||
|
||||
```typescript
|
||||
export type KhalaClientConfig = {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export function createKhalaClient(config: KhalaClientConfig) {
|
||||
return {
|
||||
pollTasks: () => GET /tasks,
|
||||
claimTask: (taskId) => POST /tasks/:id/claim,
|
||||
submitResponse: (threadId, content, meta, claimId) => POST /threads/:id/response,
|
||||
getMessages: (threadId, opts) => GET /threads/:id/messages,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Task 5.2: KhalaSense
|
||||
|
||||
**Objective:** A Sense that polls Khala for open tasks and emits signals.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/daemon/src/senses/khala-sense.ts`
|
||||
|
||||
**Behavior:**
|
||||
- `compute()`: poll `/tasks`, if tasks available → return task info as signal value
|
||||
- Reflex picks up signal → triggers a workflow that executes the turn locally
|
||||
- After local execution → POST response back to Khala
|
||||
|
||||
**This task depends on understanding the existing Sense pattern in daemon. Check `packages/daemon/src/` for examples.**
|
||||
|
||||
**Commit:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(khala): KhalaSense — agent-side polling and integration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: End-to-End Demo
|
||||
|
||||
### Task 6.1: Register a demo workflow
|
||||
|
||||
**Objective:** Register a simple 2-role "ping-pong" workflow for testing.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/src/workflows/ping-pong.ts`
|
||||
|
||||
**Workflow:**
|
||||
- Roles: `pinger` (says ping), `ponger` (says pong)
|
||||
- Moderator: alternate pinger/ponger for 3 rounds then END
|
||||
- JSONata: `steps.length >= 6 ? { "role": "__end__" } : steps.length % 2 = 0 ? { "role": "pinger" } : { "role": "ponger" }`
|
||||
|
||||
### Task 6.2: Integration test
|
||||
|
||||
**Objective:** Test the full flow with miniflare.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/khala/src/__tests__/e2e.test.ts`
|
||||
|
||||
**Test:**
|
||||
1. Create thread via API
|
||||
2. Poll tasks → get first task
|
||||
3. Claim task
|
||||
4. POST response
|
||||
5. Poll again → get next task
|
||||
6. Repeat until workflow completes
|
||||
7. Verify thread status = completed and all messages present
|
||||
|
||||
**Commit:**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(khala): ping-pong demo workflow and e2e test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Tasks | Description |
|
||||
|-------|-------|-------------|
|
||||
| 0 | 0.1 | Project scaffolding |
|
||||
| 1 | 1.1-1.2 | D1 schema & data layer |
|
||||
| 2 | 2.1-2.2 | Auth & agent registry |
|
||||
| 3 | 3.1-3.3 | Workflow engine (DO + moderator) |
|
||||
| 4 | 4.1-4.2 | Task queue API |
|
||||
| 5 | 5.1-5.2 | Agent-side integration |
|
||||
| 6 | 6.1-6.2 | End-to-end demo |
|
||||
|
||||
**Deployment:** `khala.shazhou.workers.dev`
|
||||
|
||||
**First milestone:** Phase 0-4 (cloud side complete), testable with curl.
|
||||
**Second milestone:** Phase 5-6 (agent integration + demo).
|
||||
@@ -473,7 +473,7 @@ Sense 的运行时属性(`group`、`throttle`、`timeout`)在 `nerve.yaml`
|
||||
```sql
|
||||
CREATE TABLE logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL, -- "reflex", "workflow", "system"
|
||||
source TEXT NOT NULL, -- "sense_scheduler", "sense", "workflow", "system"
|
||||
type TEXT NOT NULL, -- "run_start", "run_complete", "error", "state_change"
|
||||
ref_id TEXT, -- 关联的 reflex name / workflow run_id
|
||||
payload TEXT, -- JSON
|
||||
|
||||
@@ -350,12 +350,12 @@ export type WorkflowManager = {
|
||||
};
|
||||
```
|
||||
|
||||
### 7.2 Reflex Scheduler 扩展
|
||||
### 7.2 Sense Scheduler 扩展
|
||||
|
||||
当前 `ReflexScheduler` 只处理 `kind: "sense"` 的 reflex。扩展为同时处理 `kind: "workflow"`:
|
||||
当前 `SenseScheduler` 只处理 `kind: "sense"` 的 reflex。扩展为同时处理 `kind: "workflow"`:
|
||||
|
||||
```typescript
|
||||
// reflex-scheduler.ts 扩展
|
||||
// sense-scheduler.ts 扩展
|
||||
if (reflex.kind === "workflow") {
|
||||
const workflowName = reflex.workflow;
|
||||
if (reflex.on !== null && reflex.on.length > 0) {
|
||||
@@ -393,8 +393,8 @@ if (reflex.kind === "workflow") {
|
||||
### Phase 2:Kernel 集成
|
||||
|
||||
- [ ] `packages/daemon/src/kernel.ts` — 集成 WorkflowManager,处理 workflow worker 的生命周期
|
||||
- [ ] `packages/daemon/src/reflex-scheduler.ts` — 扩展支持 `kind: "workflow"` 的 reflex
|
||||
- [ ] 集成测试:Sense signal → Reflex → Workflow 全链路
|
||||
- [ ] `packages/daemon/src/sense-scheduler.ts` — 扩展支持 `kind: "workflow"` 的 reflex
|
||||
- [ ] 集成测试:Sense signal → SenseScheduler → Workflow 全链路
|
||||
|
||||
### Phase 3:崩溃恢复与热更新
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
# RFC-003: Agent Configuration Layer
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Status:** Draft
|
||||
**Created:** 2026-04-29
|
||||
|
||||
## Summary
|
||||
|
||||
Define a minimal agent abstraction where **adapter = capability** and **role = scenario**. Workflows directly declare which adapter each role uses — no intermediate registry or `nerve.yaml` agent config. `nerve.yaml` only holds `extract` config and `knowledge` settings.
|
||||
|
||||
## Motivation
|
||||
|
||||
The original design introduced a `nerve.yaml` agents registry to map logical names (e.g. `developer`) to adapter implementations. In practice this added an unnecessary layer of indirection:
|
||||
|
||||
- **Agent names are arbitrary** — `developer` vs `coder` vs `engineer` is a naming exercise, not architecture
|
||||
- **One more config to maintain** — adding/changing an adapter requires editing both `nerve.yaml` and the workflow
|
||||
- **Same adapter, same config** — in reality, most workflows just need "use cursor" or "use hermes", not a named abstraction on top
|
||||
|
||||
The simpler model: **workflow roles declare their adapter directly**. The adapter *is* the capability.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Adapter vs Role
|
||||
|
||||
| | Adapter | Role |
|
||||
|---|---|---|
|
||||
| **What** | Capability — what tools are available | Scenario — what to do with those tools |
|
||||
| **Granularity** | Few (cursor, hermes, claude, codex) | Many (per workflow step) |
|
||||
| **Defines** | How to spawn an agent, tool access | Prompt, schema, timeout |
|
||||
| **Layer** | Infrastructure (packages) | Business logic (WorkflowSpec) |
|
||||
|
||||
A `cursor` adapter becomes an architect, coder, or reviewer depending on the role's prompt. The adapter defines *what it can do*; the role defines *what it does right now*.
|
||||
|
||||
### Agent Protocol
|
||||
|
||||
All agent types implement a single unified interface:
|
||||
|
||||
```ts
|
||||
type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>
|
||||
```
|
||||
|
||||
- **Input**: prompt (assembled by Role) + context (start frame + prior messages + workdir + abort signal)
|
||||
- **Output**: raw string — structured data is extracted separately
|
||||
- **Internals**: adapter handles tool-specific details (cursor CLI, hermes subagent, codex API, etc.)
|
||||
|
||||
Workflow runtime never interacts with agent internals.
|
||||
|
||||
### Extract Layer
|
||||
|
||||
A separate concern that parses agent output (raw string) into typed meta:
|
||||
|
||||
```ts
|
||||
type ExtractFn<T> = (raw: string, schema: Schema<T>) => Promise<T>
|
||||
```
|
||||
|
||||
Configured globally in `nerve.yaml`, overridable per role (two-level merge: global → role).
|
||||
|
||||
**Error handling**: retry once (feed raw output + parse error back to LLM for correction), then throw `ExtractError`. The workflow moderator decides the recovery strategy (retry role, skip, or terminate) — extract never makes workflow-level decisions.
|
||||
|
||||
## Design
|
||||
|
||||
### Configuration (`nerve.yaml`)
|
||||
|
||||
`nerve.yaml` holds only extract and knowledge config — no agent registry:
|
||||
|
||||
```yaml
|
||||
extract:
|
||||
provider: dashscope
|
||||
model: qwen-plus
|
||||
```
|
||||
|
||||
### Workflow Definition (TypeScript)
|
||||
|
||||
Roles declare their adapter directly — no indirection through named agents:
|
||||
|
||||
```ts
|
||||
import { cursorAdapter, createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
|
||||
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
|
||||
|
||||
const workflow: WorkflowSpec<MyMeta> = {
|
||||
name: "develop-workflow",
|
||||
roles: {
|
||||
architect: { adapter: cursorAdapter, prompt: architectPrompt, meta: architectSchema },
|
||||
coder: { adapter: createCursorAdapter({ model: "claude-sonnet-4", timeout: 600 }), prompt: coderPrompt, meta: coderSchema },
|
||||
reviewer: { adapter: hermesAdapter, prompt: reviewPrompt, meta: reviewSchema },
|
||||
deployer: { adapter: hermesAdapter, prompt: deployPrompt, meta: deploySchema },
|
||||
},
|
||||
moderator,
|
||||
};
|
||||
```
|
||||
|
||||
### Runtime Assembly
|
||||
|
||||
```
|
||||
WorkflowSpec → Role(adapter fn + prompt) → adapter(prompt, ctx) → string
|
||||
↓
|
||||
nerve.yaml#extract → ExtractFn(string, schema) → T (typed meta)
|
||||
```
|
||||
|
||||
Adapter is a direct function reference on each role — no map, no lookup, no registry.
|
||||
|
||||
### Adapter Packages
|
||||
|
||||
Each agent adapter lives in its own package to avoid pulling unnecessary dependencies:
|
||||
|
||||
```
|
||||
packages/
|
||||
adapter-cursor/ # @uncaged/nerve-adapter-cursor — cursor-agent CLI
|
||||
adapter-hermes/ # @uncaged/nerve-adapter-hermes — hermes CLI subagent
|
||||
adapter-claude/ # @uncaged/nerve-adapter-claude — claude-code CLI (future)
|
||||
adapter-codex/ # @uncaged/nerve-adapter-codex — codex CLI (future)
|
||||
```
|
||||
|
||||
Each adapter exports a **default instance** and a **factory** for customization:
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-adapter-cursor
|
||||
import type { AgentConfig, AgentFn } from "@uncaged/nerve-core";
|
||||
|
||||
// Factory — custom config
|
||||
export function createCursorAdapter(config: AgentConfig): AgentFn;
|
||||
|
||||
// Default — sensible defaults (model: "auto", timeout: 300)
|
||||
export const cursorAdapter: AgentFn;
|
||||
```
|
||||
|
||||
The factory receives adapter config (model, timeout) and returns an `AgentFn` that spawns the CLI tool, passes the prompt, and returns raw output.
|
||||
|
||||
**Wiring** — workflows import adapters directly, no daemon-level registry:
|
||||
|
||||
```ts
|
||||
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
|
||||
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
|
||||
|
||||
// Use default instances directly in roles
|
||||
{ adapter: cursorAdapter, prompt: "...", meta: schema }
|
||||
```
|
||||
|
||||
Adapters not installed simply can't be imported — TypeScript catches missing dependencies at compile time.
|
||||
|
||||
**Workspace `package.json`** only lists the adapters it actually uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-adapter-cursor": "workspace:*",
|
||||
"@uncaged/nerve-adapter-hermes": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Migration from `workflow-utils`** — the existing `role-cursor.ts` / `shared/cursor-agent.ts` spawn logic moves to `@uncaged/nerve-adapter-cursor`. `role-hermes.ts` / `shared/hermes-agent.ts` moves to `@uncaged/nerve-adapter-hermes`. `workflow-utils` retains only extract, prompt utilities, and shared spawn infrastructure.
|
||||
|
||||
### Dynamic Prompts
|
||||
|
||||
`RoleSpec.prompt` supports both static strings and async functions:
|
||||
|
||||
```ts
|
||||
type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise<string>);
|
||||
|
||||
type RoleSpec<M> = {
|
||||
adapter: AgentFn;
|
||||
prompt: PromptInput;
|
||||
meta: Schema<M>;
|
||||
};
|
||||
```
|
||||
|
||||
Static prompts cover simple cases. Dynamic prompts (functions) are needed when the prompt depends on thread context — e.g. reading issue content, injecting prior step results, or resolving repo paths at runtime.
|
||||
|
||||
### Timeout Resolution
|
||||
|
||||
Timeout is an **adapter concern**, not a role concern. Roles define *what to do* (prompt + schema); adapters define *how to do it* (tool, model, timeout).
|
||||
|
||||
When different roles need different timeouts, create separate adapter instances:
|
||||
|
||||
```ts
|
||||
import { cursorAdapter, createCursorAdapter } from "@uncaged/nerve-adapter-cursor";
|
||||
|
||||
const fastCursor = createCursorAdapter({ model: "auto", timeout: 60 });
|
||||
const slowCursor = createCursorAdapter({ model: "auto", timeout: 600 });
|
||||
|
||||
roles: {
|
||||
reviewer: { adapter: fastCursor, prompt: reviewPrompt, meta: reviewSchema },
|
||||
coder: { adapter: slowCursor, prompt: coderPrompt, meta: coderSchema },
|
||||
}
|
||||
```
|
||||
|
||||
### No Runtime Fallback
|
||||
|
||||
- **`nerve init`** — detects agent availability (CLI exists? service reachable?), reports errors immediately
|
||||
- **Runtime** — if an agent is unavailable, the workflow fails with a clear error. No silent degradation.
|
||||
|
||||
Rationale: silent fallback hides quality differences (cursor → hermes subagent produces very different output) and makes debugging harder.
|
||||
|
||||
### Adapter Hot-Reload
|
||||
|
||||
Follows the existing `nerve.yaml` hot-reload mechanism. On config change, adapters are rebuilt. Running workflow threads are not affected (they use the `AdapterFn` bound at thread start). New threads automatically use the updated config.
|
||||
|
||||
### WorkflowContext
|
||||
|
||||
```ts
|
||||
type WorkflowContext = {
|
||||
start: StartStep;
|
||||
messages: WorkflowMessage[];
|
||||
workdir: string; // repo root — coding agent working directory
|
||||
signal: AbortSignal; // graceful cancellation
|
||||
};
|
||||
```
|
||||
|
||||
`workdir` is required for coding agents. `signal` enables graceful cancellation of long-running agent calls — adapters must respect it (e.g. kill subprocess on abort).
|
||||
|
||||
### Configuration Validation
|
||||
|
||||
`nerve validate` checks:
|
||||
- All roles have a valid adapter function (not null/undefined)
|
||||
- Adapter CLIs are available (binary exists in PATH)
|
||||
- Extract provider is configured and reachable
|
||||
|
||||
## Compatibility with Current Types
|
||||
|
||||
The existing `Role<Meta>` signature:
|
||||
|
||||
```ts
|
||||
type Role<Meta> = (start: StartStep, messages: WorkflowMessage[]) => Promise<RoleResult<Meta>>
|
||||
```
|
||||
|
||||
remains the runtime interface. The new config layer is syntactic sugar — the runtime assembles `Role<Meta>` functions from `(adapter + prompt + schema)` instead of users writing them by hand. `WorkflowDefinition` stays the same at the engine level; `WorkflowSpec` is the new user-facing authoring format that compiles down to it at daemon startup / hot-reload time (runtime lazy compile, not `nerve init`).
|
||||
|
||||
Existing hand-written `Role` functions continue to work — `WorkflowSpec` is additive, not a breaking change.
|
||||
|
||||
## Knowledge Layer
|
||||
|
||||
Project knowledge is a **built-in nerve feature**. Scope is the **repo** — each repo has its own knowledge base, tracked in git.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Local (per repo) Remote Service
|
||||
┌───────────────────────┐ ┌─────────────────────┐
|
||||
│ knowledge.yaml │ │ Embedding API │
|
||||
│ ├── include/exclude │ ──→ │ text → vector │
|
||||
│ knowledge.db (SQLite) │ ←── │ content-hash cache │
|
||||
│ ├── chunk text │ │ (avoid recompute) │
|
||||
│ ├── embedding bytes │ └─────────────────────┘
|
||||
│ └── cosine search │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
- **Local-first** — `knowledge.db` stores chunks + embeddings, search runs locally (in-memory cosine similarity)
|
||||
- **Remote service only computes embeddings** — content-addressable cache keyed by text hash, avoids redundant computation across agents
|
||||
- **Branch-aware by design** — different agents on different branches naturally have different `knowledge.db` contents
|
||||
|
||||
### Configuration (`knowledge.yaml` at repo root)
|
||||
|
||||
```yaml
|
||||
include:
|
||||
- "src/**/*.ts"
|
||||
- "docs/**/*.md"
|
||||
- "*.md"
|
||||
|
||||
exclude:
|
||||
- "node_modules/**"
|
||||
- "dist/**"
|
||||
- "*.test.ts"
|
||||
```
|
||||
|
||||
`knowledge.yaml` is committed to git. `knowledge.db` is gitignored — it's a local cache rebuilt from source files + remote embedding service.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
nerve knowledge sync # index/re-index changed files
|
||||
nerve knowledge query "how does the signal bus work"
|
||||
|
||||
# Scope
|
||||
nerve knowledge query "..." # default: cwd repo
|
||||
nerve knowledge query --repo /path/to/other/repo "..."
|
||||
nerve knowledge query -g "..." # global search (all indexed repos)
|
||||
# --repo and -g are mutually exclusive
|
||||
```
|
||||
|
||||
### Search Implementation
|
||||
|
||||
Project-scale knowledge (hundreds to low thousands of chunks) does not need vector indices. Full scan with cosine similarity in memory is sufficient and adds zero native dependencies.
|
||||
|
||||
```ts
|
||||
// Pseudocode
|
||||
const chunks = db.all("SELECT slug, chunk, embedding FROM chunks");
|
||||
const query_vec = await embed(query);
|
||||
const results = chunks
|
||||
.map(c => ({ ...c, score: cosine(query_vec, c.embedding) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
```
|
||||
|
||||
### Knowledge Layers
|
||||
|
||||
```
|
||||
Project knowledge (knowledge.yaml) Per repo, git managed, any agent reads
|
||||
Agent long-term memory Per agent, domain expertise, cross-run
|
||||
Workflow context (start + msgs) Per run, moderator-controlled history
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Agent long-term memory** — storage format and mechanism for persisting domain expertise across runs
|
||||
|
||||
### Resolved
|
||||
|
||||
- **Agent naming / registry** → removed; workflow roles declare adapter directly, no intermediate registry
|
||||
- **Extract override granularity** → two-level merge: global → role (agent level removed)
|
||||
- **Context threading** → `WorkflowContext` includes `workdir` and `signal` (see design above)
|
||||
- **Embedding service** → self-hosted, 1024-dim vectors, content-hash cache
|
||||
|
||||
## References
|
||||
|
||||
- [RFC-002: Workflow Engine](./rfc-002-workflow-engine.md)
|
||||
- Current `Role` / `Moderator` types: `packages/core/src/workflow.ts`
|
||||
@@ -0,0 +1,187 @@
|
||||
# RFC-004: Package Architecture — Shareable Workflows, Roles & Senses
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Status:** Draft
|
||||
**Created:** 2026-04-29
|
||||
|
||||
## Summary
|
||||
|
||||
Make workflows, roles, and senses publishable as lightweight npm packages. Workspaces become pure configuration — selecting packages, wiring adapters, and providing credentials. No builtin workflows in the nerve core.
|
||||
|
||||
## Motivation
|
||||
|
||||
Currently, workflows like `develop-sense` and `develop-workflow` live inside the workspace (`~/.uncaged-nerve/workflows/`). This creates problems:
|
||||
|
||||
1. **No sharing** — every workspace duplicates the same workflow code
|
||||
2. **No versioning** — upgrading a workflow means manual file edits
|
||||
3. **Builtin is a trap** — if we bake workflows into nerve core, they require adapters and LLM providers that may not be installed. A fresh `nerve` install on a bare machine would fail to load builtins.
|
||||
4. **Roles are already shared** — `_shared/workspace-committer.ts` proves the pattern works; we just need to formalize it as packages
|
||||
|
||||
The adapter pattern (`@uncaged/nerve-adapter-hermes`, `@uncaged/nerve-adapter-cursor`) already established the precedent: infrastructure as packages, workspace as wiring.
|
||||
|
||||
## Design
|
||||
|
||||
### Package Taxonomy
|
||||
|
||||
```
|
||||
@uncaged/nerve-core # types, engine
|
||||
@uncaged/nerve-daemon # runtime
|
||||
@uncaged/nerve-workflow-utils # createRole, decorateRole, withDryRun, onFail, etc.
|
||||
|
||||
# Adapters (existing)
|
||||
@uncaged/nerve-adapter-hermes
|
||||
@uncaged/nerve-adapter-cursor
|
||||
|
||||
# Workflows (new)
|
||||
@uncaged/nerve-workflow-solve-issue
|
||||
@uncaged/nerve-workflow-develop-sense
|
||||
@uncaged/nerve-workflow-develop-workflow
|
||||
|
||||
# Shared Roles (new)
|
||||
@uncaged/nerve-role-committer # workspace committer (branch, commit, push)
|
||||
@uncaged/nerve-role-reviewer # code review role
|
||||
@uncaged/nerve-role-publisher # PR creation role
|
||||
|
||||
# Senses (existing pattern, formalized)
|
||||
@uncaged/nerve-sense-cpu-usage
|
||||
@uncaged/nerve-sense-disk-usage
|
||||
```
|
||||
|
||||
### Package Contract
|
||||
|
||||
Each package type exports a factory function:
|
||||
|
||||
#### Workflow Package
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-workflow-develop-sense
|
||||
import type { AgentFn, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
|
||||
export type SenseMeta = { /* ... */ };
|
||||
|
||||
export type CreateDevelopSenseDeps = {
|
||||
defaultAdapter: AgentFn;
|
||||
adapters?: Partial<Record<keyof SenseMeta, AgentFn>>;
|
||||
extract: LlmExtractorConfig;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export function createDevelopSenseWorkflow(deps: CreateDevelopSenseDeps): WorkflowDefinition<SenseMeta>;
|
||||
```
|
||||
|
||||
Key design decisions:
|
||||
- `defaultAdapter` + optional `adapters` override per role — via `Partial<Record<keyof Meta, AgentFn>>`
|
||||
- Adapter keys are derived from `Meta` type — adding/removing a role automatically updates the adapter map
|
||||
- Roles that don't need an agent simply don't appear in `adapters` (the `Partial` allows this)
|
||||
|
||||
#### Role Package
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-role-committer
|
||||
import type { AgentFn, Role } from "@uncaged/nerve-core";
|
||||
import type { LlmExtractorConfig } from "@uncaged/nerve-workflow-utils";
|
||||
import { createRole, decorateRole, withDryRun, onFail } from "@uncaged/nerve-workflow-utils";
|
||||
|
||||
export type CommitterMeta = { committed: boolean };
|
||||
|
||||
export function createCommitterRole(adapter: AgentFn, extract: LlmExtractorConfig): Role<CommitterMeta> {
|
||||
const inner = createRole(adapter, prompt, committerMetaSchema, extract);
|
||||
return decorateRole(inner, [
|
||||
withDryRun({ label: "committer", meta: { committed: true } }),
|
||||
onFail({ label: "committer", meta: { committed: false } }),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Roles compose with the decorator chain from `workflow-utils`:
|
||||
- `withDryRun` — skip execution in dry-run mode
|
||||
- `onFail` — catch errors into structured failure results
|
||||
- `decorateRole(role, [...])` — apply decorators left-to-right
|
||||
- Custom `RoleDecorator<M>` can be created for project-specific needs
|
||||
|
||||
#### Sense Package
|
||||
|
||||
```ts
|
||||
// @uncaged/nerve-sense-cpu-usage
|
||||
export const senseName = "cpu-usage";
|
||||
export const schema = { /* drizzle schema */ };
|
||||
export async function compute(ctx: SenseContext): Promise<SenseResult>;
|
||||
```
|
||||
|
||||
### Workspace as Configuration
|
||||
|
||||
The workspace becomes a thin wiring layer:
|
||||
|
||||
```
|
||||
~/.uncaged-nerve/
|
||||
nerve.yaml # senses, extract config
|
||||
package.json # depends on workflow/role/adapter packages
|
||||
workflows/
|
||||
develop-sense/
|
||||
index.ts # ~10 lines: import package, wire adapters, export
|
||||
solve-issue/
|
||||
index.ts # same pattern
|
||||
```
|
||||
|
||||
A typical `index.ts`:
|
||||
|
||||
```ts
|
||||
import { createDevelopSenseWorkflow } from "@uncaged/nerve-workflow-develop-sense";
|
||||
import { hermesAdapter } from "@uncaged/nerve-adapter-hermes";
|
||||
import { cursorAdapter } from "@uncaged/nerve-adapter-cursor";
|
||||
|
||||
export default createDevelopSenseWorkflow({
|
||||
defaultAdapter: hermesAdapter,
|
||||
adapters: { planner: cursorAdapter, coder: cursorAdapter },
|
||||
extract: { provider: { apiKey, baseUrl, model } },
|
||||
cwd: nerveRoot,
|
||||
});
|
||||
```
|
||||
|
||||
### What Stays in Workspace
|
||||
|
||||
- **Custom workflows** — project-specific workflows that aren't general enough to share
|
||||
- **Custom senses** — project-specific metrics
|
||||
- **Configuration** — adapter selection, credentials, `nerve.yaml`
|
||||
- **Overrides** — a workspace can always write its own role/workflow instead of using a package
|
||||
|
||||
### Dependency Rules
|
||||
|
||||
```
|
||||
nerve-core ← no deps on other nerve packages
|
||||
nerve-workflow-utils ← depends on nerve-core
|
||||
nerve-adapter-* ← depends on nerve-core
|
||||
nerve-role-* ← depends on nerve-core, nerve-workflow-utils
|
||||
nerve-workflow-* ← depends on nerve-core, nerve-workflow-utils, may depend on nerve-role-*
|
||||
nerve-sense-* ← depends on nerve-core
|
||||
nerve-daemon ← depends on nerve-core, nerve-store
|
||||
```
|
||||
|
||||
Workflow packages depend on role packages (not adapters). Adapters are injected at the workspace level.
|
||||
|
||||
### Migration Path
|
||||
|
||||
1. **Phase 1: Extract role packages** — Start with `@uncaged/nerve-role-committer` (already `_shared/workspace-committer.ts`). Publish, update workspace to import from package.
|
||||
2. **Phase 2: Extract workflow packages** — Move `develop-sense` and `develop-workflow` to packages. Workspace `index.ts` becomes pure wiring.
|
||||
3. **Phase 3: Sense packages** — Formalize sense packaging (lower priority, senses are already self-contained directories).
|
||||
4. **Phase 4: Community** — Document the package contract so others can publish workflows/roles/senses.
|
||||
|
||||
### Not in Scope
|
||||
|
||||
- **No builtin workflows** — nerve core ships zero workflows. All workflows are packages installed by the workspace.
|
||||
- **No workflow marketplace/registry** — just npm packages. `pnpm add @uncaged/nerve-workflow-solve-issue`.
|
||||
- **No nerve.yaml workflow declaration** — workflows are still TypeScript entry points. The daemon discovers them the same way it does today.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Monorepo vs separate repos?** — Should workflow/role packages live in the nerve monorepo or separate repos? Monorepo is easier for coordinated releases; separate repos allow independent versioning.
|
||||
2. **Sense package format** — Senses currently bundle with esbuild. Should sense packages ship pre-bundled or as TypeScript source?
|
||||
3. **Version coupling** — How tightly should workflow packages pin `nerve-core`? Peer deps with semver range?
|
||||
|
||||
## Prior Art
|
||||
|
||||
- Adapter packages (`@uncaged/nerve-adapter-*`) — established the factory + injection pattern
|
||||
- `_shared/workspace-committer.ts` — proved roles can be shared across workflows
|
||||
- `createRole` / `decorateRole` / `withDryRun` / `onFail` in `workflow-utils` — building blocks that role packages compose
|
||||
- `defaultAdapter` + `Partial<Record<keyof Meta, AgentFn>>` pattern — adapter injection without coupling
|
||||
@@ -0,0 +1,80 @@
|
||||
# Skill: Publish @uncaged/nerve packages to npm
|
||||
|
||||
## When to use
|
||||
|
||||
When releasing a new version of any `@uncaged/nerve-*` package to npm.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- npm login with an account that has **owner** access to the `@uncaged` org
|
||||
- All tests pass: `pnpm -r run test`
|
||||
- Clean working tree (no uncommitted changes)
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Path | npm |
|
||||
|---------|------|-----|
|
||||
| `@uncaged/nerve-core` | `packages/core` | [link](https://www.npmjs.com/package/@uncaged/nerve-core) |
|
||||
| `@uncaged/nerve-daemon` | `packages/daemon` | [link](https://www.npmjs.com/package/@uncaged/nerve-daemon) |
|
||||
| `@uncaged/nerve-cli` | `packages/cli` | [link](https://www.npmjs.com/package/@uncaged/nerve-cli) |
|
||||
|
||||
## Dependency order
|
||||
|
||||
`core` → `daemon` → `cli`
|
||||
|
||||
Always publish in this order. If `core` has changes, bump and publish it first, then update dependents.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Ensure clean state
|
||||
|
||||
```bash
|
||||
git checkout main && git pull origin main
|
||||
pnpm install
|
||||
pnpm -r run build
|
||||
pnpm -r run test
|
||||
```
|
||||
|
||||
### 2. Bump versions
|
||||
|
||||
Manually update `version` in each changed package's `package.json`.
|
||||
|
||||
Follow semver:
|
||||
- **patch** (0.1.x): bug fixes, refactors
|
||||
- **minor** (0.x.0): new features, non-breaking API additions
|
||||
- **major** (x.0.0): breaking changes
|
||||
|
||||
If bumping `core`, also update the `@uncaged/nerve-core` dependency version in `daemon` and `cli` package.json. Same for `daemon` → `cli`.
|
||||
|
||||
### 3. Build
|
||||
|
||||
```bash
|
||||
pnpm -r run build
|
||||
```
|
||||
|
||||
### 4. Publish (in order)
|
||||
|
||||
```bash
|
||||
# Only publish packages that have version bumps
|
||||
# MUST use pnpm publish (not npm) — pnpm converts workspace:* to real versions
|
||||
cd packages/core && pnpm publish --access public --no-git-checks
|
||||
cd packages/daemon && pnpm publish --access public --no-git-checks
|
||||
cd packages/cli && pnpm publish --access public --no-git-checks
|
||||
```
|
||||
|
||||
### 5. Commit & tag
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "release: @uncaged/nerve-core@X.Y.Z, @uncaged/nerve-daemon@X.Y.Z, @uncaged/nerve-cli@X.Y.Z"
|
||||
git tag -a vX.Y.Z -m "Release vX.Y.Z"
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Don't publish without building first** — `tsup` output in `dist/` is what npm ships
|
||||
- **Dependency order matters** — if you publish `daemon` before `core`, npm may resolve the old `core` version
|
||||
- **`--access public`** is required for scoped packages on first publish; safe to always include
|
||||
- **Check `npm whoami`** to confirm you're logged in as the right account
|
||||
- **No changeset tool** — this project uses manual version bumps (no changesets/lerna)
|
||||
@@ -0,0 +1,101 @@
|
||||
# Skill: Setup nerve from scratch
|
||||
|
||||
## When to use
|
||||
|
||||
Setting up the nerve project for local development from a fresh clone.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** ≥ 18
|
||||
- **pnpm** ≥ 9 (`npm install -g pnpm`)
|
||||
- **Git** access to `git.shazhou.work`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Clone
|
||||
|
||||
```bash
|
||||
git clone https://git.shazhou.work/uncaged/nerve.git
|
||||
cd nerve
|
||||
```
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
This installs all workspace packages and links internal dependencies (`core` → `daemon` → `cli`).
|
||||
|
||||
### 3. Build all packages
|
||||
|
||||
```bash
|
||||
pnpm -r run build
|
||||
```
|
||||
|
||||
Build order is handled automatically by pnpm workspace — `core` builds first, then `daemon`, then `cli`.
|
||||
|
||||
### 4. Run tests
|
||||
|
||||
```bash
|
||||
pnpm -r run test
|
||||
```
|
||||
|
||||
Or test individual packages:
|
||||
|
||||
```bash
|
||||
pnpm --filter @uncaged/nerve-core test
|
||||
pnpm --filter @uncaged/nerve-daemon test
|
||||
pnpm --filter @uncaged/nerve-cli test
|
||||
```
|
||||
|
||||
### 5. Try the CLI
|
||||
|
||||
```bash
|
||||
# Link the CLI globally
|
||||
cd packages/cli && npm link
|
||||
|
||||
# Initialize a workspace
|
||||
mkdir ~/my-nerve-workspace && cd ~/my-nerve-workspace
|
||||
nerve init
|
||||
|
||||
# Edit senses in nerve.yaml, then:
|
||||
nerve start # start the daemon
|
||||
nerve sense list # list registered senses
|
||||
nerve stop # stop the daemon
|
||||
```
|
||||
|
||||
### 6. Lint & format
|
||||
|
||||
```bash
|
||||
pnpm run check # biome lint check
|
||||
pnpm run format # biome auto-format
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
nerve/
|
||||
├── packages/
|
||||
│ ├── core/ # @uncaged/nerve-core — shared types, log store, blob store
|
||||
│ ├── daemon/ # @uncaged/nerve-daemon — kernel, sense runtime, workflow manager
|
||||
│ └── cli/ # @uncaged/nerve-cli — CLI commands (init, start, stop, sense, etc.)
|
||||
├── docs/ # RFCs, conventions, skills
|
||||
├── pnpm-workspace.yaml
|
||||
└── biome.json # linter/formatter config
|
||||
```
|
||||
|
||||
## Key conventions
|
||||
|
||||
- **Monorepo** with pnpm workspaces
|
||||
- **ESM only** — all packages output ESM (`"type": "module"`)
|
||||
- **tsup** for builds, **vitest** for tests, **biome** for lint/format
|
||||
- **SQLite** (better-sqlite3) for log store and blob store
|
||||
- See `docs/coding-conventions.md` for code style rules
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Must build before test** — daemon and cli import compiled output from core
|
||||
- **better-sqlite3** requires native compilation — if `pnpm install` fails, ensure you have build tools (`build-essential` on Linux, Xcode CLI tools on macOS)
|
||||
- **Node 18+** required — uses native `fetch`, `crypto.randomUUID`, etc.
|
||||
- **pnpm only** — don't use npm/yarn, workspace links won't resolve correctly
|
||||
+11
-17
@@ -1,23 +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 the value is returned,
|
||||
* which causes the engine to emit a Signal.
|
||||
*/
|
||||
export async function compute(db: DrizzleDB, _peers: PeerMap): Promise<number | null> {
|
||||
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 oneMin;
|
||||
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
|
||||
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
|
||||
return { state: { samples: newSamples }, workflow: null };
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Migration: 0001_init
|
||||
-- Creates the samples table for the cpu-usage sense.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS samples (
|
||||
ts INTEGER PRIMARY KEY,
|
||||
value REAL NOT NULL
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
/**
|
||||
* Each row records one CPU load sample.
|
||||
* `ts` is the Unix timestamp in milliseconds (primary key, append-only).
|
||||
* `value` is the 1-minute load average from os.loadavg()[0].
|
||||
*/
|
||||
export const samples = sqliteTable("samples", {
|
||||
ts: integer("ts").primaryKey(),
|
||||
value: real("value").notNull(),
|
||||
});
|
||||
+4
-14
@@ -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:
|
||||
@@ -11,30 +11,20 @@ senses:
|
||||
throttle: 5s
|
||||
timeout: 8s
|
||||
grace_period: null
|
||||
interval: 10s
|
||||
|
||||
disk-usage:
|
||||
group: system
|
||||
throttle: null
|
||||
timeout: 15s
|
||||
grace_period: null
|
||||
interval: 30s
|
||||
|
||||
system-health:
|
||||
group: derived
|
||||
throttle: 2s
|
||||
timeout: 10s
|
||||
grace_period: null
|
||||
|
||||
reflexes:
|
||||
# cpu-usage runs on a 10-second interval
|
||||
- sense: cpu-usage
|
||||
interval: 10s
|
||||
|
||||
# disk-usage runs on a 30-second interval
|
||||
- sense: disk-usage
|
||||
interval: 30s
|
||||
|
||||
# system-health is event-driven: fires whenever cpu-usage or disk-usage emits a signal
|
||||
- sense: system-health
|
||||
on:
|
||||
- cpu-usage
|
||||
- disk-usage
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
* nerve-health — built-in sense that reports daemon health via IPC.
|
||||
*
|
||||
* When running inside a sense worker, this compute function sends a
|
||||
* "health-request" to the parent kernel process and returns the
|
||||
* health-response payload as its signal.
|
||||
* "health-request" to the parent kernel process and merges the response into state.
|
||||
*
|
||||
* Usage in nerve.yaml:
|
||||
* senses:
|
||||
@@ -11,9 +10,6 @@
|
||||
* group: internal
|
||||
* throttle: 30s
|
||||
* timeout: 5s
|
||||
*
|
||||
* reflexes:
|
||||
* - sense: nerve-health
|
||||
* interval: 30s
|
||||
*/
|
||||
|
||||
@@ -25,9 +21,23 @@ export type NerveHealth = {
|
||||
workerUptime: number;
|
||||
};
|
||||
|
||||
export async function compute(): Promise<NerveHealth | null> {
|
||||
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 health;
|
||||
return {
|
||||
state: { lastCheck: Date.now(), lastHealth: health },
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
|
||||
function requestHealthFromKernel(): Promise<NerveHealth> {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
include:
|
||||
- ".knowledge/**/*.md"
|
||||
|
||||
exclude: []
|
||||
+9
-2
@@ -1,14 +1,21 @@
|
||||
{
|
||||
"name": "nerve",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"build": "pnpm -r run build",
|
||||
"test": "pnpm -r test",
|
||||
"check": "biome check .",
|
||||
"format": "biome format --write ."
|
||||
"format": "biome format --write .",
|
||||
"link:dev": "bash scripts/link-dev.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.0",
|
||||
"tsup": "^8.0.0",
|
||||
"@rslib/core": "^0.21.3",
|
||||
"husky": "^9.1.7",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@uncaged/nerve-adapter-cursor",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rslib/core": "^0.21.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
export default defineConfig({
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
dts: true,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: "node",
|
||||
cleanDistPath: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
|
||||
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
|
||||
|
||||
export type CursorAgentMode = "plan" | "ask" | "default";
|
||||
|
||||
export type CursorAgentOptions = {
|
||||
prompt: string;
|
||||
mode: CursorAgentMode;
|
||||
model: string;
|
||||
cwd: string;
|
||||
env: SpawnEnv | null;
|
||||
timeoutMs: number | null;
|
||||
dryRun: boolean;
|
||||
abortSignal: AbortSignal | null;
|
||||
};
|
||||
|
||||
type CursorAgentOptionsInput = CursorAgentOptions | Omit<CursorAgentOptions, "dryRun">;
|
||||
|
||||
function resolveCursorAgentDryRun(options: CursorAgentOptionsInput): boolean {
|
||||
return "dryRun" in options ? options.dryRun : false;
|
||||
}
|
||||
|
||||
function normalizeAbortSignal(options: CursorAgentOptionsInput): AbortSignal | null {
|
||||
return "abortSignal" in options ? options.abortSignal : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes `cursor-agent` with the prompt passed as a single argv slot (`shell: false`).
|
||||
*/
|
||||
export async function cursorAgent(
|
||||
options: CursorAgentOptionsInput,
|
||||
): Promise<Result<string, SpawnError>> {
|
||||
const dryRun = resolveCursorAgentDryRun(options);
|
||||
if (dryRun) {
|
||||
return ok("[dryRun] skipped");
|
||||
}
|
||||
|
||||
const args: string[] = [
|
||||
"-p",
|
||||
options.prompt,
|
||||
"--model",
|
||||
options.model,
|
||||
"--output-format",
|
||||
"text",
|
||||
"--trust",
|
||||
"--force",
|
||||
];
|
||||
if (options.mode === "plan") {
|
||||
args.push("--mode=plan");
|
||||
} else if (options.mode === "ask") {
|
||||
args.push("--mode=ask");
|
||||
}
|
||||
|
||||
const run = await spawnSafe("cursor-agent", args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
timeoutMs: options.timeoutMs,
|
||||
dryRun: false,
|
||||
abortSignal: normalizeAbortSignal(options),
|
||||
});
|
||||
|
||||
if (!run.ok) {
|
||||
return run;
|
||||
}
|
||||
|
||||
return ok(run.value.stdout);
|
||||
}
|
||||
|
||||
function throwCursorSpawnError(error: SpawnError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
`cursor-agent: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
|
||||
);
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
throw new Error("cursor-agent: timeout");
|
||||
}
|
||||
if (error.kind === "aborted") {
|
||||
throw new Error("cursor-agent: aborted");
|
||||
}
|
||||
throw new Error(`cursor-agent: ${error.message}`);
|
||||
}
|
||||
|
||||
/** Default adapter config: model auto-selection and 300s wall-clock cap (milliseconds). */
|
||||
const CURSOR_ADAPTER_DEFAULT_MS = 300_000;
|
||||
|
||||
export type CursorAdapterConfig = AgentConfig & {
|
||||
/** When set, passes `--mode=ask` or `--mode=plan` to `cursor-agent` (default runs without extra mode). */
|
||||
mode?: CursorAgentMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Cursor CLI `AgentFn` from adapter config (model, timeout).
|
||||
*/
|
||||
export function createCursorAdapter(config: CursorAdapterConfig): AgentFn {
|
||||
const timeoutMs = config.timeout;
|
||||
const mode = config.mode ?? "default";
|
||||
|
||||
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
|
||||
const run = await cursorAgent({
|
||||
prompt,
|
||||
mode,
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
env: null,
|
||||
timeoutMs,
|
||||
dryRun: false,
|
||||
abortSignal: null,
|
||||
});
|
||||
if (!run.ok) {
|
||||
throwCursorSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
}
|
||||
|
||||
/** Default instance — `model: "auto"`, `timeout: 300` seconds (as milliseconds). */
|
||||
export const cursorAdapter: AgentFn = createCursorAdapter({
|
||||
type: "cursor",
|
||||
model: "auto",
|
||||
timeout: CURSOR_ADAPTER_DEFAULT_MS,
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@uncaged/nerve-adapter-hermes",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rslib/core": "^0.21.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
export default defineConfig({
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
dts: true,
|
||||
},
|
||||
],
|
||||
source: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: "node",
|
||||
cleanDistPath: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { AgentConfig, AgentFn, ThreadContext } from "@uncaged/nerve-core";
|
||||
import { type Result, type SpawnEnv, type SpawnError, ok, spawnSafe } from "@uncaged/nerve-core";
|
||||
|
||||
/**
|
||||
* Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only
|
||||
* (shell: false) following the Nerve issue #208 contract.
|
||||
*/
|
||||
export type HermesAgentOptions = {
|
||||
prompt: string;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
skills: string[];
|
||||
/** When true, suppresses interactive UI noise. */
|
||||
quiet: boolean;
|
||||
maxTurns: number;
|
||||
env: SpawnEnv | null;
|
||||
timeoutMs: number | null;
|
||||
dryRun: boolean;
|
||||
abortSignal: AbortSignal | null;
|
||||
};
|
||||
|
||||
type HermesAgentOptionsInput = HermesAgentOptions | Omit<HermesAgentOptions, "dryRun">;
|
||||
|
||||
function resolveHermesDryRun(options: HermesAgentOptionsInput): boolean {
|
||||
return "dryRun" in options ? options.dryRun : false;
|
||||
}
|
||||
|
||||
function normalizeAbortSignal(options: HermesAgentOptionsInput): AbortSignal | null {
|
||||
return "abortSignal" in options ? options.abortSignal : null;
|
||||
}
|
||||
|
||||
export async function hermesAgent(
|
||||
options: HermesAgentOptionsInput,
|
||||
): Promise<Result<string, SpawnError>> {
|
||||
const dryRun = resolveHermesDryRun(options);
|
||||
if (dryRun) {
|
||||
return ok("[dryRun] hermes stub");
|
||||
}
|
||||
const args: string[] = [
|
||||
"chat",
|
||||
"-q",
|
||||
options.prompt,
|
||||
"--yolo",
|
||||
"--max-turns",
|
||||
String(options.maxTurns),
|
||||
];
|
||||
if (options.model) {
|
||||
args.push("--model", options.model);
|
||||
}
|
||||
if (options.provider) {
|
||||
args.push("--provider", options.provider);
|
||||
}
|
||||
for (const s of options.skills) {
|
||||
args.push("-s", s);
|
||||
}
|
||||
if (options.quiet) {
|
||||
args.push("--quiet");
|
||||
}
|
||||
const run = await spawnSafe("hermes", args, {
|
||||
cwd: null,
|
||||
env: options.env,
|
||||
timeoutMs: options.timeoutMs,
|
||||
dryRun: false,
|
||||
abortSignal: normalizeAbortSignal(options),
|
||||
});
|
||||
if (!run.ok) {
|
||||
return run;
|
||||
}
|
||||
return ok(run.value.stdout);
|
||||
}
|
||||
|
||||
function throwHermesSpawnError(error: SpawnError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
`hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
|
||||
);
|
||||
}
|
||||
if (error.kind === "timeout") {
|
||||
throw new Error("hermes: timeout");
|
||||
}
|
||||
if (error.kind === "aborted") {
|
||||
throw new Error("hermes: aborted");
|
||||
}
|
||||
throw new Error(`hermes: ${error.message}`);
|
||||
}
|
||||
|
||||
const HERMES_ADAPTER_DEFAULT_MAX_TURNS = 90;
|
||||
|
||||
/** Default wall-clock cap: 300 seconds (milliseconds). */
|
||||
const HERMES_ADAPTER_DEFAULT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Builds a Hermes CLI `AgentFn` from adapter config (model, timeout).
|
||||
*/
|
||||
export function createHermesAdapter(config: AgentConfig): AgentFn {
|
||||
const modelFromConfig = config.model === "auto" ? null : config.model;
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (_ctx: ThreadContext, prompt: string): Promise<string> => {
|
||||
const run = await hermesAgent({
|
||||
prompt,
|
||||
model: modelFromConfig,
|
||||
provider: null,
|
||||
skills: [],
|
||||
quiet: true,
|
||||
maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS,
|
||||
env: null,
|
||||
timeoutMs,
|
||||
dryRun: false,
|
||||
abortSignal: null,
|
||||
});
|
||||
if (!run.ok) {
|
||||
throwHermesSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
}
|
||||
|
||||
/** Default instance — `model: "auto"`, `timeout: 300` seconds (as milliseconds). */
|
||||
export const hermesAdapter: AgentFn = createHermesAdapter({
|
||||
type: "hermes",
|
||||
model: "auto",
|
||||
timeout: HERMES_ADAPTER_DEFAULT_MS,
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# @uncaged/nerve-cli
|
||||
|
||||
Command-line interface for the [nerve](../../README.md) observation engine.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add -g @uncaged/nerve-cli
|
||||
# or
|
||||
npx @uncaged/nerve-cli
|
||||
```
|
||||
|
||||
Requires Node.js ≥ 22.5.
|
||||
|
||||
## Commands
|
||||
|
||||
### Workspace
|
||||
|
||||
```bash
|
||||
nerve init # Initialize a nerve workspace (installs deps, scaffolds config)
|
||||
nerve validate # Validate nerve.yaml configuration
|
||||
```
|
||||
|
||||
### Daemon management
|
||||
|
||||
```bash
|
||||
nerve daemon start # Start the daemon (background)
|
||||
nerve daemon stop # Stop the daemon
|
||||
nerve daemon status # Show pid, uptime, sense names from nerve.yaml (process must exist)
|
||||
nerve daemon restart # Stop then start
|
||||
nerve daemon logs # Tail daemon process logs (file under workspace logs/)
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
nerve dev # Foreground kernel with file watcher + IPC (Ctrl+C stops)
|
||||
```
|
||||
|
||||
### Querying & status
|
||||
|
||||
```bash
|
||||
nerve logs # Tail or page the daemon text log file (path in footer; default ~/.uncaged-nerve/logs/nerve.log)
|
||||
nerve status # Short daemon health summary (aliases daemon status)
|
||||
```
|
||||
|
||||
Structured rows in `data/logs.db` are surfaced via **`nerve workflow inspect`** / **`nerve workflow list`** (and `LogStore` in code), not via `nerve logs`.
|
||||
|
||||
### Sense
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Sense state is persisted as JSON under `data/senses/<name>.json` by the sense worker after each successful compute.
|
||||
|
||||
### Store maintenance
|
||||
|
||||
```bash
|
||||
nerve store archive # Move old log rows to JSONL under data/archive/logs/… (optional --vacuum)
|
||||
```
|
||||
|
||||
### Workflows
|
||||
|
||||
```bash
|
||||
nerve workflow list # Queued/started runs (add --all for terminal states; --workflow, --limit, --offset)
|
||||
nerve workflow inspect <runId> # Run metadata + paginated workflow log lines
|
||||
nerve workflow thread <runId> # Role rounds from persisted messages (--before, --budget)
|
||||
nerve workflow trigger <name> # IPC trigger-workflow (daemon must be running)
|
||||
# Optional JSON: --payload '{"prompt":"…","maxRounds":50}'
|
||||
```
|
||||
|
||||
`nerve workflow trigger` sends a `trigger-workflow` line on the daemon Unix socket (same protocol as `@uncaged/nerve-core` / `parseDaemonIpcRequest`). It does not read `nerve.yaml` workflow definitions beyond what the running daemon already loaded.
|
||||
|
||||
### Top-level aliases
|
||||
|
||||
```bash
|
||||
nerve start → nerve daemon start
|
||||
nerve stop → nerve daemon stop
|
||||
nerve status → nerve daemon status
|
||||
nerve logs → nerve daemon logs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,22 +1,36 @@
|
||||
{
|
||||
"name": "@uncaged/nerve-cli",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"nerve": "dist/cli.js"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": ["dist", "skills"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup"
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "rslib build",
|
||||
"pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-daemon run build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"@uncaged/nerve-daemon": "workspace:*",
|
||||
"citty": "^0.1.6"
|
||||
"@uncaged/nerve-store": "workspace:*",
|
||||
"citty": "^0.1.6",
|
||||
"picomatch": "^4.0.2",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0"
|
||||
"@rslib/core": "^0.21.3",
|
||||
"@types/node": "^22.0.0",
|
||||
"@uncaged/nerve-daemon": "workspace:*",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
export default defineConfig({
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
dts: true,
|
||||
banner: {
|
||||
js: "#!/usr/bin/env -S node --disable-warning=ExperimentalWarning",
|
||||
},
|
||||
},
|
||||
],
|
||||
source: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
cli: "src/cli.ts",
|
||||
"daemon-bootstrap": "src/daemon-bootstrap.ts",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: "node",
|
||||
cleanDistPath: true,
|
||||
externals: ["@uncaged/nerve-daemon", "@uncaged/nerve-store"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,587 @@
|
||||
<!-- nerve-cli-version: __NERVE_CLI_VERSION__ -->
|
||||
|
||||
## Cursor Agent 使用提示
|
||||
|
||||
在 Cursor 中与 Agent 对话时,可以用以下方式指代代码与配置:
|
||||
|
||||
- **`@Files` / `@file`**:引用单个文件,例如 `@nerve.yaml`、`@senses/cpu-usage/src/index.ts`,减少幻觉并让修改对准正确路径。
|
||||
- **`@Folder` / `@Codebase`**:需要跨目录理解工作区结构时使用;改动前仍应优先打开相关 sense/workflow 源文件确认。
|
||||
- **`@Terminal`**:把 CLI 输出纳入上下文,便于对照 `nerve daemon logs`、`nerve sense query` 等结果。
|
||||
- **`@Docs`**:若项目或依赖有文档索引,可用来对齐 API 与约定。
|
||||
- 工作区根目录下的 **`nerve.yaml`**、`senses/`、`workflows/` 是 nerve 的核心入口;讨论调度与配置时优先 `@` 这些路径。
|
||||
- 本规则由 `nerve agent inject cursor` 安装;更新 CLI 后在同一目录再次执行可覆盖为新版。
|
||||
|
||||
---
|
||||
|
||||
# Nerve — AI Agent 观测引擎
|
||||
|
||||
Nerve 是一个轻量级观测引擎守护进程。它持续观测外部状态,通过声明式规则响应变化,编排多步骤工作流。
|
||||
|
||||
## 核心架构
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Workflow → Log
|
||||
```
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| **Sense** | 观测函数,`compute()` 采样或推导数据。返回非 null 则发出 Signal,可选触发 Workflow。每个 Sense 有独立 SQLite 数据库。 |
|
||||
| **Signal** | Sense 返回非 null 时发出的通知。纯事实,无意图。通过内存 Signal Bus 分发,不持久化。 |
|
||||
| **Workflow** | 有状态的多步骤执行。包含 Role(有副作用的执行者)和 Moderator(纯路由器)。每个实例是一个 Thread,有唯一 runId。 |
|
||||
| **Log** | 不可变审计日志。记录执行、状态转换、错误。不能触发 Sense(防止反馈循环)。 |
|
||||
| **Engine** | 内核,持有 Signal Bus、Process Manager、Workflow Manager。不直接加载用户代码。 |
|
||||
| **Daemon** | 引擎运行时,作为后台进程运行。 |
|
||||
|
||||
**关键规则:**
|
||||
- 因果链单向:External → Sense → Signal → Workflow + Log
|
||||
- 进程隔离:每个 Sense group 一个 worker(长期),每个 Workflow 类型一个 worker(按需)
|
||||
- 两个扩展点:Sense(观测什么 + 何时)、Workflow(做什么)
|
||||
|
||||
## 工作区结构
|
||||
|
||||
由 `nerve init` 生成的工作区根目录(默认 `~/.uncaged-nerve/`)包含 **`AGENT.md`**。实现 sense/workflow 前先阅读该文件:它与本文 skill 对齐,约定目录布局、`createRole` 用法以及**始终在仓库根目录**执行的构建命令。
|
||||
|
||||
```
|
||||
~/.uncaged-nerve/
|
||||
├── AGENT.md # 人类 / Agent 可读的工作区约定(init 生成)
|
||||
├── nerve.yaml # 核心配置
|
||||
├── package.json # 单一根包(sense/workflow 下不再有独立 package)
|
||||
├── scripts/build.mjs # 根目录 esbuild;通过 npm/pnpm 的 build 脚本调用
|
||||
├── senses/
|
||||
│ └── <name>/
|
||||
│ ├── src/index.ts # exports compute() + table
|
||||
│ ├── src/schema.ts # Drizzle 表定义
|
||||
│ └── migrations/ # SQL 迁移
|
||||
├── workflows/
|
||||
│ └── <name>/
|
||||
│ ├── index.ts # default export:WorkflowDefinition
|
||||
│ ├── moderator.ts # 可选:抽出 moderator,由 index 导入
|
||||
│ ├── build.ts # 可选:共享常量 / 纯函数(避免 index 臃肿;非 esbuild 入口)
|
||||
│ └── roles/
|
||||
│ └── <role>.ts # 每角色单文件(推荐平铺,而非 roles/<role>/index.ts)
|
||||
└── data/ # 运行时数据(SQLite、blobs)
|
||||
```
|
||||
|
||||
### 命名约定
|
||||
|
||||
- **Workflow**:动词开头的 kebab-case(例如 `review-pull-request`、`deploy-staging`)。避免单独名词式命名(如 `notifications`)。
|
||||
- **Sense**:描述性名词 kebab-case(例如 `cpu-usage`)。
|
||||
|
||||
---
|
||||
|
||||
## CLI 完整参考
|
||||
|
||||
全局选项:`--host <host:port>`(连接远程 daemon)、`--api-token <secret>`(Bearer 认证)
|
||||
|
||||
### 初始化与脚手架
|
||||
|
||||
```bash
|
||||
nerve init # 初始化工作区
|
||||
nerve init --from <git-url> # 从 git 仓库克隆工作区
|
||||
nerve init workspace # 只初始化工作区结构
|
||||
|
||||
nerve create sense <name> # 创建 sense 脚手架
|
||||
nerve create sense <name> --force # 覆盖已有
|
||||
nerve create workflow <name> # 创建 workflow 脚手架
|
||||
nerve create workflow <name> --force
|
||||
|
||||
nerve validate # 验证 nerve.yaml 配置
|
||||
```
|
||||
|
||||
### Daemon 管理
|
||||
|
||||
```bash
|
||||
nerve daemon start # 启动后台 daemon
|
||||
nerve daemon start --port 3000 # 指定 HTTP API 端口
|
||||
nerve daemon stop # 停止 daemon
|
||||
nerve daemon restart # 重启
|
||||
nerve daemon status # 查看状态
|
||||
nerve daemon logs # 查看日志
|
||||
nerve daemon logs --follow # 实时日志
|
||||
nerve daemon logs --n 50 # 最近 50 行
|
||||
|
||||
nerve dev # 前台开发模式(不 fork daemon)
|
||||
nerve dev --port 3000 # 指定端口
|
||||
```
|
||||
|
||||
### Sense 操作
|
||||
|
||||
```bash
|
||||
nerve sense list # 列出所有注册的 sense
|
||||
nerve sense trigger <name> # 手动触发 sense 计算
|
||||
nerve sense schema <name> # 查看 sense 数据库表结构
|
||||
nerve sense schema <name> --json # JSON 格式
|
||||
nerve sense query <name> <sql> # 对 sense 数据库执行只读 SQL
|
||||
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
|
||||
```
|
||||
|
||||
### Workflow 操作
|
||||
|
||||
```bash
|
||||
nerve workflow list # 列出 nerve.yaml 中定义的 workflow
|
||||
nerve workflow status # 查看运行中的 workflow 状态
|
||||
nerve workflow trigger <name> # 触发 workflow
|
||||
nerve workflow trigger <name> --prompt "检查生产环境"
|
||||
nerve workflow trigger <name> --maxRounds 50
|
||||
nerve workflow trigger <name> --dryRun # 干跑模式
|
||||
```
|
||||
|
||||
### Thread(Workflow 执行记录)
|
||||
|
||||
```bash
|
||||
nerve thread list # 列出最近的 workflow 执行
|
||||
nerve thread list --all # 包含已完成/失败的
|
||||
nerve thread list --workflow <name> # 按 workflow 过滤
|
||||
nerve thread list --limit 50 # 最多 50 条
|
||||
|
||||
nerve thread show <runId> # 查看 role 对话轮次
|
||||
nerve thread show <runId> --budget 16000 # 增大输出预算(默认 8000 字符)
|
||||
|
||||
nerve thread inspect <runId> # 查看详情和事件
|
||||
|
||||
nerve thread kill <runId> # 终止运行中/排队中的 thread
|
||||
```
|
||||
|
||||
### Store(日志归档)
|
||||
|
||||
```bash
|
||||
nerve store archive # 导出旧日志到 JSONL 归档
|
||||
nerve store archive --vacuum # 归档后 VACUUM 数据库
|
||||
```
|
||||
|
||||
### Knowledge(知识库)
|
||||
|
||||
```bash
|
||||
nerve knowledge sync # 从 knowledge.yaml 重建索引
|
||||
nerve knowledge query "搜索内容" # 搜索知识库
|
||||
nerve knowledge query "内容" --limit 5
|
||||
nerve knowledge query "内容" -g # 搜索所有注册仓库
|
||||
```
|
||||
|
||||
### Remote(远程 daemon)
|
||||
|
||||
```bash
|
||||
nerve remote add <name> <host:port> --token <secret>
|
||||
nerve remote list
|
||||
nerve remote show <name>
|
||||
nerve remote set-url <name> <host>
|
||||
nerve remote set-token <name> <token>
|
||||
nerve remote remove <name>
|
||||
nerve remote default <name> # 设为默认远程
|
||||
```
|
||||
|
||||
### Agent(向 Hermes 注入本 skill)
|
||||
|
||||
```bash
|
||||
nerve agent status # CLI 版本与各 Hermes 注入目录中的 skill 版本
|
||||
nerve agent inject hermes # 安装到 ~/.hermes/skills/nerve
|
||||
nerve agent inject hermes --profile <name> # 写入 ~/.hermes/profiles/<name>/skills/nerve
|
||||
nerve agent update # 将所有已注入目录更新到当前 CLI 对应版本
|
||||
nerve agent remove hermes # 移除默认 profile 的注入
|
||||
nerve agent remove hermes --profile <name>
|
||||
|
||||
nerve agent inject cursor # 在 cwd 生成 .cursorrules
|
||||
nerve agent inject cursor --path /foo # 在指定目录生成
|
||||
nerve agent remove cursor [--path /foo]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## nerve.yaml 配置参考
|
||||
|
||||
```yaml
|
||||
# 引擎全局配置
|
||||
max_rounds: 100 # moderator 最大轮次(默认 100)
|
||||
|
||||
# Sense 配置
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system # 必填,同 group 的 sense 共享 worker
|
||||
interval: 10s # 轮询间隔(duration: 5s, 10m, 1h)
|
||||
throttle: 5s # 最小计算间隔
|
||||
timeout: 10s # compute 超时
|
||||
grace_period: null # 优雅关闭等待
|
||||
retention: 10000 # _signals 表最大行数(默认 10000)
|
||||
|
||||
system-health:
|
||||
group: derived
|
||||
on: [cpu-usage, disk-usage] # 响应式:被列出的 sense 发出 signal 时触发
|
||||
throttle: null
|
||||
timeout: null
|
||||
|
||||
# Workflow 配置
|
||||
workflows:
|
||||
my-workflow:
|
||||
concurrency: 1 # 必填,并发数
|
||||
overflow: drop # 必填,超并发时处理:drop | queue
|
||||
max_queue: 100 # overflow=queue 时的队列上限(默认 100)
|
||||
|
||||
# HTTP API
|
||||
api:
|
||||
port: 3000 # null = 不启用 HTTP
|
||||
host: "127.0.0.1" # 监听地址
|
||||
token: null # 非 loopback 时必填
|
||||
|
||||
# LLM Extract(可选)
|
||||
extract:
|
||||
provider: anthropic
|
||||
model: claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sense 开发指南
|
||||
|
||||
### compute 函数签名
|
||||
|
||||
Sense 的 `compute` **无参数**。它不接收数据库句柄:daemon 在 worker 内调用 `SenseComputeFn`,由运行时负责把非 null 结果的 `signal` 写入该 sense 的 Drizzle 表并记入 `_signals`。超时由运行时控制(对应 `nerve.yaml` 里的 `timeout`),无需在业务代码里读取 `AbortSignal`。
|
||||
|
||||
```typescript
|
||||
import type { ComputeResult, SenseComputeFn } from "@uncaged/nerve-core";
|
||||
|
||||
export const compute: SenseComputeFn<MySignalShape> = async () => {
|
||||
// ...
|
||||
};
|
||||
// 或等价地:
|
||||
export async function compute(): Promise<ComputeResult<MySignalShape>> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
(运行时定义见 `@uncaged/nerve-core` 的 `SenseComputeFn` / `SenseModule`,daemon 侧在 `sense-runtime.ts` 的 `executeCompute` 中插入 `result.signal`。)
|
||||
|
||||
### 返回值
|
||||
|
||||
```typescript
|
||||
// 返回 null = 静默,不发 signal
|
||||
// 返回非 null = 发出 signal(并写入业务表),可选触发 workflow
|
||||
type ComputeResult<T> =
|
||||
| null
|
||||
| { signal: T; workflow: WorkflowTrigger | null };
|
||||
|
||||
type WorkflowTrigger = {
|
||||
name: string; // workflow 名称(对应 nerve.yaml 中的 key)
|
||||
maxRounds: number; // moderator 最大轮次
|
||||
prompt: string; // 初始 prompt
|
||||
dryRun: boolean; // 干跑模式
|
||||
};
|
||||
```
|
||||
|
||||
若返回值是普通对象且不含 `signal` 字段,内核会按 shorthand 视为 `{ signal: payload, workflow: null }`(见 core 的 `routeSenseComputeOutput`)。
|
||||
|
||||
### Sense 模块导出
|
||||
|
||||
```typescript
|
||||
// senses/<name>/src/index.ts
|
||||
import type { ComputeResult } from "@uncaged/nerve-core";
|
||||
import { table } from "./schema.js";
|
||||
|
||||
type Row = { ts: number; value: number };
|
||||
|
||||
export async function compute(): Promise<ComputeResult<Row>> {
|
||||
const row: Row = { ts: Date.now(), value: Math.random() }; // 替换为真实观测逻辑
|
||||
return { signal: row, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
```
|
||||
|
||||
### Schema 定义
|
||||
|
||||
```typescript
|
||||
// senses/<name>/src/schema.ts
|
||||
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const table = sqliteTable("samples", {
|
||||
ts: integer("ts").notNull(),
|
||||
value: real("value").notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
### 调度方式
|
||||
|
||||
1. **interval 轮询**:`interval: 10s` — 每 10 秒执行一次
|
||||
2. **响应式触发**:`on: [cpu-usage]` — 当 cpu-usage 发出 signal 时触发
|
||||
3. 两者可以组合
|
||||
|
||||
### 调试
|
||||
|
||||
```bash
|
||||
nerve dev # 前台运行,看实时输出
|
||||
nerve sense trigger <name> # 手动触发一次
|
||||
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 5"
|
||||
```
|
||||
|
||||
### 完整示例:CPU 监控
|
||||
|
||||
```typescript
|
||||
// senses/cpu-usage/src/schema.ts
|
||||
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const table = sqliteTable("samples", {
|
||||
ts: integer("ts").notNull(),
|
||||
value: real("value").notNull(),
|
||||
});
|
||||
|
||||
// senses/cpu-usage/src/index.ts
|
||||
import os from "node:os";
|
||||
import type { ComputeResult } from "@uncaged/nerve-core";
|
||||
import { table } from "./schema.js";
|
||||
|
||||
type Row = { ts: number; value: number };
|
||||
|
||||
export async function compute(): Promise<ComputeResult<Row>> {
|
||||
const oneMin = os.loadavg()[0];
|
||||
return { signal: { ts: Date.now(), value: oneMin }, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
```
|
||||
|
||||
nerve.yaml:
|
||||
```yaml
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system
|
||||
interval: 10s
|
||||
throttle: 5s
|
||||
timeout: 10s
|
||||
retention: 10000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow 开发指南
|
||||
|
||||
### 核心类型
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
WorkflowDefinition,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
RoleMeta,
|
||||
Moderator,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
// Role<Meta> — (ctx: ThreadContext) => Promise<RoleResult<Meta>>
|
||||
// RoleResult<Meta> — { content: string; meta: Meta }
|
||||
// ThreadContext<M extends RoleMeta> — threadId, start(__start__ 帧), steps(各 role 轮次)
|
||||
// Moderator<M> — (ctx) => 下一个 role 名 | END
|
||||
// WorkflowDefinition<M extends RoleMeta> — name, roles, moderator
|
||||
```
|
||||
|
||||
### createRole 四元组(接入 LLM 时推荐)
|
||||
|
||||
工作区根目录需安装 **`@uncaged/nerve-workflow-utils`**(及所选 agent 适配器包)。默认 `nerve init` 的 `package.json` 不含该依赖时,在 `~/.uncaged-nerve` 下执行 `pnpm add @uncaged/nerve-workflow-utils`(或 npm 等价命令)。
|
||||
|
||||
使用 **`createRole`**,按固定顺序传入四件事:
|
||||
|
||||
1. **adapter** — `AgentFn`,`(ctx, systemPrompt) => Promise<string>`(原始模型输出文本)。
|
||||
2. **prompt** — `string`,或 `async (ctx: ThreadContext) => string`。
|
||||
3. **meta** — `z.ZodType<M>`,供 moderator 路由的结构化 meta。
|
||||
4. **extract** — `{ provider: LlmProvider; dryRun: boolean | null }`,声明从回复中抽取 meta 时用的 LLM(OpenAI 兼容)及是否 dry-run。
|
||||
|
||||
```typescript
|
||||
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { ThreadContext } from "@uncaged/nerve-core";
|
||||
import { z } from "zod";
|
||||
|
||||
const provider = {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: process.env.EXAMPLE_API_KEY!,
|
||||
model: "gpt-4o-mini",
|
||||
};
|
||||
|
||||
const planMeta = z.object({ next: z.enum(["execute", "stop"]) });
|
||||
|
||||
export const planner = createRole(
|
||||
createLlmAdapter(provider),
|
||||
async (ctx: ThreadContext) => `规划任务:${ctx.start.content}`,
|
||||
planMeta,
|
||||
{ provider, dryRun: null },
|
||||
);
|
||||
```
|
||||
|
||||
`createLlmAdapter` 仅位于 **`@uncaged/nerve-workflow-utils`**:用 `LlmProvider` 生成 `AgentFn`,单轮对话里 **system** 来自 `createRole` 解析后的 prompt 字符串,**user** 为线程起点 `ctx.start.content`。
|
||||
|
||||
### 基本 Workflow 示例(平铺 `roles/<role>.ts`)
|
||||
|
||||
```typescript
|
||||
// workflows/example/roles/main.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
|
||||
const prompt = ctx.start.content;
|
||||
return {
|
||||
content: `处理完成: ${prompt}`,
|
||||
meta: { round: ctx.steps.length },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/example/index.ts
|
||||
import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
import { main } from "./roles/main.js";
|
||||
|
||||
type Meta = Record<"main", { round: number }>;
|
||||
|
||||
const workflow: WorkflowDefinition<Meta> = {
|
||||
name: "example",
|
||||
roles: { main },
|
||||
moderator(ctx: ThreadContext<Meta>) {
|
||||
return ctx.steps.length === 0 ? "main" : END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
```
|
||||
|
||||
可选:将 `moderator` 挪到 `moderator.ts` 再 `import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`。
|
||||
|
||||
### 多 Role Workflow 示例
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/roles/planner.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "计划: ...", meta: { status: "planned" } };
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/roles/executor.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "执行: ...", meta: { status: "executed" } };
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/roles/reviewer.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "审核通过", meta: { status: "approved" } };
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/index.ts
|
||||
import type { WorkflowDefinition, ThreadContext } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
import { executor } from "./roles/executor.js";
|
||||
import { planner } from "./roles/planner.js";
|
||||
import { reviewer } from "./roles/reviewer.js";
|
||||
|
||||
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
|
||||
|
||||
const workflow: WorkflowDefinition<Roles> = {
|
||||
name: "plan-execute-review",
|
||||
roles: { planner, executor, reviewer },
|
||||
moderator(ctx: ThreadContext<Roles>) {
|
||||
if (ctx.steps.length === 0) return "planner";
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
if (last.role === "planner") return "executor";
|
||||
if (last.role === "executor") return "reviewer";
|
||||
return END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
```
|
||||
|
||||
### Agent 适配器
|
||||
|
||||
Workflow role 可以集成 AI agent。已知适配器 **ID**:`echo`、`cursor`、`hermes`、`codex`。
|
||||
|
||||
```typescript
|
||||
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
||||
```
|
||||
|
||||
没有现成 agent 包时,用 **`createLlmAdapter`(`@uncaged/nerve-workflow-utils`)** 从 OpenAI 兼容的 `LlmProvider` 构造 `AgentFn`,再交给 **`createRole`** 的四元组。
|
||||
|
||||
### Workflow 运行状态
|
||||
|
||||
`queued` → `started` → `completed` | `failed` | `crashed` | `killed` | `interrupted` | `dropped`
|
||||
|
||||
---
|
||||
|
||||
## 日常操作 Pattern
|
||||
|
||||
### 查看系统整体状态
|
||||
|
||||
```bash
|
||||
nerve daemon status # daemon 是否在运行
|
||||
nerve sense list # 所有 sense 及其调度配置
|
||||
nerve workflow status # 运行中的 workflow
|
||||
nerve thread list # 最近的 workflow 执行记录
|
||||
```
|
||||
|
||||
### 检查某个 sense 的历史数据
|
||||
|
||||
```bash
|
||||
nerve sense query cpu-usage "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
|
||||
nerve sense schema cpu-usage # 查看表结构
|
||||
```
|
||||
|
||||
### 手动触发 workflow
|
||||
|
||||
```bash
|
||||
nerve workflow trigger my-workflow --prompt "手动检查"
|
||||
nerve thread list --workflow my-workflow # 查看执行状态
|
||||
nerve thread show <runId> # 查看对话详情
|
||||
```
|
||||
|
||||
### 排查 sense 报错
|
||||
|
||||
```bash
|
||||
nerve daemon logs --follow # 查看实时日志
|
||||
nerve sense trigger <name> # 手动触发看报错
|
||||
nerve dev # 前台模式,更详细的输出
|
||||
```
|
||||
|
||||
### 开发新 sense
|
||||
|
||||
```bash
|
||||
nerve create sense my-sensor # 脚手架
|
||||
# 编辑 senses/my-sensor/src/index.ts 和 schema.ts
|
||||
nerve validate # 验证配置
|
||||
nerve dev # 前台测试
|
||||
nerve sense trigger my-sensor # 单次触发验证
|
||||
nerve sense query my-sensor "SELECT * FROM ..." # 检查数据
|
||||
```
|
||||
|
||||
### 开发新 workflow
|
||||
|
||||
```bash
|
||||
nerve create workflow my-flow # 脚手架(当前 CLI 可能仍生成 roles/<name>/ 子目录)
|
||||
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
|
||||
nerve validate # 验证配置
|
||||
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建(等价:pnpm run build);勿在单个 workflow 子目录单独跑 build
|
||||
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
|
||||
nerve thread show <runId> # 查看执行轨迹
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Sense 返回值**:返回 `null` 表示静默(不发 signal);返回 `{ signal, workflow }` 才发 signal。不要返回 undefined。
|
||||
- **Sense 持久化**:daemon 在 `compute()` 返回非 null 时自动执行 `db.insert(table).values(signal)` 并写入 `_signals`;业务代码不要自行 insert。
|
||||
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
|
||||
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`。
|
||||
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
|
||||
- **_signals 表**:每个 sense 自动有 `_signals` 表记录 signal 历史,受 `retention` 配置限制。
|
||||
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
|
||||
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
|
||||
@@ -0,0 +1,580 @@
|
||||
---
|
||||
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`。)
|
||||
|
||||
### 返回值
|
||||
|
||||
```typescript
|
||||
// 返回 null = 静默,不发 signal
|
||||
// 返回非 null = 发出 signal(并写入业务表),可选触发 workflow
|
||||
type ComputeResult<T> =
|
||||
| null
|
||||
| { signal: T; workflow: WorkflowTrigger | null };
|
||||
|
||||
type WorkflowTrigger = {
|
||||
name: string; // workflow 名称(对应 nerve.yaml 中的 key)
|
||||
maxRounds: number; // moderator 最大轮次
|
||||
prompt: string; // 初始 prompt
|
||||
dryRun: boolean; // 干跑模式
|
||||
};
|
||||
```
|
||||
|
||||
若返回值是普通对象且不含 `signal` 字段,内核会按 shorthand 视为 `{ signal: payload, workflow: null }`(见 core 的 `routeSenseComputeOutput`)。
|
||||
|
||||
### Sense 模块导出
|
||||
|
||||
```typescript
|
||||
// senses/<name>/src/index.ts
|
||||
import type { ComputeResult } from "@uncaged/nerve-core";
|
||||
import { table } from "./schema.js";
|
||||
|
||||
type Row = { ts: number; value: number };
|
||||
|
||||
export async function compute(): Promise<ComputeResult<Row>> {
|
||||
const row: Row = { ts: Date.now(), value: Math.random() }; // 替换为真实观测逻辑
|
||||
return { signal: row, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
```
|
||||
|
||||
### Schema 定义
|
||||
|
||||
```typescript
|
||||
// senses/<name>/src/schema.ts
|
||||
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const table = sqliteTable("samples", {
|
||||
ts: integer("ts").notNull(),
|
||||
value: real("value").notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
### 调度方式
|
||||
|
||||
1. **interval 轮询**:`interval: 10s` — 每 10 秒执行一次
|
||||
2. **响应式触发**:`on: [cpu-usage]` — 当 cpu-usage 发出 signal 时触发
|
||||
3. 两者可以组合
|
||||
|
||||
### 调试
|
||||
|
||||
```bash
|
||||
nerve dev # 前台运行,看实时输出
|
||||
nerve sense trigger <name> # 手动触发一次
|
||||
nerve sense query <name> "SELECT * FROM samples ORDER BY ts DESC LIMIT 5"
|
||||
```
|
||||
|
||||
### 完整示例:CPU 监控
|
||||
|
||||
```typescript
|
||||
// senses/cpu-usage/src/schema.ts
|
||||
import { sqliteTable, integer, real } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const table = sqliteTable("samples", {
|
||||
ts: integer("ts").notNull(),
|
||||
value: real("value").notNull(),
|
||||
});
|
||||
|
||||
// senses/cpu-usage/src/index.ts
|
||||
import os from "node:os";
|
||||
import type { ComputeResult } from "@uncaged/nerve-core";
|
||||
import { table } from "./schema.js";
|
||||
|
||||
type Row = { ts: number; value: number };
|
||||
|
||||
export async function compute(): Promise<ComputeResult<Row>> {
|
||||
const oneMin = os.loadavg()[0];
|
||||
return { signal: { ts: Date.now(), value: oneMin }, workflow: null };
|
||||
}
|
||||
|
||||
export { table };
|
||||
```
|
||||
|
||||
nerve.yaml:
|
||||
```yaml
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system
|
||||
interval: 10s
|
||||
throttle: 5s
|
||||
timeout: 10s
|
||||
retention: 10000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow 开发指南
|
||||
|
||||
### 核心类型
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
WorkflowDefinition,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
RoleMeta,
|
||||
Moderator,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
// Role<Meta> — (ctx: ThreadContext) => Promise<RoleResult<Meta>>
|
||||
// RoleResult<Meta> — { content: string; meta: Meta }
|
||||
// ThreadContext<M extends RoleMeta> — threadId, start(__start__ 帧), steps(各 role 轮次)
|
||||
// Moderator<M> — (ctx) => 下一个 role 名 | END
|
||||
// WorkflowDefinition<M extends RoleMeta> — name, roles, moderator
|
||||
```
|
||||
|
||||
### createRole 四元组(接入 LLM 时推荐)
|
||||
|
||||
工作区根目录需安装 **`@uncaged/nerve-workflow-utils`**(及所选 agent 适配器包)。默认 `nerve init` 的 `package.json` 不含该依赖时,在 `~/.uncaged-nerve` 下执行 `pnpm add @uncaged/nerve-workflow-utils`(或 npm 等价命令)。
|
||||
|
||||
使用 **`createRole`**,按固定顺序传入四件事:
|
||||
|
||||
1. **adapter** — `AgentFn`,`(ctx, systemPrompt) => Promise<string>`(原始模型输出文本)。
|
||||
2. **prompt** — `string`,或 `async (ctx: ThreadContext) => string`。
|
||||
3. **meta** — `z.ZodType<M>`,供 moderator 路由的结构化 meta。
|
||||
4. **extract** — `{ provider: LlmProvider; dryRun: boolean | null }`,声明从回复中抽取 meta 时用的 LLM(OpenAI 兼容)及是否 dry-run。
|
||||
|
||||
```typescript
|
||||
import { createLlmAdapter, createRole } from "@uncaged/nerve-workflow-utils";
|
||||
import type { ThreadContext } from "@uncaged/nerve-core";
|
||||
import { z } from "zod";
|
||||
|
||||
const provider = {
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiKey: process.env.EXAMPLE_API_KEY!,
|
||||
model: "gpt-4o-mini",
|
||||
};
|
||||
|
||||
const planMeta = z.object({ next: z.enum(["execute", "stop"]) });
|
||||
|
||||
export const planner = createRole(
|
||||
createLlmAdapter(provider),
|
||||
async (ctx: ThreadContext) => `规划任务:${ctx.start.content}`,
|
||||
planMeta,
|
||||
{ provider, dryRun: null },
|
||||
);
|
||||
```
|
||||
|
||||
`createLlmAdapter` 仅位于 **`@uncaged/nerve-workflow-utils`**:用 `LlmProvider` 生成 `AgentFn`,单轮对话里 **system** 来自 `createRole` 解析后的 prompt 字符串,**user** 为线程起点 `ctx.start.content`。
|
||||
|
||||
### 基本 Workflow 示例(平铺 `roles/<role>.ts`)
|
||||
|
||||
```typescript
|
||||
// workflows/example/roles/main.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function main(ctx: ThreadContext): Promise<RoleResult<{ round: number }>> {
|
||||
const prompt = ctx.start.content;
|
||||
return {
|
||||
content: `处理完成: ${prompt}`,
|
||||
meta: { round: ctx.steps.length },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/example/index.ts
|
||||
import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
import { main } from "./roles/main.js";
|
||||
|
||||
type Meta = Record<"main", { round: number }>;
|
||||
|
||||
const workflow: WorkflowDefinition<Meta> = {
|
||||
name: "example",
|
||||
roles: { main },
|
||||
moderator(ctx: ThreadContext<Meta>) {
|
||||
return ctx.steps.length === 0 ? "main" : END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
```
|
||||
|
||||
可选:将 `moderator` 挪到 `moderator.ts` 再 `import { route } from "./moderator.js"`,保持 `index.ts` 只负责组装 `WorkflowDefinition`。
|
||||
|
||||
### 多 Role Workflow 示例
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/roles/planner.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function planner(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "计划: ...", meta: { status: "planned" } };
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/roles/executor.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function executor(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "执行: ...", meta: { status: "executed" } };
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/roles/reviewer.ts
|
||||
import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
export async function reviewer(ctx: ThreadContext): Promise<RoleResult<{ status: string }>> {
|
||||
void ctx;
|
||||
return { content: "审核通过", meta: { status: "approved" } };
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// workflows/plan-execute-review/index.ts
|
||||
import type { WorkflowDefinition, ThreadContext } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
import { executor } from "./roles/executor.js";
|
||||
import { planner } from "./roles/planner.js";
|
||||
import { reviewer } from "./roles/reviewer.js";
|
||||
|
||||
type Roles = Record<"planner" | "executor" | "reviewer", { status: string }>;
|
||||
|
||||
const workflow: WorkflowDefinition<Roles> = {
|
||||
name: "plan-execute-review",
|
||||
roles: { planner, executor, reviewer },
|
||||
moderator(ctx: ThreadContext<Roles>) {
|
||||
if (ctx.steps.length === 0) return "planner";
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
if (last.role === "planner") return "executor";
|
||||
if (last.role === "executor") return "reviewer";
|
||||
return END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
```
|
||||
|
||||
### Agent 适配器
|
||||
|
||||
Workflow role 可以集成 AI agent。已知适配器 **ID**:`echo`、`cursor`、`hermes`、`codex`。
|
||||
|
||||
```typescript
|
||||
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
||||
```
|
||||
|
||||
没有现成 agent 包时,用 **`createLlmAdapter`(`@uncaged/nerve-workflow-utils`)** 从 OpenAI 兼容的 `LlmProvider` 构造 `AgentFn`,再交给 **`createRole`** 的四元组。
|
||||
|
||||
### Workflow 运行状态
|
||||
|
||||
`queued` → `started` → `completed` | `failed` | `crashed` | `killed` | `interrupted` | `dropped`
|
||||
|
||||
---
|
||||
|
||||
## 日常操作 Pattern
|
||||
|
||||
### 查看系统整体状态
|
||||
|
||||
```bash
|
||||
nerve daemon status # daemon 是否在运行
|
||||
nerve sense list # 所有 sense 及其调度配置
|
||||
nerve workflow status # 运行中的 workflow
|
||||
nerve thread list # 最近的 workflow 执行记录
|
||||
```
|
||||
|
||||
### 检查某个 sense 的历史数据
|
||||
|
||||
```bash
|
||||
nerve sense query cpu-usage "SELECT * FROM samples ORDER BY ts DESC LIMIT 10" --json
|
||||
nerve sense schema cpu-usage # 查看表结构
|
||||
```
|
||||
|
||||
### 手动触发 workflow
|
||||
|
||||
```bash
|
||||
nerve workflow trigger my-workflow --prompt "手动检查"
|
||||
nerve thread list --workflow my-workflow # 查看执行状态
|
||||
nerve thread show <runId> # 查看对话详情
|
||||
```
|
||||
|
||||
### 排查 sense 报错
|
||||
|
||||
```bash
|
||||
nerve daemon logs --follow # 查看实时日志
|
||||
nerve sense trigger <name> # 手动触发看报错
|
||||
nerve dev # 前台模式,更详细的输出
|
||||
```
|
||||
|
||||
### 开发新 sense
|
||||
|
||||
```bash
|
||||
nerve create sense my-sensor # 脚手架
|
||||
# 编辑 senses/my-sensor/src/index.ts 和 schema.ts
|
||||
nerve validate # 验证配置
|
||||
nerve dev # 前台测试
|
||||
nerve sense trigger my-sensor # 单次触发验证
|
||||
nerve sense query my-sensor "SELECT * FROM ..." # 检查数据
|
||||
```
|
||||
|
||||
### 开发新 workflow
|
||||
|
||||
```bash
|
||||
nerve create workflow my-flow # 脚手架(当前 CLI 可能仍生成 roles/<name>/ 子目录)
|
||||
# 推荐对齐 AGENT.md:workflows/my-flow/index.ts + roles/<role>.ts(平铺),moderator 可拆到 moderator.ts
|
||||
nerve validate # 验证配置
|
||||
cd ~/.uncaged-nerve && npm run build # 工作区根目录构建(等价:pnpm run build);勿在单个 workflow 子目录单独跑 build
|
||||
nerve workflow trigger my-flow --prompt "测试" --dryRun # 干跑
|
||||
nerve thread show <runId> # 查看执行轨迹
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Sense 返回值**:返回 `null` 表示静默(不发 signal);返回 `{ signal, workflow }` 才发 signal。不要返回 undefined。
|
||||
- **Sense 持久化**:daemon 在 `compute()` 返回非 null 时自动执行 `db.insert(table).values(signal)` 并写入 `_signals`;业务代码不要自行 insert。
|
||||
- **no optional properties**:nerve 代码规范禁止 `?:`,用 `T | null` 代替。
|
||||
- **函数式风格**:用 `function` + `type`,不用 `class` + `interface`。
|
||||
- **workflow 用 default export**:工作区里通常只有 `workflows/<name>/index.ts` 使用 default export(daemon 加载约定)。
|
||||
- **_signals 表**:每个 sense 自动有 `_signals` 表记录 signal 历史,受 `retention` 配置限制。
|
||||
- **concurrency + overflow**:workflow 必须配置并发策略,否则验证失败。
|
||||
- **moderator 是同步函数**:不要加 async,moderator 是纯路由逻辑,不能有副作用。
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chunkMarkdown } from "../knowledge/chunk-markdown.js";
|
||||
|
||||
describe("chunkMarkdown", () => {
|
||||
it("splits markdown by headings into separate chunks", () => {
|
||||
const md = `# Title One
|
||||
|
||||
Intro para under first heading.
|
||||
|
||||
## Title Two
|
||||
|
||||
Second section body.
|
||||
|
||||
`;
|
||||
const chunks = chunkMarkdown("docs/guide.md", md);
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
||||
const joined = chunks.map((c) => c.text).join("\n");
|
||||
expect(joined).toContain("Title One");
|
||||
expect(joined).toContain("Title Two");
|
||||
});
|
||||
|
||||
it("includes preamble before first heading as its own chunk when present", () => {
|
||||
const md = `Preamble line here.
|
||||
|
||||
# First Real Heading
|
||||
|
||||
Under heading.
|
||||
`;
|
||||
const chunks = chunkMarkdown("readme.md", md);
|
||||
const preamble = chunks.find((c) => c.slug.includes("preamble"));
|
||||
expect(preamble).toBeDefined();
|
||||
expect(preamble?.text).toContain("Preamble");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
consumeGlobalDaemonCliFlags,
|
||||
getCliDaemonApiToken,
|
||||
getCliDaemonHost,
|
||||
} from "../cli-global.js";
|
||||
|
||||
describe("consumeGlobalDaemonCliFlags", () => {
|
||||
it("strips --host and --api-token and populates getters", () => {
|
||||
const out = consumeGlobalDaemonCliFlags([
|
||||
"--host",
|
||||
"192.168.1.5:9800",
|
||||
"--api-token=abc",
|
||||
"sense",
|
||||
"list",
|
||||
]);
|
||||
expect(out).toEqual(["sense", "list"]);
|
||||
expect(getCliDaemonHost()).toBe("192.168.1.5:9800");
|
||||
expect(getCliDaemonApiToken()).toBe("abc");
|
||||
});
|
||||
|
||||
it("supports --host=value form", () => {
|
||||
consumeGlobalDaemonCliFlags(["--host=luming:9800", "status"]);
|
||||
expect(getCliDaemonHost()).toBe("luming:9800");
|
||||
});
|
||||
|
||||
it("throws when --host has no value", () => {
|
||||
expect(() => consumeGlobalDaemonCliFlags(["--host", "--api-token", "x"])).toThrow(/--host/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Tests for nerve create sense template helpers.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildSenseIndexTs, validateResourceName } from "../commands/create.js";
|
||||
|
||||
describe("validateSenseName", () => {
|
||||
it("accepts valid ids", () => {
|
||||
expect(validateResourceName("a", "Sense")).toBe(null);
|
||||
expect(validateResourceName("my-sense", "Sense")).toBe(null);
|
||||
expect(validateResourceName("cpu-usage", "Sense")).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects invalid ids", () => {
|
||||
expect(validateResourceName("", "Sense")).not.toBe(null);
|
||||
expect(validateResourceName("My-Sense", "Sense")).not.toBe(null);
|
||||
expect(validateResourceName("-bad", "Sense")).not.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSenseIndexTs", () => {
|
||||
it("generates a stateful sense stub with TypeScript types", () => {
|
||||
const ts = buildSenseIndexTs("my-sense");
|
||||
expect(ts).toContain("type SenseState");
|
||||
expect(ts).toContain("export const initialState");
|
||||
expect(ts).toContain("export async function compute");
|
||||
expect(ts).toContain("workflow: null");
|
||||
expect(ts).toContain("lastRun");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Tests for nerve create workflow scaffold logic.
|
||||
*
|
||||
* We test the file-generation path by isolating the template rendering,
|
||||
* not by invoking the full citty command (which calls process.exit).
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildWorkflowScaffold } from "../commands/create.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "nerve-cli-init-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("buildWorkflowScaffold", () => {
|
||||
it("includes the workflow name in the main role content", () => {
|
||||
const { roleMainIndexTs } = buildWorkflowScaffold("my-workflow");
|
||||
expect(roleMainIndexTs).toContain("my-workflow started");
|
||||
});
|
||||
|
||||
it("root index contains WorkflowDefinition import from nerve-core", () => {
|
||||
const { indexTs } = buildWorkflowScaffold("test");
|
||||
expect(indexTs).toContain("WorkflowDefinition");
|
||||
expect(indexTs).toContain("@uncaged/nerve-core");
|
||||
});
|
||||
|
||||
it("root index wires moderator with ThreadContext and END", () => {
|
||||
const { indexTs } = buildWorkflowScaffold("test");
|
||||
expect(indexTs).toContain("moderator");
|
||||
expect(indexTs).toContain("ThreadContext");
|
||||
expect(indexTs).toContain("ctx.steps.length");
|
||||
expect(indexTs).toContain("END");
|
||||
});
|
||||
|
||||
it("root index imports main role and sets name field", () => {
|
||||
const { indexTs } = buildWorkflowScaffold("test");
|
||||
expect(indexTs).toContain('name: "test"');
|
||||
expect(indexTs).toContain("main: mainRole");
|
||||
expect(indexTs).toContain("./roles/main/index.js");
|
||||
});
|
||||
|
||||
it("main role module exports mainRole with ThreadContext", () => {
|
||||
const { roleMainIndexTs } = buildWorkflowScaffold("test");
|
||||
expect(roleMainIndexTs).toContain("export async function mainRole");
|
||||
expect(roleMainIndexTs).toContain("ThreadContext");
|
||||
expect(roleMainIndexTs).toContain("RoleResult");
|
||||
expect(roleMainIndexTs).not.toContain("StartStep");
|
||||
expect(roleMainIndexTs).not.toContain("WorkflowMessage");
|
||||
});
|
||||
|
||||
it("uses different names per call", () => {
|
||||
const a = buildWorkflowScaffold("workflow-a");
|
||||
const b = buildWorkflowScaffold("workflow-b");
|
||||
expect(a.roleMainIndexTs).toContain("workflow-a started");
|
||||
expect(b.roleMainIndexTs).toContain("workflow-b started");
|
||||
expect(a.roleMainIndexTs).not.toContain("workflow-b");
|
||||
});
|
||||
|
||||
it("produces valid TypeScript syntax for index (no unclosed braces)", () => {
|
||||
const { indexTs } = buildWorkflowScaffold("test");
|
||||
const opens = (indexTs.match(/\{/g) ?? []).length;
|
||||
const closes = (indexTs.match(/\}/g) ?? []).length;
|
||||
expect(opens).toBe(closes);
|
||||
});
|
||||
|
||||
it("ends root index with export default workflow", () => {
|
||||
const { indexTs } = buildWorkflowScaffold("test");
|
||||
expect(indexTs.trim().endsWith("export default workflow;")).toBe(true);
|
||||
});
|
||||
|
||||
it("prompt markdown names the workflow", () => {
|
||||
const { roleMainPromptMd } = buildWorkflowScaffold("my-flow");
|
||||
expect(roleMainPromptMd).toContain("# my-flow — main role");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow scaffold file writing (simulated)", () => {
|
||||
it("writes all scaffold files to disk correctly", () => {
|
||||
const workflowDir = join(tmpDir, "workflows", "my-task");
|
||||
mkdirSync(join(workflowDir, "roles", "main"), { recursive: true });
|
||||
const scaffold = buildWorkflowScaffold("my-task");
|
||||
writeFileSync(join(workflowDir, "index.ts"), scaffold.indexTs, "utf8");
|
||||
writeFileSync(join(workflowDir, "roles", "main", "index.ts"), scaffold.roleMainIndexTs, "utf8");
|
||||
writeFileSync(
|
||||
join(workflowDir, "roles", "main", "prompt.md"),
|
||||
scaffold.roleMainPromptMd,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(readFileSync(join(workflowDir, "index.ts"), "utf8")).toContain('name: "my-task"');
|
||||
expect(readFileSync(join(workflowDir, "roles", "main", "index.ts"), "utf8")).toContain(
|
||||
"my-task started",
|
||||
);
|
||||
expect(readFileSync(join(workflowDir, "roles", "main", "prompt.md"), "utf8")).toContain(
|
||||
"# my-task — main role",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { daemonCommand } from "../commands/daemon.js";
|
||||
import { devCommand } from "../commands/dev.js";
|
||||
import { daemonStartCommand } from "../commands/start.js";
|
||||
|
||||
describe("nerve daemon command group", () => {
|
||||
it("exposes start, stop, status, restart, and logs subcommands", () => {
|
||||
const subs = daemonCommand.subCommands;
|
||||
expect(subs).toBeDefined();
|
||||
if (!subs) {
|
||||
throw new Error("expected daemonCommand.subCommands");
|
||||
}
|
||||
expect(Object.keys(subs).sort()).toEqual(["logs", "restart", "start", "status", "stop"]);
|
||||
});
|
||||
|
||||
it("shares the same start command object as top-level nerve start alias", () => {
|
||||
const subs = daemonCommand.subCommands;
|
||||
expect(subs?.start).toBe(daemonStartCommand);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nerve dev", () => {
|
||||
it("is a foreground dev command", () => {
|
||||
expect(devCommand.meta?.name).toBe("dev");
|
||||
expect(devCommand.meta?.description).toMatch(/foreground/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* E2E-style tests for `nerve create workflow` and `nerve create sense`.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { defineCommand, runCommand } from "citty";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createCommand } from "../commands/create.js";
|
||||
import { initCommand } from "../commands/init.js";
|
||||
|
||||
const testRootCommand = defineCommand({
|
||||
meta: { name: "nerve", description: "e2e-create" },
|
||||
subCommands: {
|
||||
init: initCommand,
|
||||
create: createCommand,
|
||||
},
|
||||
});
|
||||
|
||||
type CliRunResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
class ProcessExitError extends Error {
|
||||
readonly code: number;
|
||||
constructor(code: number) {
|
||||
super(`process.exit(${String(code)})`);
|
||||
this.name = "ProcessExitError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void {
|
||||
const orig = stream.write.bind(stream) as (
|
||||
chunk: string | Uint8Array,
|
||||
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
|
||||
cb?: (err: Error | null | undefined) => void,
|
||||
) => boolean;
|
||||
|
||||
stream.write = ((
|
||||
chunk: string | Uint8Array,
|
||||
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
|
||||
cb?: (err: Error | null | undefined) => void,
|
||||
) => {
|
||||
if (typeof chunk === "string") {
|
||||
sink.push(chunk);
|
||||
} else {
|
||||
sink.push(Buffer.from(chunk).toString("utf8"));
|
||||
}
|
||||
if (typeof encodingOrCb === "function") {
|
||||
encodingOrCb(null);
|
||||
return true;
|
||||
}
|
||||
if (cb !== undefined) {
|
||||
cb(null);
|
||||
}
|
||||
return true;
|
||||
}) as typeof stream.write;
|
||||
|
||||
return () => {
|
||||
stream.write = orig as typeof stream.write;
|
||||
};
|
||||
}
|
||||
|
||||
async function runTestCli(fakeHome: string, args: string[]): Promise<CliRunResult> {
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
const restoreOut = patchWriteStream(process.stdout, stdoutChunks);
|
||||
const restoreErr = patchWriteStream(process.stderr, stderrChunks);
|
||||
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = fakeHome;
|
||||
|
||||
let exitCode = 0;
|
||||
const origExit = process.exit;
|
||||
process.exit = ((code?: number) => {
|
||||
exitCode = typeof code === "number" ? code : 0;
|
||||
throw new ProcessExitError(exitCode);
|
||||
}) as typeof process.exit;
|
||||
|
||||
try {
|
||||
await runCommand(testRootCommand, { rawArgs: args });
|
||||
} catch (e) {
|
||||
if (e instanceof ProcessExitError) {
|
||||
exitCode = e.code;
|
||||
} else {
|
||||
exitCode = 1;
|
||||
stderrChunks.push(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
if (prevHome === undefined) {
|
||||
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
restoreOut();
|
||||
restoreErr();
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: stdoutChunks.join(""),
|
||||
stderr: stderrChunks.join(""),
|
||||
exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
describe("e2e create", () => {
|
||||
let fakeHome: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (fakeHome !== null) {
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
fakeHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
"create workflow scaffolds sources and root build emits dist/workflows/<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"]);
|
||||
|
||||
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
|
||||
expect(wf.exitCode).toBe(0);
|
||||
expect(wf.stdout).toContain("✅");
|
||||
|
||||
const wfDir = join(nerveRoot, "workflows", "e2e-flow");
|
||||
const indexPath = join(wfDir, "index.ts");
|
||||
const mainRolePath = join(wfDir, "roles", "main", "index.ts");
|
||||
expect(existsSync(join(wfDir, "package.json"))).toBe(false);
|
||||
expect(existsSync(indexPath)).toBe(true);
|
||||
expect(existsSync(mainRolePath)).toBe(true);
|
||||
expect(readFileSync(indexPath, "utf8")).toContain('name: "e2e-flow"');
|
||||
expect(readFileSync(mainRolePath, "utf8")).toContain("e2e-flow started");
|
||||
expect(existsSync(join(nerveRoot, "dist", "workflows", "e2e-flow", "index.js"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"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"]);
|
||||
|
||||
const sense = await runTestCli(fakeHome, ["create", "sense", "e2e-sense"]);
|
||||
expect(sense.exitCode).toBe(0);
|
||||
expect(sense.stdout).toContain("✅");
|
||||
|
||||
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(nerveRoot, "dist", "senses", "e2e-sense", "index.js"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"create workflow exits 1 when directory exists without --force",
|
||||
{ timeout: 10_000 },
|
||||
async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
mkdirSync(join(nerveRoot, "workflows", "dup-wf"), { recursive: true });
|
||||
writeFileSync(join(nerveRoot, "workflows", "dup-wf", "index.ts"), "// x", "utf8");
|
||||
|
||||
const first = await runTestCli(fakeHome, ["create", "workflow", "dup-wf"]);
|
||||
expect(first.exitCode).toBe(1);
|
||||
expect(first.stderr).toContain("already exists");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* E2E: daemon start / status / stop lifecycle against the in-process test harness.
|
||||
*
|
||||
* Does not invoke the `stop` CLI while the harness PID file points at the current process
|
||||
* (that would SIGTERM the test runner). After `status` shows running, we stop the kernel
|
||||
* and remove `nerve.pid`, then assert `status` reports not running; `afterEach` tears down
|
||||
* the temp HOME.
|
||||
*/
|
||||
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { type TestDaemonHandle, runCli, startTestDaemon, stopTestDaemon } from "./e2e-harness.js";
|
||||
|
||||
describe("e2e daemon lifecycle", () => {
|
||||
let daemon: TestDaemonHandle | null = null;
|
||||
let kernelAlreadyStopped = false;
|
||||
|
||||
afterEach(async () => {
|
||||
const h = daemon;
|
||||
const skipKernel = kernelAlreadyStopped;
|
||||
daemon = null;
|
||||
kernelAlreadyStopped = false;
|
||||
if (h === null) return;
|
||||
await Promise.race([
|
||||
stopTestDaemon(h, skipKernel),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it(
|
||||
"nerve.pid + kernel up, status running, then stopped + pid cleared, status not running",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
const pidPath = join(daemon.nerveRoot, "nerve.pid");
|
||||
expect(existsSync(pidPath)).toBe(true);
|
||||
|
||||
const health = daemon.kernel.getHealth();
|
||||
expect(health.activeGroups).toBeGreaterThan(0);
|
||||
expect(daemon.kernel.getWorkerPid("e2e")).not.toBeNull();
|
||||
expect(existsSync(daemon.socketPath)).toBe(true);
|
||||
|
||||
const statusUp = await runCli(daemon, ["daemon", "status"]);
|
||||
expect(statusUp.exitCode).toBe(0);
|
||||
expect(statusUp.stdout).toContain("running");
|
||||
expect(statusUp.stdout).toContain("✅ Nerve daemon is running.");
|
||||
|
||||
const statusTopLevel = await runCli(daemon, ["status"]);
|
||||
expect(statusTopLevel.exitCode).toBe(0);
|
||||
expect(statusTopLevel.stdout).toContain("running");
|
||||
|
||||
await daemon.kernel.stop();
|
||||
kernelAlreadyStopped = true;
|
||||
unlinkSync(pidPath);
|
||||
|
||||
expect(existsSync(pidPath)).toBe(false);
|
||||
expect(existsSync(daemon.socketPath)).toBe(false);
|
||||
|
||||
const statusDown = await runCli(daemon, ["daemon", "status"]);
|
||||
expect(statusDown.exitCode).toBe(0);
|
||||
expect(statusDown.stdout).toContain("not running");
|
||||
expect(statusDown.stdout).toContain("😴 Nerve daemon is not running.");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Shared E2E harness: temp HOME layout (`.uncaged-nerve`), real kernel + sense worker,
|
||||
* IPC socket for CLI, and `runCommand` helpers with captured stdio.
|
||||
*
|
||||
* Stateful senses persist JSON under `data/senses/<name>.json` (see sense-worker).
|
||||
*
|
||||
* ## Timeout guard (vitest)
|
||||
*
|
||||
* Always tear down the daemon in `afterEach` so a failed assertion does not leave a
|
||||
* kernel and worker children running. Optionally race `stopTestDaemon` against a timer
|
||||
* so CI does not hang if shutdown stalls:
|
||||
*
|
||||
* ```ts
|
||||
* import { afterEach } from "vitest";
|
||||
* import { stopTestDaemon, type TestDaemonHandle } from "./e2e-harness.js";
|
||||
*
|
||||
* 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),
|
||||
* ),
|
||||
* ]);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import { createKernel } from "@uncaged/nerve-daemon";
|
||||
import type { Kernel } from "@uncaged/nerve-daemon";
|
||||
import { defineCommand, runCommand } from "citty";
|
||||
|
||||
import { daemonCommand } from "../commands/daemon.js";
|
||||
import { logsCommand } from "../commands/logs.js";
|
||||
import { senseCommand } from "../commands/sense.js";
|
||||
import { statusCommand } from "../commands/status.js";
|
||||
import { stopCommand } from "../commands/stop.js";
|
||||
import { storeCommand } from "../commands/store.js";
|
||||
import { threadCommand } from "../commands/thread.js";
|
||||
import { workflowCommand } from "../commands/workflow.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
|
||||
const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js");
|
||||
const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js");
|
||||
|
||||
const nerveYamlTemplate = `senses:
|
||||
counter:
|
||||
group: e2e
|
||||
|
||||
workflows:
|
||||
echo:
|
||||
concurrency: 1
|
||||
overflow: queue
|
||||
max_queue: 10
|
||||
|
||||
max_rounds: 10
|
||||
|
||||
api:
|
||||
port: null
|
||||
token: null
|
||||
host: 127.0.0.1
|
||||
`;
|
||||
|
||||
/**
|
||||
* Minimal echo workflow (one role round then END).
|
||||
* Short delay in the role so two sequential CLI triggers can observe a queued run while the first is active.
|
||||
*/
|
||||
const echoWorkflowIndexJs = `const END = "__end__";
|
||||
|
||||
export default {
|
||||
name: "echo",
|
||||
roles: {
|
||||
echo: async (ctx) => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
const p = typeof ctx.start.content === "string" ? ctx.start.content : "";
|
||||
return {
|
||||
content: p.length > 0 ? "echo:" + p : "echo:empty",
|
||||
meta: {},
|
||||
};
|
||||
},
|
||||
},
|
||||
moderator({ steps }) {
|
||||
if (steps.length === 0) return "echo";
|
||||
return END;
|
||||
},
|
||||
};
|
||||
`;
|
||||
|
||||
const nerveYamlWithNoopWorkflow = `senses:
|
||||
counter:
|
||||
group: e2e
|
||||
|
||||
workflows:
|
||||
noop:
|
||||
concurrency: 1
|
||||
overflow: drop
|
||||
|
||||
max_rounds: 10
|
||||
|
||||
api:
|
||||
port: null
|
||||
token: null
|
||||
host: 127.0.0.1
|
||||
`;
|
||||
|
||||
/** Minimal counter sense — each compute increments \`count\` in persisted state. */
|
||||
const counterIndexJs = `export const initialState = { count: 0 };
|
||||
|
||||
export async function compute(state) {
|
||||
return {
|
||||
state: { count: state.count + 1 },
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
/** First trigger launches local noop workflow; later triggers only advance idleTicks. */
|
||||
const counterIndexJsWithNoopWorkflow = `export const initialState = { launched: false, idleTicks: 0 };
|
||||
|
||||
export async function compute(state) {
|
||||
if (!state.launched) {
|
||||
return {
|
||||
state: { launched: true, idleTicks: state.idleTicks },
|
||||
workflow: {
|
||||
name: "noop",
|
||||
maxRounds: 3,
|
||||
prompt: "e2e-archive",
|
||||
dryRun: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: { launched: state.launched, idleTicks: state.idleTicks + 1 },
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
/** Minimal workflow: moderator ends immediately (no role rounds). */
|
||||
const noopWorkflowIndexJs = `const END = "__end__";
|
||||
export default {
|
||||
name: "noop",
|
||||
roles: {
|
||||
bot: async () => ({ content: "ok", meta: {} }),
|
||||
},
|
||||
moderator: () => END,
|
||||
};
|
||||
`;
|
||||
|
||||
const e2eRootCommand = defineCommand({
|
||||
meta: { name: "nerve", description: "e2e" },
|
||||
subCommands: {
|
||||
sense: senseCommand,
|
||||
logs: logsCommand,
|
||||
daemon: daemonCommand,
|
||||
status: statusCommand,
|
||||
stop: stopCommand,
|
||||
workflow: workflowCommand,
|
||||
store: storeCommand,
|
||||
thread: threadCommand,
|
||||
},
|
||||
});
|
||||
|
||||
function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig {
|
||||
return {
|
||||
senses: {
|
||||
counter: {
|
||||
group: "e2e",
|
||||
throttle: null,
|
||||
timeout: null,
|
||||
gracePeriod: null,
|
||||
interval: null,
|
||||
on: [],
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
echo: { concurrency: 1, overflow: "queue" as const, maxQueue: 10 },
|
||||
...(withNoopWorkflow ? { noop: { concurrency: 1, overflow: "drop" as const } } : {}),
|
||||
},
|
||||
maxRounds: 10,
|
||||
extract: null,
|
||||
api: { port: null, token: null, host: "127.0.0.1" },
|
||||
};
|
||||
}
|
||||
|
||||
function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): void {
|
||||
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "data", "blobs"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "dist", "senses", "counter"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "dist", "workflows", "echo"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "nerve.yaml"),
|
||||
withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "dist", "senses", "counter", "index.js"),
|
||||
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "dist", "workflows", "echo", "index.js"),
|
||||
echoWorkflowIndexJs,
|
||||
"utf8",
|
||||
);
|
||||
if (withNoopWorkflow) {
|
||||
mkdirSync(join(nerveRoot, "dist", "workflows", "noop"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "dist", "workflows", "noop", "index.js"),
|
||||
noopWorkflowIndexJs,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
linkWorkspaceDaemonIntoNerveRoot(nerveRoot);
|
||||
}
|
||||
|
||||
export type TestDaemonHandle = {
|
||||
fakeHome: string;
|
||||
nerveRoot: string;
|
||||
socketPath: string;
|
||||
kernel: Kernel;
|
||||
};
|
||||
|
||||
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`.
|
||||
*/
|
||||
withNoopWorkflow: boolean;
|
||||
} | null;
|
||||
|
||||
function useNoopWorkflow(opts: StartTestDaemonOpts): boolean {
|
||||
return opts !== null && opts.withNoopWorkflow === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlink workspace `@uncaged/nerve-daemon` into `<nerveRoot>/node_modules` so
|
||||
* `loadDaemonModule(nerveRoot)` resolves for `nerve store` / `nerve thread` in e2e.
|
||||
*/
|
||||
export function linkWorkspaceDaemonIntoNerveRoot(nerveRoot: string): void {
|
||||
const daemonPkgRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.json"));
|
||||
const nm = join(nerveRoot, "node_modules");
|
||||
mkdirSync(nm, { recursive: true });
|
||||
|
||||
const linkDir = join(nm, "@uncaged");
|
||||
mkdirSync(linkDir, { recursive: true });
|
||||
const linkPath = join(linkDir, "nerve-daemon");
|
||||
if (!existsSync(linkPath)) symlinkSync(daemonPkgRoot, linkPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until predicate returns true, or reject after `timeoutMs`.
|
||||
* (Same idea as `packages/daemon/src/__tests__/kernel-integration.test.ts`.)
|
||||
*/
|
||||
export async function pollUntil(
|
||||
predicate: () => boolean,
|
||||
timeoutMs: number,
|
||||
intervalMs = 50,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`pollUntil timed out after ${String(timeoutMs)}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
const check = setInterval(() => {
|
||||
if (predicate()) {
|
||||
clearTimeout(timer);
|
||||
clearInterval(check);
|
||||
resolve();
|
||||
}
|
||||
}, intervalMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates `fakeHome`, lays out `fakeHome/.uncaged-nerve` (nerve.yaml + counter sense),
|
||||
* starts a real kernel (sense-worker child + IPC on `nerve.sock`), writes `nerve.pid`
|
||||
* to the current test process so `isRunning()` succeeds under that HOME, and awaits
|
||||
* `kernel.ready`.
|
||||
*/
|
||||
export async function startTestDaemon(
|
||||
_opts: StartTestDaemonOpts = null,
|
||||
): Promise<TestDaemonHandle> {
|
||||
const withNoop = useNoopWorkflow(_opts);
|
||||
if (!existsSync(senseWorkerScript)) {
|
||||
throw new Error(
|
||||
`Missing "${senseWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\` (cli package "pretest" runs this automatically).`,
|
||||
);
|
||||
}
|
||||
if (!existsSync(workflowWorkerScript)) {
|
||||
throw new Error(
|
||||
`Missing "${workflowWorkerScript}". Run \`pnpm --filter @uncaged/nerve-daemon build\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fakeHome = mkdtempSync(join(tmpdir(), "nerve-cli-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
writeWorkspaceLayout(nerveRoot, withNoop);
|
||||
|
||||
const config = defaultTestConfig(withNoop);
|
||||
const socketPath = join(nerveRoot, "nerve.sock");
|
||||
const kernel = createKernel(config, nerveRoot, {
|
||||
workerScript: senseWorkerScript,
|
||||
ipcSocketPath: socketPath,
|
||||
enableFileWatcher: false,
|
||||
});
|
||||
|
||||
await kernel.ready;
|
||||
writeFileSync(join(nerveRoot, "nerve.pid"), String(process.pid), "utf8");
|
||||
|
||||
return { fakeHome, nerveRoot, socketPath, kernel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the kernel (workers + IPC) and removes the temp HOME tree.
|
||||
*
|
||||
* @param kernelAlreadyStopped — pass `true` when the test already called `kernel.stop()`
|
||||
* (e.g. daemon lifecycle e2e); only the temp directory is removed.
|
||||
*/
|
||||
export async function stopTestDaemon(
|
||||
handle: TestDaemonHandle,
|
||||
kernelAlreadyStopped = false,
|
||||
): Promise<void> {
|
||||
if (!kernelAlreadyStopped) {
|
||||
await handle.kernel.stop();
|
||||
}
|
||||
try {
|
||||
rmSync(handle.fakeHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export type CliRunResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void {
|
||||
const orig = stream.write.bind(stream) as (
|
||||
chunk: string | Uint8Array,
|
||||
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
|
||||
cb?: (err: Error | null | undefined) => void,
|
||||
) => boolean;
|
||||
|
||||
stream.write = ((
|
||||
chunk: string | Uint8Array,
|
||||
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
|
||||
cb?: (err: Error | null | undefined) => void,
|
||||
) => {
|
||||
if (typeof chunk === "string") {
|
||||
sink.push(chunk);
|
||||
} else {
|
||||
sink.push(Buffer.from(chunk).toString("utf8"));
|
||||
}
|
||||
if (typeof encodingOrCb === "function") {
|
||||
encodingOrCb(null);
|
||||
return true;
|
||||
}
|
||||
if (cb !== undefined) {
|
||||
cb(null);
|
||||
}
|
||||
return true;
|
||||
}) as typeof stream.write;
|
||||
|
||||
return () => {
|
||||
stream.write = orig as typeof stream.write;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `nerve <args>` for the subset wired in `e2eRootCommand` (`sense`, `logs`, `daemon`,
|
||||
* `status`, `stop`, `store`, `workflow`, `thread`), with `process.env.HOME` pointing at `handle.fakeHome`
|
||||
* so `getNerveRoot()` resolves to the test workspace. Captures stdout/stderr; sets `exitCode`
|
||||
* when `process.exit` is invoked or on thrown errors.
|
||||
*/
|
||||
export async function runCli(handle: TestDaemonHandle, args: string[]): Promise<CliRunResult> {
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
const restoreOut = patchWriteStream(process.stdout, stdoutChunks);
|
||||
const restoreErr = patchWriteStream(process.stderr, stderrChunks);
|
||||
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = handle.fakeHome;
|
||||
|
||||
let exitCode = 0;
|
||||
const origExit = process.exit;
|
||||
process.exit = ((code?: number) => {
|
||||
exitCode = typeof code === "number" ? code : 0;
|
||||
throw new ProcessExitError(exitCode);
|
||||
}) as typeof process.exit;
|
||||
|
||||
try {
|
||||
await runCommand(e2eRootCommand, { rawArgs: args });
|
||||
} catch (e) {
|
||||
if (e instanceof ProcessExitError) {
|
||||
exitCode = e.code;
|
||||
} else {
|
||||
exitCode = 1;
|
||||
stderrChunks.push(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
if (prevHome === undefined) {
|
||||
process.env.HOME = undefined;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
restoreOut();
|
||||
restoreErr();
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: stdoutChunks.join(""),
|
||||
stderr: stderrChunks.join(""),
|
||||
exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
class ProcessExitError extends Error {
|
||||
readonly code: number;
|
||||
constructor(code: number) {
|
||||
super(`process.exit(${String(code)})`);
|
||||
this.name = "ProcessExitError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* E2E test for `nerve logs` command (#161).
|
||||
*
|
||||
* The logs command reads from a plain text log file at
|
||||
* `<nerveRoot>/logs/nerve.log`. Since the e2e harness starts the kernel
|
||||
* in-process (not as a detached daemon), no log file is created automatically.
|
||||
* We manually write test log content to the expected path.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { type TestDaemonHandle, runCli, startTestDaemon, stopTestDaemon } from "./e2e-harness.js";
|
||||
|
||||
/** Generate N log lines for testing. */
|
||||
function generateLogLines(count: number): string[] {
|
||||
const lines: string[] = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const ts = new Date(Date.UTC(2025, 0, 1, 0, 0, i)).toISOString();
|
||||
lines.push(`${ts} [INFO] log entry ${i}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Write fake log content to the daemon log path. */
|
||||
function writeTestLogFile(nerveRoot: string, lines: string[]): void {
|
||||
const logsDir = join(nerveRoot, "logs");
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
writeFileSync(join(logsDir, "nerve.log"), `${lines.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
describe("e2e logs", () => {
|
||||
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),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows log file not found when no log exists", { timeout: 30_000 }, async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
// No log file written — command should fail
|
||||
const result = await runCli(daemon, ["logs"]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("Log file not found");
|
||||
});
|
||||
|
||||
it("displays last N lines (tail mode)", { timeout: 30_000 }, async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
const lines = generateLogLines(10);
|
||||
writeTestLogFile(daemon.nerveRoot, lines);
|
||||
|
||||
// Default: last 50 lines, but we only have 10
|
||||
const result = await runCli(daemon, ["logs"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("log entry 1");
|
||||
expect(result.stdout).toContain("log entry 10");
|
||||
expect(result.stdout).toContain("lines 1-10 of 10");
|
||||
});
|
||||
|
||||
it("respects -n flag to limit lines", { timeout: 30_000 }, async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
const lines = generateLogLines(20);
|
||||
writeTestLogFile(daemon.nerveRoot, lines);
|
||||
|
||||
// Request only last 5 lines
|
||||
const result = await runCli(daemon, ["logs", "-n", "5"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
// Should show lines 16-20 (last 5)
|
||||
expect(result.stdout).toContain("log entry 16");
|
||||
expect(result.stdout).toContain("log entry 20");
|
||||
expect(result.stdout).toContain("lines 16-20 of 20");
|
||||
|
||||
// Should NOT contain earlier lines
|
||||
expect(result.stdout).not.toContain("log entry 1\n");
|
||||
expect(result.stdout).not.toContain("log entry 15\n");
|
||||
});
|
||||
|
||||
it("supports --offset for pagination", { timeout: 30_000 }, async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
const lines = generateLogLines(20);
|
||||
writeTestLogFile(daemon.nerveRoot, lines);
|
||||
|
||||
// Offset 5 means start at line 5, show default 50 (will get 5-20)
|
||||
const result = await runCli(daemon, ["logs", "--offset", "5", "-n", "5"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
// Should show lines 5-9
|
||||
expect(result.stdout).toContain("log entry 5");
|
||||
expect(result.stdout).toContain("log entry 9");
|
||||
expect(result.stdout).toContain("lines 5-9 of 20");
|
||||
});
|
||||
|
||||
it("shows pagination hint when earlier lines exist", { timeout: 30_000 }, async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
const lines = generateLogLines(100);
|
||||
writeTestLogFile(daemon.nerveRoot, lines);
|
||||
|
||||
// Request last 10 lines — there should be a "previous page" hint
|
||||
const result = await runCli(daemon, ["logs", "-n", "10"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Earlier lines available");
|
||||
expect(result.stdout).toContain("nerve logs --offset");
|
||||
});
|
||||
|
||||
it("shows empty message for empty log file", { timeout: 30_000 }, async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
// Write an empty log file
|
||||
const logsDir = join(daemon.nerveRoot, "logs");
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
writeFileSync(join(logsDir, "nerve.log"), "", "utf8");
|
||||
|
||||
const result = await runCli(daemon, ["logs"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("Log file is empty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Smoke test: start a real daemon with a counter sense, trigger it,
|
||||
* 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 {
|
||||
type TestDaemonHandle,
|
||||
pollUntil,
|
||||
runCli,
|
||||
startTestDaemon,
|
||||
stopTestDaemon,
|
||||
} from "./e2e-harness.js";
|
||||
|
||||
describe("e2e smoke", () => {
|
||||
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),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it("sense list after trigger and persisted counter state", { timeout: 30_000 }, async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
expect(triggerResult.stdout).toContain("Triggered");
|
||||
|
||||
const statePath = join(daemon.nerveRoot, "data", "senses", "counter.json");
|
||||
await pollUntil(() => {
|
||||
try {
|
||||
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);
|
||||
|
||||
const listResult = await runCli(daemon, ["sense", "list"]);
|
||||
expect(listResult.exitCode).toBe(0);
|
||||
expect(listResult.stdout).toContain("counter");
|
||||
expect(listResult.stdout).not.toContain("last signal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* E2E: `nerve store archive` against a real daemon + logs.db (issue #163).
|
||||
*
|
||||
* Archive eligibility is by `logs.timestamp` (ms; there is no `created_at` column);
|
||||
* RFC-001 §5.4 cold-archive uses UTC days.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type TestDaemonHandle,
|
||||
linkWorkspaceDaemonIntoNerveRoot,
|
||||
pollUntil,
|
||||
runCli,
|
||||
startTestDaemon,
|
||||
stopTestDaemon,
|
||||
} from "./e2e-harness.js";
|
||||
|
||||
/** Wall time safely outside the 30-day hot window (RFC-001 archive). */
|
||||
const ARCHIVED_TEST_TS = Date.UTC(2020, 5, 15, 12, 0, 0);
|
||||
const EXPECTED_ARCHIVE_DAY = "2020-06-15";
|
||||
|
||||
describe("e2e store archive", () => {
|
||||
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),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it(
|
||||
"archives old workflow logs to JSONL, removes rows from logs, thread list still reads workflow_runs",
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
daemon = await startTestDaemon({ withNoopWorkflow: true });
|
||||
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
|
||||
|
||||
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
|
||||
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
|
||||
await pollUntil(() => {
|
||||
if (!existsSync(logsDb)) return false;
|
||||
try {
|
||||
const db = new DatabaseSync(logsDb, { readOnly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS c FROM logs WHERE source = 'workflow' AND type = 'completed'",
|
||||
)
|
||||
.get() as { c: number } | undefined;
|
||||
db.close();
|
||||
return (row?.c ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
const dbMut = new DatabaseSync(logsDb);
|
||||
dbMut.exec(`UPDATE logs SET timestamp = ${String(ARCHIVED_TEST_TS)} WHERE 1=1`);
|
||||
dbMut.close();
|
||||
|
||||
const archiveResult = await runCli(daemon, ["store", "archive"]);
|
||||
expect(archiveResult.exitCode).toBe(0);
|
||||
expect(archiveResult.stdout).toContain("✅ Archived");
|
||||
expect(archiveResult.stdout).toContain("rows=");
|
||||
expect(archiveResult.stdout).toContain(EXPECTED_ARCHIVE_DAY);
|
||||
|
||||
const dbAfter = new DatabaseSync(logsDb, { readOnly: true });
|
||||
const logCountRow = dbAfter.prepare("SELECT COUNT(*) AS c FROM logs").get() as { c: number };
|
||||
dbAfter.close();
|
||||
expect(logCountRow.c).toBe(0);
|
||||
|
||||
const archiveDir = join(daemon.nerveRoot, "data", "archive", "logs");
|
||||
expect(existsSync(archiveDir)).toBe(true);
|
||||
const names = readdirSync(archiveDir);
|
||||
expect(names.some((n) => n === `${EXPECTED_ARCHIVE_DAY}.jsonl`)).toBe(true);
|
||||
const jsonlPath = join(archiveDir, `${EXPECTED_ARCHIVE_DAY}.jsonl`);
|
||||
const jsonl = readFileSync(jsonlPath, "utf8");
|
||||
expect(jsonl.length).toBeGreaterThan(0);
|
||||
expect(jsonl).toContain('"source":"workflow"');
|
||||
|
||||
const listResult = await runCli(daemon, ["thread", "list", "--all"]);
|
||||
expect(listResult.exitCode).toBe(0);
|
||||
// workflow_runs is not pruned by archive — list may still show completed runs; hot logs are empty.
|
||||
expect(listResult.stdout).toContain("noop");
|
||||
},
|
||||
);
|
||||
|
||||
it("store archive --vacuum completes VACUUM after archiving", { timeout: 60_000 }, async () => {
|
||||
daemon = await startTestDaemon({ withNoopWorkflow: true });
|
||||
linkWorkspaceDaemonIntoNerveRoot(daemon.nerveRoot);
|
||||
|
||||
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
|
||||
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
|
||||
await pollUntil(() => {
|
||||
if (!existsSync(logsDb)) return false;
|
||||
try {
|
||||
const db = new DatabaseSync(logsDb, { readOnly: true });
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS c FROM logs WHERE source = 'workflow' AND type = 'completed'",
|
||||
)
|
||||
.get() as { c: number } | undefined;
|
||||
db.close();
|
||||
return (row?.c ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
const dbMut = new DatabaseSync(logsDb);
|
||||
dbMut.exec(`UPDATE logs SET timestamp = ${String(ARCHIVED_TEST_TS)} WHERE 1=1`);
|
||||
dbMut.close();
|
||||
|
||||
const archiveVac = await runCli(daemon, ["store", "archive", "--vacuum"]);
|
||||
expect(archiveVac.exitCode).toBe(0);
|
||||
expect(archiveVac.stdout).toContain("VACUUM completed.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* E2E tests for `nerve validate` and `nerve init` commands (#162).
|
||||
* No running daemon needed — just temp dirs with HOME manipulation.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { defineCommand, runCommand } from "citty";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { initCommand } from "../commands/init.js";
|
||||
import { validateCommand } from "../commands/validate.js";
|
||||
|
||||
const testRootCommand = defineCommand({
|
||||
meta: { name: "nerve", description: "e2e-validate-init" },
|
||||
subCommands: {
|
||||
validate: validateCommand,
|
||||
init: initCommand,
|
||||
},
|
||||
});
|
||||
|
||||
type CliRunResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
class ProcessExitError extends Error {
|
||||
readonly code: number;
|
||||
constructor(code: number) {
|
||||
super(`process.exit(${String(code)})`);
|
||||
this.name = "ProcessExitError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function patchWriteStream(stream: NodeJS.WriteStream, sink: string[]): () => void {
|
||||
const orig = stream.write.bind(stream) as (
|
||||
chunk: string | Uint8Array,
|
||||
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
|
||||
cb?: (err: Error | null | undefined) => void,
|
||||
) => boolean;
|
||||
|
||||
stream.write = ((
|
||||
chunk: string | Uint8Array,
|
||||
encodingOrCb?: BufferEncoding | ((err: Error | null | undefined) => void),
|
||||
cb?: (err: Error | null | undefined) => void,
|
||||
) => {
|
||||
if (typeof chunk === "string") {
|
||||
sink.push(chunk);
|
||||
} else {
|
||||
sink.push(Buffer.from(chunk).toString("utf8"));
|
||||
}
|
||||
if (typeof encodingOrCb === "function") {
|
||||
encodingOrCb(null);
|
||||
return true;
|
||||
}
|
||||
if (cb !== undefined) {
|
||||
cb(null);
|
||||
}
|
||||
return true;
|
||||
}) as typeof stream.write;
|
||||
|
||||
return () => {
|
||||
stream.write = orig as typeof stream.write;
|
||||
};
|
||||
}
|
||||
|
||||
async function runTestCli(fakeHome: string, args: string[]): Promise<CliRunResult> {
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
const restoreOut = patchWriteStream(process.stdout, stdoutChunks);
|
||||
const restoreErr = patchWriteStream(process.stderr, stderrChunks);
|
||||
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = fakeHome;
|
||||
|
||||
let exitCode = 0;
|
||||
const origExit = process.exit;
|
||||
process.exit = ((code?: number) => {
|
||||
exitCode = typeof code === "number" ? code : 0;
|
||||
throw new ProcessExitError(exitCode);
|
||||
}) as typeof process.exit;
|
||||
|
||||
try {
|
||||
await runCommand(testRootCommand, { rawArgs: args });
|
||||
} catch (e) {
|
||||
if (e instanceof ProcessExitError) {
|
||||
exitCode = e.code;
|
||||
} else {
|
||||
exitCode = 1;
|
||||
stderrChunks.push(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
if (prevHome === undefined) {
|
||||
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
restoreOut();
|
||||
restoreErr();
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: stdoutChunks.join(""),
|
||||
stderr: stderrChunks.join(""),
|
||||
exitCode,
|
||||
};
|
||||
}
|
||||
|
||||
const VALID_NERVE_YAML = `senses:
|
||||
counter:
|
||||
group: e2e
|
||||
|
||||
workflows: {}
|
||||
|
||||
max_rounds: 10
|
||||
|
||||
api:
|
||||
port: null
|
||||
token: null
|
||||
host: 127.0.0.1
|
||||
`;
|
||||
|
||||
describe("e2e validate", () => {
|
||||
let fakeHome: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (fakeHome !== null) {
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
fakeHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("exits 0 for valid nerve.yaml", { timeout: 10_000 }, async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
mkdirSync(nerveRoot, { recursive: true });
|
||||
writeFileSync(join(nerveRoot, "nerve.yaml"), VALID_NERVE_YAML, "utf8");
|
||||
|
||||
const result = await runTestCli(fakeHome, ["validate"]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("✅");
|
||||
expect(result.stdout).toContain("valid");
|
||||
});
|
||||
|
||||
it("exits 1 for invalid nerve.yaml", { timeout: 10_000 }, async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
mkdirSync(nerveRoot, { recursive: true });
|
||||
writeFileSync(join(nerveRoot, "nerve.yaml"), "not: valid: yaml: {{{\n", "utf8");
|
||||
|
||||
const result = await runTestCli(fakeHome, ["validate"]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exits 1 for malformed config (missing required fields)", { timeout: 10_000 }, async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
mkdirSync(nerveRoot, { recursive: true });
|
||||
writeFileSync(join(nerveRoot, "nerve.yaml"), "foo: bar\n", "utf8");
|
||||
|
||||
const result = await runTestCli(fakeHome, ["validate"]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("❌");
|
||||
});
|
||||
|
||||
it("exits 1 when nerve.yaml is missing", { timeout: 10_000 }, async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-validate-e2e-"));
|
||||
// Don't create .uncaged-nerve at all
|
||||
|
||||
const result = await runTestCli(fakeHome, ["validate"]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("e2e init", () => {
|
||||
let fakeHome: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (fakeHome !== null) {
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
fakeHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("init --force creates workspace layout", { timeout: 10_000 }, async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-init-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
|
||||
const result = await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
// init should exit 0 (install/git failures are warnings, not fatal)
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("✅");
|
||||
|
||||
// Verify key files exist
|
||||
expect(existsSync(join(nerveRoot, "nerve.yaml"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "package.json"))).toBe(true);
|
||||
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, ".cursor", "rules", "nerve-skills.mdc"))).toBe(true);
|
||||
|
||||
const pkgJson = readFileSync(join(nerveRoot, "package.json"), "utf8");
|
||||
expect(pkgJson).not.toContain("nerve-skills");
|
||||
expect(pkgJson).toContain('"build": "node scripts/build.mjs"');
|
||||
expect(pkgJson).toContain('"esbuild": "^0.27.0"');
|
||||
|
||||
const buildScript = readFileSync(join(nerveRoot, "scripts", "build.mjs"), "utf8");
|
||||
expect(buildScript).toContain('path.join(root, "senses")');
|
||||
expect(buildScript).toContain('path.join(root, "workflows")');
|
||||
expect(buildScript).toContain("dist");
|
||||
});
|
||||
|
||||
it("generated nerve.yaml passes validate", { timeout: 10_000 }, async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-init-e2e-"));
|
||||
|
||||
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
|
||||
const validateResult = await runTestCli(fakeHome, ["validate"]);
|
||||
expect(validateResult.exitCode).toBe(0);
|
||||
expect(validateResult.stdout).toContain("✅");
|
||||
expect(validateResult.stdout).toContain("valid");
|
||||
});
|
||||
|
||||
it("init without --force on existing dir exits 1", { timeout: 10_000 }, async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-init-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
mkdirSync(nerveRoot, { recursive: true });
|
||||
writeFileSync(join(nerveRoot, "marker"), "exists", "utf8");
|
||||
|
||||
const result = await runTestCli(fakeHome, ["init"]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("already exists");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* E2E (issue #160): real kernel + workflow worker + IPC, then CLI `workflow` / `thread`
|
||||
* against logs.db. Run listings live on `nerve thread list` (there is no `nerve workflow runs` subcommand).
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createLogStore } from "@uncaged/nerve-store";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { type TestDaemonHandle, runCli, startTestDaemon, stopTestDaemon } from "./e2e-harness.js";
|
||||
|
||||
async function waitForCompletedEchoRuns(
|
||||
logsDbPath: string,
|
||||
minCount: number,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const store = createLogStore(logsDbPath);
|
||||
try {
|
||||
const done = store.getAllWorkflowRuns("echo").filter((r) => r.status === "completed");
|
||||
if (done.length >= minCount) return;
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out after ${String(timeoutMs)}ms waiting for ${String(minCount)} completed echo run(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("e2e workflow CLI (real daemon)", () => {
|
||||
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),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it(
|
||||
"trigger, thread list / --all, inspect, show (echo workflow)",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startTestDaemon();
|
||||
const logsDb = join(daemon.nerveRoot, "data", "logs.db");
|
||||
|
||||
const t1 = await runCli(daemon, [
|
||||
"workflow",
|
||||
"trigger",
|
||||
"echo",
|
||||
"--prompt",
|
||||
"alpha-e2e",
|
||||
"--max-rounds",
|
||||
"10",
|
||||
]);
|
||||
expect(t1.exitCode).toBe(0);
|
||||
expect(t1.stdout).toContain("Triggered");
|
||||
|
||||
const t2 = await runCli(daemon, [
|
||||
"workflow",
|
||||
"trigger",
|
||||
"echo",
|
||||
"--prompt",
|
||||
"beta-e2e",
|
||||
"--max-rounds",
|
||||
"10",
|
||||
]);
|
||||
expect(t2.exitCode).toBe(0);
|
||||
|
||||
const activeRightAfter = await runCli(daemon, ["thread", "list"]);
|
||||
expect(activeRightAfter.exitCode).toBe(0);
|
||||
expect(activeRightAfter.stdout).toContain("echo");
|
||||
expect(activeRightAfter.stdout).toMatch(/queued|started/);
|
||||
|
||||
await waitForCompletedEchoRuns(logsDb, 2, 25_000);
|
||||
|
||||
const store = createLogStore(logsDb);
|
||||
let runId: string;
|
||||
try {
|
||||
const completed = store
|
||||
.getAllWorkflowRuns("echo")
|
||||
.filter((r) => r.status === "completed")
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
expect(completed.length).toBeGreaterThanOrEqual(2);
|
||||
runId = completed[0]?.runId ?? "";
|
||||
expect(runId.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
|
||||
const listAll = await runCli(daemon, ["thread", "list", "--all"]);
|
||||
expect(listAll.exitCode).toBe(0);
|
||||
expect(listAll.stdout).toContain(runId);
|
||||
expect(listAll.stdout).toContain("✅");
|
||||
expect(listAll.stdout).toContain("workflow=echo");
|
||||
|
||||
const listDefault = await runCli(daemon, ["thread", "list"]);
|
||||
expect(listDefault.exitCode).toBe(0);
|
||||
expect(listDefault.stdout).toMatch(/No active workflow runs|📭 No active/);
|
||||
|
||||
const inspect = await runCli(daemon, ["thread", "inspect", runId, "--limit", "50"]);
|
||||
expect(inspect.exitCode).toBe(0);
|
||||
expect(inspect.stdout).toContain(`Workflow run: ${runId}`);
|
||||
expect(inspect.stdout).toContain("type=started");
|
||||
expect(inspect.stdout).toContain("type=completed");
|
||||
|
||||
const show = await runCli(daemon, ["thread", "show", runId, "--budget", "50000"]);
|
||||
expect(show.exitCode).toBe(0);
|
||||
expect(show.stdout).toContain("echo:alpha-e2e");
|
||||
expect(show.stdout).toContain("[#1 echo]");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
# nerve daemon — E2E Scenarios
|
||||
|
||||
## daemon start
|
||||
|
||||
- ✅ starts daemon, writes PID file, kernel comes up (via lifecycle test)
|
||||
- 🔲 start when already running — error or no-op
|
||||
- 🔲 start with `--foreground` flag
|
||||
|
||||
## daemon stop
|
||||
|
||||
- ✅ stops daemon, clears PID file (via lifecycle test)
|
||||
- 🔲 stop when not running — graceful message
|
||||
|
||||
## daemon status
|
||||
|
||||
- ✅ reports "running" when daemon is up, "not running" when stopped (via lifecycle test)
|
||||
- 🔲 `--json` output
|
||||
|
||||
## daemon restart
|
||||
|
||||
- 🔲 restarts daemon — stop + start round-trip
|
||||
- 🔲 restart when not running — starts fresh
|
||||
|
||||
## daemon logs (nerve logs)
|
||||
|
||||
- ✅ shows "log file not found" when no log exists
|
||||
- ✅ displays last N lines (tail mode)
|
||||
- ✅ respects `-n` flag to limit lines
|
||||
- ✅ supports `--offset` for pagination
|
||||
- ✅ shows pagination hint when earlier lines exist
|
||||
- ✅ shows empty message for empty log file
|
||||
- 🔲 `--follow` / `-f` streams new lines
|
||||
@@ -0,0 +1,5 @@
|
||||
# nerve dev — E2E Scenarios
|
||||
|
||||
- 🔲 runs foreground kernel session with hot-reload
|
||||
- 🔲 exits cleanly on Ctrl+C / SIGINT
|
||||
- 🔲 detects sense file changes and reloads
|
||||
@@ -0,0 +1,14 @@
|
||||
# nerve init — E2E Scenarios
|
||||
|
||||
## init (workspace)
|
||||
|
||||
- ✅ `--force` creates workspace layout (nerve.yaml, senses/, workflows/)
|
||||
- ✅ generated nerve.yaml passes validate
|
||||
- ✅ init without `--force` on existing dir exits 1
|
||||
- 🔲 init in empty dir without `--force` — succeeds
|
||||
- 🔲 `--from <git-url>` clones and sets up workspace
|
||||
|
||||
## create workflow / create sense
|
||||
|
||||
- 🔲 `nerve create workflow <name>` scaffolds under workflows/
|
||||
- 🔲 `nerve create sense <name>` scaffolds under senses/
|
||||
@@ -0,0 +1,36 @@
|
||||
# nerve remote — E2E Scenarios
|
||||
|
||||
## remote add
|
||||
|
||||
- 🔲 adds a named remote, persists to config
|
||||
- 🔲 duplicate name — error message
|
||||
|
||||
## remote list
|
||||
|
||||
- 🔲 lists all remotes with name and host
|
||||
- 🔲 empty state — no remotes
|
||||
|
||||
## remote show
|
||||
|
||||
- 🔲 shows remote details (host, token masked)
|
||||
- 🔲 non-existent remote — error message
|
||||
|
||||
## remote set-url
|
||||
|
||||
- 🔲 updates remote host
|
||||
- 🔲 non-existent remote — error message
|
||||
|
||||
## remote set-token
|
||||
|
||||
- 🔲 updates remote token
|
||||
- 🔲 non-existent remote — error message
|
||||
|
||||
## remote remove
|
||||
|
||||
- 🔲 removes a remote
|
||||
- 🔲 non-existent remote — error message
|
||||
|
||||
## remote default
|
||||
|
||||
- 🔲 set default remote
|
||||
- 🔲 show current default
|
||||
@@ -0,0 +1,13 @@
|
||||
# nerve sense — E2E Scenarios
|
||||
|
||||
## sense list
|
||||
|
||||
- ✅ prints sense list with name, group, throttle, triggers
|
||||
- 🔲 empty state — no senses registered, prints empty message
|
||||
- 🔲 `--json` — outputs valid JSON array
|
||||
|
||||
## sense trigger
|
||||
|
||||
- ✅ 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
|
||||
@@ -0,0 +1,6 @@
|
||||
# nerve smoke — E2E Scenarios
|
||||
|
||||
Full round-trip integration tests that exercise multiple subcommands together.
|
||||
|
||||
- ✅ 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
|
||||
@@ -0,0 +1,8 @@
|
||||
# nerve store — E2E Scenarios
|
||||
|
||||
## store archive
|
||||
|
||||
- ✅ archives old workflow logs to JSONL, removes rows from logs DB, thread list still reads workflow_runs
|
||||
- ✅ `--vacuum` completes VACUUM after archiving
|
||||
- 🔲 archive with no old data — exits cleanly with "nothing to archive"
|
||||
- 🔲 archive with custom `--before` date filter
|
||||
@@ -0,0 +1,24 @@
|
||||
# nerve thread — E2E Scenarios
|
||||
|
||||
## thread list
|
||||
|
||||
- ✅ `--all` completes without throwing
|
||||
- 🔲 lists active threads with run ID, workflow name, status
|
||||
- 🔲 empty state — no threads
|
||||
|
||||
## thread inspect
|
||||
|
||||
- ✅ inspect `<runId>` completes without throwing
|
||||
- 🔲 inspect non-existent runId — error message
|
||||
- 🔲 output contains workflow name, roles, round count
|
||||
|
||||
## thread show
|
||||
|
||||
- ✅ show `<runId>` completes without throwing (role rounds path)
|
||||
- 🔲 show non-existent runId — error message
|
||||
- 🔲 output contains conversation messages
|
||||
|
||||
## thread kill
|
||||
|
||||
- 🔲 kill active thread — exits 0, thread stops
|
||||
- 🔲 kill non-existent thread — error message
|
||||
@@ -0,0 +1,7 @@
|
||||
# nerve validate — E2E Scenarios
|
||||
|
||||
- ✅ exits 0 for valid nerve.yaml
|
||||
- ✅ exits 1 for invalid nerve.yaml
|
||||
- ✅ exits 1 for malformed config (missing required fields)
|
||||
- ✅ exits 1 when nerve.yaml is missing
|
||||
- 🔲 `--json` outputs structured validation errors
|
||||
@@ -0,0 +1,17 @@
|
||||
# nerve workflow — E2E Scenarios
|
||||
|
||||
## workflow list
|
||||
|
||||
- 🔲 lists registered workflows with name and status
|
||||
- 🔲 empty state — no workflows registered
|
||||
|
||||
## workflow status
|
||||
|
||||
- 🔲 shows status of a specific workflow
|
||||
- 🔲 non-existent workflow — error message
|
||||
|
||||
## workflow trigger
|
||||
|
||||
- ✅ trigger + thread list/inspect/show round-trip (echo workflow)
|
||||
- 🔲 trigger non-existent workflow — error message
|
||||
- 🔲 trigger with `--input` JSON payload
|
||||
@@ -0,0 +1,24 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { listKnowledgeFiles } from "../knowledge/glob-files.js";
|
||||
|
||||
describe("listKnowledgeFiles", () => {
|
||||
it("includes matching paths and applies exclude globs", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "nerve-glob-"));
|
||||
mkdirSync(join(root, "src"), { recursive: true });
|
||||
writeFileSync(join(root, "src", "keep.ts"), "export function x() {}\n");
|
||||
writeFileSync(join(root, "src", "drop.test.ts"), "// test\n");
|
||||
|
||||
const files = listKnowledgeFiles(root, {
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["**/*.test.ts"],
|
||||
});
|
||||
|
||||
expect(files).toContain("src/keep.ts");
|
||||
expect(files).not.toContain("src/drop.test.ts");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { knowledgeQueryScopeConflictMessage } from "../knowledge/query-scope.js";
|
||||
|
||||
describe("knowledgeQueryScopeConflictMessage", () => {
|
||||
it("returns null when only -r is used", () => {
|
||||
expect(knowledgeQueryScopeConflictMessage("/tmp/repo", false)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when only -g is used", () => {
|
||||
expect(knowledgeQueryScopeConflictMessage(undefined, true)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when neither -r nor -g", () => {
|
||||
expect(knowledgeQueryScopeConflictMessage(undefined, false)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns error when both -r and -g", () => {
|
||||
const msg = knowledgeQueryScopeConflictMessage("/some/path", true);
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg).toContain("-r");
|
||||
expect(msg).toContain("-g");
|
||||
});
|
||||
|
||||
it("treats empty -r as absent", () => {
|
||||
expect(knowledgeQueryScopeConflictMessage("", true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { fakeEmbeddingBytes } from "../knowledge/fake-embedding.js";
|
||||
import { contentHash, openKnowledgeDb, replaceAllChunks } from "../knowledge/knowledge-db.js";
|
||||
import { KNOWLEDGE_DB } from "../knowledge/paths.js";
|
||||
|
||||
const DIM = 1024;
|
||||
|
||||
function fakeEmbedding1024(seed: string): Buffer {
|
||||
const buf = Buffer.alloc(DIM * 4);
|
||||
for (let i = 0; i < DIM; i++) {
|
||||
const c = seed.charCodeAt(i % Math.max(seed.length, 1)) || 1;
|
||||
buf.writeFloatLE((c / 255) * Math.sin(i + 0.1), i * 4);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
const embedMocks = vi.hoisted(() => ({
|
||||
resolveEmbedConfig: vi.fn(),
|
||||
embedQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../knowledge/embed-service.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../knowledge/embed-service.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveEmbedConfig: () => embedMocks.resolveEmbedConfig(),
|
||||
embedQuery: (cfg: Parameters<typeof actual.embedQuery>[0], text: string) =>
|
||||
embedMocks.embedQuery(cfg, text),
|
||||
};
|
||||
});
|
||||
|
||||
import { queryKnowledgeRepo } from "../knowledge/query.js";
|
||||
|
||||
describe("queryKnowledgeRepo (word overlap fallback)", () => {
|
||||
const savedUrl = process.env.EMBED_SERVICE_URL;
|
||||
const savedToken = process.env.EMBED_AUTH_TOKEN;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBED_SERVICE_URL = undefined;
|
||||
process.env.EMBED_AUTH_TOKEN = undefined;
|
||||
embedMocks.resolveEmbedConfig.mockReturnValue(null);
|
||||
embedMocks.embedQuery.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedUrl !== undefined) {
|
||||
process.env.EMBED_SERVICE_URL = savedUrl;
|
||||
} else {
|
||||
process.env.EMBED_SERVICE_URL = undefined;
|
||||
}
|
||||
if (savedToken !== undefined) {
|
||||
process.env.EMBED_AUTH_TOKEN = savedToken;
|
||||
} else {
|
||||
process.env.EMBED_AUTH_TOKEN = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns higher scores for chunks that share words with the query", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "nerve-q-"));
|
||||
const dbPath = join(root, KNOWLEDGE_DB);
|
||||
mkdirSync(root, { recursive: true });
|
||||
|
||||
const db = openKnowledgeDb(dbPath);
|
||||
try {
|
||||
replaceAllChunks(db, [
|
||||
{
|
||||
path: "a.md",
|
||||
slug: "a.md#0",
|
||||
chunkIndex: 0,
|
||||
text: "the sense scheduler triggers computes",
|
||||
contentHash: contentHash("the sense scheduler triggers computes"),
|
||||
embedding: fakeEmbeddingBytes("a"),
|
||||
},
|
||||
{
|
||||
path: "b.md",
|
||||
slug: "b.md#0",
|
||||
chunkIndex: 0,
|
||||
text: "unrelated cooking recipes",
|
||||
contentHash: contentHash("unrelated cooking recipes"),
|
||||
embedding: fakeEmbeddingBytes("b"),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
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");
|
||||
expect(ranked[0]?.score).toBeGreaterThan(ranked[1]?.score ?? 0);
|
||||
});
|
||||
|
||||
it("respects limit", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "nerve-q2-"));
|
||||
const dbPath = join(root, KNOWLEDGE_DB);
|
||||
mkdirSync(root, { recursive: true });
|
||||
|
||||
const db = openKnowledgeDb(dbPath);
|
||||
try {
|
||||
replaceAllChunks(db, [
|
||||
{
|
||||
path: "x.md",
|
||||
slug: "x.md#0",
|
||||
chunkIndex: 0,
|
||||
text: "one",
|
||||
contentHash: contentHash("one"),
|
||||
embedding: fakeEmbeddingBytes("x"),
|
||||
},
|
||||
{
|
||||
path: "y.md",
|
||||
slug: "y.md#0",
|
||||
chunkIndex: 0,
|
||||
text: "two",
|
||||
contentHash: contentHash("two"),
|
||||
embedding: fakeEmbeddingBytes("y"),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const ranked = await queryKnowledgeRepo(root, dbPath, "one", 1);
|
||||
expect(ranked).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryKnowledgeRepo (embed service)", () => {
|
||||
const savedUrl = process.env.EMBED_SERVICE_URL;
|
||||
const savedToken = process.env.EMBED_AUTH_TOKEN;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBED_SERVICE_URL = "http://embed.test";
|
||||
process.env.EMBED_AUTH_TOKEN = "test-token";
|
||||
embedMocks.resolveEmbedConfig.mockReturnValue({
|
||||
url: "http://embed.test",
|
||||
token: "test-token",
|
||||
});
|
||||
embedMocks.embedQuery.mockImplementation(async (_c: unknown, text: string) =>
|
||||
fakeEmbedding1024(text),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
embedMocks.embedQuery.mockReset();
|
||||
embedMocks.resolveEmbedConfig.mockReset();
|
||||
if (savedUrl !== undefined) {
|
||||
process.env.EMBED_SERVICE_URL = savedUrl;
|
||||
} else {
|
||||
process.env.EMBED_SERVICE_URL = undefined;
|
||||
}
|
||||
if (savedToken !== undefined) {
|
||||
process.env.EMBED_AUTH_TOKEN = savedToken;
|
||||
} else {
|
||||
process.env.EMBED_AUTH_TOKEN = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses cosine similarity when embed config is present", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "nerve-q-embed-"));
|
||||
const dbPath = join(root, KNOWLEDGE_DB);
|
||||
mkdirSync(root, { recursive: true });
|
||||
|
||||
const textA = "alpha beta gamma";
|
||||
const textB = "zzz unrelated";
|
||||
|
||||
const db = openKnowledgeDb(dbPath);
|
||||
try {
|
||||
replaceAllChunks(db, [
|
||||
{
|
||||
path: "a.md",
|
||||
slug: "a.md#0",
|
||||
chunkIndex: 0,
|
||||
text: textA,
|
||||
contentHash: contentHash(textA),
|
||||
embedding: fakeEmbedding1024(textA),
|
||||
},
|
||||
{
|
||||
path: "b.md",
|
||||
slug: "b.md#0",
|
||||
chunkIndex: 0,
|
||||
text: textB,
|
||||
contentHash: contentHash(textB),
|
||||
embedding: fakeEmbedding1024(textB),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const ranked = await queryKnowledgeRepo(root, dbPath, textA, 10);
|
||||
expect(ranked.length).toBe(2);
|
||||
expect(ranked[0]?.path).toBe("a.md");
|
||||
expect(ranked[0]?.score).toBeGreaterThan(ranked[1]?.score ?? 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
listRegisteredKnowledgeRoots,
|
||||
readKnowledgeRegistry,
|
||||
registerKnowledgeRepoRoot,
|
||||
} from "../knowledge/registry.js";
|
||||
|
||||
describe("knowledge repo registry", () => {
|
||||
it("accumulates registered repo roots under a nerve home", () => {
|
||||
const nerveHome = mkdtempSync(join(tmpdir(), "nerve-reg-"));
|
||||
const repoA = mkdtempSync(join(tmpdir(), "repo-a-"));
|
||||
const repoB = mkdtempSync(join(tmpdir(), "repo-b-"));
|
||||
|
||||
registerKnowledgeRepoRoot(repoA, nerveHome);
|
||||
registerKnowledgeRepoRoot(repoB, nerveHome);
|
||||
registerKnowledgeRepoRoot(repoA, nerveHome);
|
||||
|
||||
expect(readKnowledgeRegistry(nerveHome).roots).toEqual([repoA, repoB].sort());
|
||||
expect(listRegisteredKnowledgeRoots(nerveHome)).toEqual([repoA, repoB].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const DIM = 1024;
|
||||
|
||||
function fakeEmbedding1024(seed: string): Buffer {
|
||||
const buf = Buffer.alloc(DIM * 4);
|
||||
for (let i = 0; i < DIM; i++) {
|
||||
const c = seed.charCodeAt(i % Math.max(seed.length, 1)) || 1;
|
||||
buf.writeFloatLE((c / 255) * Math.sin(i + 0.1), i * 4);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
vi.mock("../knowledge/embed-service.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../knowledge/embed-service.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveEmbedConfig: vi.fn(() => ({ url: "http://embed.test", token: "test-token" })),
|
||||
embedTexts: vi.fn(async (_config: unknown, texts: string[]) =>
|
||||
texts.map((t) => fakeEmbedding1024(t)),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { runKnowledgeSync } from "../knowledge/sync.js";
|
||||
|
||||
describe("runKnowledgeSync", () => {
|
||||
const savedUrl = process.env.EMBED_SERVICE_URL;
|
||||
const savedToken = process.env.EMBED_AUTH_TOKEN;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.EMBED_SERVICE_URL = "http://embed.test";
|
||||
process.env.EMBED_AUTH_TOKEN = "test-token";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedUrl !== undefined) {
|
||||
process.env.EMBED_SERVICE_URL = savedUrl;
|
||||
} else {
|
||||
process.env.EMBED_SERVICE_URL = undefined;
|
||||
}
|
||||
if (savedToken !== undefined) {
|
||||
process.env.EMBED_AUTH_TOKEN = savedToken;
|
||||
} else {
|
||||
process.env.EMBED_AUTH_TOKEN = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("creates knowledge.db with chunk rows", async () => {
|
||||
const nerveHome = mkdtempSync(join(tmpdir(), "nerve-home-"));
|
||||
const root = mkdtempSync(join(tmpdir(), "nerve-know-sync-"));
|
||||
mkdirSync(join(root, "docs"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "docs", "a.md"),
|
||||
`# Hello
|
||||
|
||||
Some body text about bananas.
|
||||
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, "knowledge.yaml"),
|
||||
`include:
|
||||
- "docs/**/*.md"
|
||||
exclude: []
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await runKnowledgeSync(root, nerveHome);
|
||||
expect(result.chunksWritten).toBeGreaterThan(0);
|
||||
expect(result.embeddingSource).toBe("remote");
|
||||
|
||||
const db = new DatabaseSync(result.dbPath, { readOnly: true });
|
||||
try {
|
||||
const row = db.prepare("SELECT COUNT(*) AS c FROM chunks").get() as { c: number };
|
||||
expect(row.c).toBe(result.chunksWritten);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Tests for nerve logs command — pure helper functions only.
|
||||
*
|
||||
* We test sliceLogs and buildLogFooter without touching the filesystem or
|
||||
* spawning a real process.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_LOG_LINES, buildLogFooter, readAllLines, sliceLogs } from "../commands/logs.js";
|
||||
import { logsCommand } from "../commands/logs.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sliceLogs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sliceLogs", () => {
|
||||
const make = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
|
||||
|
||||
it("returns empty result for empty array", () => {
|
||||
const r = sliceLogs([], 0, 50);
|
||||
expect(r.lines).toHaveLength(0);
|
||||
expect(r.total).toBe(0);
|
||||
expect(r.nextOffset).toBeNull();
|
||||
});
|
||||
|
||||
it("tail mode (offset=0): returns last N lines", () => {
|
||||
const lines = make(100);
|
||||
const r = sliceLogs(lines, 0, 10);
|
||||
expect(r.lines).toHaveLength(10);
|
||||
expect(r.lines[0]).toBe("line 91");
|
||||
expect(r.lines[9]).toBe("line 100");
|
||||
expect(r.startLine).toBe(91);
|
||||
expect(r.endLine).toBe(100);
|
||||
});
|
||||
|
||||
it("tail mode: when file shorter than limit, returns all", () => {
|
||||
const lines = make(20);
|
||||
const r = sliceLogs(lines, 0, 50);
|
||||
expect(r.lines).toHaveLength(20);
|
||||
expect(r.startLine).toBe(1);
|
||||
expect(r.endLine).toBe(20);
|
||||
expect(r.nextOffset).toBeNull();
|
||||
});
|
||||
|
||||
it("tail mode: provides nextOffset when earlier lines exist", () => {
|
||||
const lines = make(200);
|
||||
const r = sliceLogs(lines, 0, 50);
|
||||
expect(r.nextOffset).not.toBeNull();
|
||||
expect(r.nextOffset).toBe(151 - 50); // startLine=151, prev page starts at 101
|
||||
});
|
||||
|
||||
it("tail mode: nextOffset is null when showing from line 1", () => {
|
||||
const lines = make(40);
|
||||
const r = sliceLogs(lines, 0, 50);
|
||||
expect(r.nextOffset).toBeNull();
|
||||
});
|
||||
|
||||
it("offset mode: starts at given 1-based line number", () => {
|
||||
const lines = make(100);
|
||||
const r = sliceLogs(lines, 10, 5);
|
||||
expect(r.lines[0]).toBe("line 10");
|
||||
expect(r.startLine).toBe(10);
|
||||
expect(r.endLine).toBe(14);
|
||||
});
|
||||
|
||||
it("offset mode: clamps start to 0 for offset=1", () => {
|
||||
const lines = make(50);
|
||||
const r = sliceLogs(lines, 1, 10);
|
||||
expect(r.startLine).toBe(1);
|
||||
});
|
||||
|
||||
it("offset mode: nextOffset is null when slice starts at line 1", () => {
|
||||
const lines = make(50);
|
||||
const r = sliceLogs(lines, 1, 20);
|
||||
expect(r.nextOffset).toBeNull();
|
||||
});
|
||||
|
||||
it("offset mode: nextOffset points to previous page", () => {
|
||||
const lines = make(100);
|
||||
const r = sliceLogs(lines, 51, 50); // lines 51-100
|
||||
expect(r.nextOffset).toBe(1); // previous page starts at line 1
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildLogFooter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildLogFooter", () => {
|
||||
it("returns empty-file message when total=0", () => {
|
||||
const slice = { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
|
||||
expect(buildLogFooter(slice, 50, "/path/to/nerve.log")).toContain("empty");
|
||||
});
|
||||
|
||||
it("includes range and path in footer", () => {
|
||||
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
|
||||
const footer = buildLogFooter(slice, 50, "/var/log/nerve.log");
|
||||
expect(footer).toContain("lines 151-200 of 200");
|
||||
expect(footer).toContain("/var/log/nerve.log");
|
||||
});
|
||||
|
||||
it("includes pagination hint when nextOffset is set", () => {
|
||||
const slice = { lines: ["x"], total: 200, startLine: 151, endLine: 200, nextOffset: 101 };
|
||||
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
|
||||
expect(footer).toContain("nerve logs --offset 101 -n 50");
|
||||
});
|
||||
|
||||
it("no pagination hint when nextOffset is null", () => {
|
||||
const slice = { lines: ["x"], total: 20, startLine: 1, endLine: 20, nextOffset: null };
|
||||
const footer = buildLogFooter(slice, 50, "/path/nerve.log");
|
||||
expect(footer).not.toContain("nerve logs --offset");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DEFAULT_LOG_LINES constant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DEFAULT_LOG_LINES", () => {
|
||||
it("is 50", () => {
|
||||
expect(DEFAULT_LOG_LINES).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readAllLines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readAllLines", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns empty array for nonexistent file", async () => {
|
||||
const result = await readAllLines(join(tmpDir, "missing.log"));
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reads all lines from a file", async () => {
|
||||
const logFile = join(tmpDir, "test.log");
|
||||
writeFileSync(logFile, "line1\nline2\nline3\n");
|
||||
const result = await readAllLines(logFile);
|
||||
expect(result).toEqual(["line1", "line2", "line3"]);
|
||||
});
|
||||
|
||||
it("handles file with no trailing newline", async () => {
|
||||
const logFile = join(tmpDir, "test.log");
|
||||
writeFileSync(logFile, "a\nb\nc");
|
||||
const result = await readAllLines(logFile);
|
||||
expect(result).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty file", async () => {
|
||||
const logFile = join(tmpDir, "empty.log");
|
||||
writeFileSync(logFile, "");
|
||||
const result = await readAllLines(logFile);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration: readAllLines + sliceLogs end-to-end
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("readAllLines + sliceLogs integration", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "nerve-logs-int-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("tail-paginates a large log file correctly", async () => {
|
||||
const logFile = join(tmpDir, "big.log");
|
||||
const content = Array.from({ length: 120 }, (_, i) => `entry ${i + 1}`).join("\n");
|
||||
writeFileSync(logFile, content);
|
||||
|
||||
const all = await readAllLines(logFile);
|
||||
const page1 = sliceLogs(all, 0, 50); // last 50: lines 71-120
|
||||
expect(page1.startLine).toBe(71);
|
||||
expect(page1.endLine).toBe(120);
|
||||
expect(page1.nextOffset).toBe(21); // max(1, 71-50)
|
||||
|
||||
const page2 = sliceLogs(all, page1.nextOffset!, 50); // lines 21-70
|
||||
expect(page2.startLine).toBe(21);
|
||||
expect(page2.endLine).toBe(70);
|
||||
expect(page2.nextOffset).toBe(1); // max(1, 21-50) = 1
|
||||
|
||||
const page3 = sliceLogs(all, page2.nextOffset!, 50); // lines 1-50
|
||||
expect(page3.startLine).toBe(1);
|
||||
expect(page3.endLine).toBe(50);
|
||||
expect(page3.nextOffset).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// logsCommand: negative offset validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("logsCommand negative offset", () => {
|
||||
let stderrOutput: string;
|
||||
let exitCode: number | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
stderrOutput = "";
|
||||
exitCode = undefined;
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
||||
stderrOutput += typeof chunk === "string" ? chunk : chunk.toString();
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
|
||||
exitCode = typeof code === "number" ? code : 1;
|
||||
throw new Error(`process.exit(${exitCode})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("exits with code 1 and writes to stderr when offset is negative", async () => {
|
||||
await expect(
|
||||
logsCommand.run?.({
|
||||
args: { n: "50", offset: "-5", follow: false },
|
||||
rawArgs: [],
|
||||
cmd: logsCommand as never,
|
||||
}),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderrOutput).toContain("--offset must be a non-negative integer");
|
||||
expect(stderrOutput).toContain("-5");
|
||||
});
|
||||
|
||||
it("exits with code 1 for offset=-1", async () => {
|
||||
await expect(
|
||||
logsCommand.run?.({
|
||||
args: { n: "10", offset: "-1", follow: false },
|
||||
rawArgs: [],
|
||||
cmd: logsCommand as never,
|
||||
}),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as node_fs from "node:fs";
|
||||
import * as node_os from "node:os";
|
||||
import * as node_path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:os", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:os")>("node:os");
|
||||
return { ...actual, homedir: vi.fn(() => actual.homedir()) };
|
||||
});
|
||||
|
||||
import {
|
||||
type RemotesConfig,
|
||||
getDefaultRemoteName,
|
||||
loadRemotes,
|
||||
resolveRemote,
|
||||
saveRemotes,
|
||||
} from "../remotes.js";
|
||||
|
||||
describe("remotes", () => {
|
||||
let tmpDir: string;
|
||||
const homedirMock = node_os.homedir as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "nerve-remote-test-"));
|
||||
homedirMock.mockReturnValue(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
node_fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("loadRemotes returns empty config when file does not exist", () => {
|
||||
const config = loadRemotes();
|
||||
expect(config).toEqual({ remotes: {}, default: null });
|
||||
});
|
||||
|
||||
it("saveRemotes and loadRemotes round-trip", () => {
|
||||
const config: RemotesConfig = {
|
||||
remotes: {
|
||||
luming: { host: "192.168.2.58:9800", token: "secret" },
|
||||
tuanzi: { host: "100.89.82.86:9800", token: null },
|
||||
},
|
||||
default: "luming",
|
||||
};
|
||||
saveRemotes(config);
|
||||
const loaded = loadRemotes();
|
||||
expect(loaded).toEqual(config);
|
||||
});
|
||||
|
||||
it("saveRemotes creates file with restricted permissions", () => {
|
||||
saveRemotes({ remotes: {}, default: null });
|
||||
const p = node_path.join(tmpDir, ".nerve", "remotes.json");
|
||||
const stat = node_fs.statSync(p);
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("resolveRemote returns entry when found", () => {
|
||||
saveRemotes({
|
||||
remotes: { mybox: { host: "10.0.0.1:9800", token: "tok" } },
|
||||
default: null,
|
||||
});
|
||||
const result = resolveRemote("mybox");
|
||||
expect(result).toEqual({ host: "10.0.0.1:9800", token: "tok" });
|
||||
});
|
||||
|
||||
it("resolveRemote returns null when not found", () => {
|
||||
saveRemotes({ remotes: {}, default: null });
|
||||
expect(resolveRemote("nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("getDefaultRemoteName returns default", () => {
|
||||
saveRemotes({
|
||||
remotes: { a: { host: "h:1", token: null } },
|
||||
default: "a",
|
||||
});
|
||||
expect(getDefaultRemoteName()).toBe("a");
|
||||
});
|
||||
|
||||
it("getDefaultRemoteName returns null when unset", () => {
|
||||
expect(getDefaultRemoteName()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Integration test for `nerve sense list` through citty `runCommand` with a temp
|
||||
* HOME and nerve.yaml. The daemon IPC layer is exercised against a real Unix
|
||||
* socket mock server (no daemon process). `workspace.isRunning` and
|
||||
* `getSocketPath` are mocked so the CLI takes the live-daemon code path while
|
||||
* the socket points at the fake IPC server.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { type Server, createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { SenseInfo } from "@uncaged/nerve-core";
|
||||
import { runCommand } from "citty";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { senseCommand } from "../commands/sense.js";
|
||||
import * as workspace from "../workspace.js";
|
||||
|
||||
describe("nerve sense list CLI (runCommand + temp HOME + mock IPC socket)", () => {
|
||||
let prevHome: string | undefined;
|
||||
let fakeHome: string;
|
||||
let sockDir: string;
|
||||
let sockPath: string;
|
||||
let ipcServer: Server | null;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn<typeof process.stdout, "write">> | null;
|
||||
let listSensesRequests: unknown[];
|
||||
|
||||
const daemonSenseRow: SenseInfo = {
|
||||
name: "cpu-usage",
|
||||
group: "system",
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
triggers: ["every 5s"],
|
||||
};
|
||||
|
||||
function nerveYamlFixture(): string {
|
||||
return `
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system
|
||||
throttle: 5s
|
||||
timeout: 3s
|
||||
interval: 5s
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function collectStdout(): string {
|
||||
if (stdoutSpy === null) return "";
|
||||
let out = "";
|
||||
for (const call of stdoutSpy.mock.calls) {
|
||||
const chunk = call[0];
|
||||
if (typeof chunk === "string") out += chunk;
|
||||
else if (Buffer.isBuffer(chunk)) out += chunk.toString("utf8");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
listSensesRequests = [];
|
||||
ipcServer = null;
|
||||
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
prevHome = process.env.HOME;
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-sense-list-cli-e2e-"));
|
||||
process.env.HOME = fakeHome;
|
||||
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
mkdirSync(nerveRoot, { recursive: true });
|
||||
writeFileSync(join(nerveRoot, "nerve.yaml"), `${nerveYamlFixture()}\n`, "utf8");
|
||||
|
||||
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-ipc-e2e-"));
|
||||
sockPath = join(sockDir, "nerve.sock");
|
||||
|
||||
vi.spyOn(workspace, "getSocketPath").mockReturnValue(sockPath);
|
||||
vi.spyOn(workspace, "isRunning").mockReturnValue(true);
|
||||
|
||||
ipcServer = createServer((socket) => {
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
const line = chunk.toString("utf8").trim();
|
||||
try {
|
||||
const req = JSON.parse(line) as { type: string };
|
||||
if (req.type === "list-senses") {
|
||||
listSensesRequests.push(req);
|
||||
socket.write(`${JSON.stringify({ ok: true, senses: [daemonSenseRow] })}\n`);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
ipcServer?.listen(sockPath, resolve);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
stdoutSpy?.mockRestore();
|
||||
stdoutSpy = null;
|
||||
|
||||
if (ipcServer !== null) {
|
||||
await new Promise<void>((resolve) => {
|
||||
ipcServer?.close(() => resolve());
|
||||
});
|
||||
ipcServer = 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 });
|
||||
rmSync(sockDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prints sense list from daemon path with name, group, throttle, and trigger schedule", async () => {
|
||||
await runCommand(senseCommand, { rawArgs: ["list"] });
|
||||
|
||||
expect(listSensesRequests).toHaveLength(1);
|
||||
expect(listSensesRequests[0]).toMatchObject({ type: "list-senses" });
|
||||
|
||||
const out = collectStdout();
|
||||
expect(out).toContain("cpu-usage");
|
||||
expect(out).toContain("group: system");
|
||||
expect(out).toContain("throttle: 5s");
|
||||
expect(out).toContain("timeout: 3s");
|
||||
expect(out).toContain("trigger schedule: every 5s");
|
||||
expect(out).not.toContain("last signal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Tests for `nerve sense list` — formatting helpers and IPC round-trip.
|
||||
*
|
||||
* Covers:
|
||||
* - formatDuration helper
|
||||
* - formatSenseList output
|
||||
* - sensesFromConfig (static fallback from nerve.yaml)
|
||||
* - listSensesViaDaemon IPC round-trip via real Unix socket
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { SenseInfo } from "@uncaged/nerve-core";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { formatDuration, formatSenseList, sensesFromConfig } from "../commands/sense.js";
|
||||
import { listSensesViaDaemon } from "../daemon-client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_SENSES: SenseInfo[] = [
|
||||
{
|
||||
name: "cpu-usage",
|
||||
group: "system",
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
triggers: ["every 30s", "on: cpu-threshold"],
|
||||
},
|
||||
{
|
||||
name: "disk-usage",
|
||||
group: "system",
|
||||
throttle: 30000,
|
||||
timeout: null,
|
||||
triggers: [],
|
||||
},
|
||||
{
|
||||
name: "active-tasks",
|
||||
group: "tasks",
|
||||
throttle: 10000,
|
||||
timeout: 30000,
|
||||
triggers: ["every 1m"],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("returns '—' for null", () => {
|
||||
expect(formatDuration(null)).toBe("—");
|
||||
});
|
||||
|
||||
it("formats sub-minute durations as seconds", () => {
|
||||
expect(formatDuration(0)).toBe("0s");
|
||||
expect(formatDuration(1000)).toBe("1s");
|
||||
expect(formatDuration(59000)).toBe("59s");
|
||||
});
|
||||
|
||||
it("formats minute-range durations as Xm Ys", () => {
|
||||
expect(formatDuration(60000)).toBe("1m 0s");
|
||||
expect(formatDuration(90000)).toBe("1m 30s");
|
||||
expect(formatDuration(3599000)).toBe("59m 59s");
|
||||
});
|
||||
|
||||
it("formats hour-range durations as Xh Ym", () => {
|
||||
expect(formatDuration(3600000)).toBe("1h 0m");
|
||||
expect(formatDuration(3660000)).toBe("1h 1m");
|
||||
expect(formatDuration(7200000)).toBe("2h 0m");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatSenseList
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("formatSenseList", () => {
|
||||
it("returns empty message when no senses", () => {
|
||||
const output = formatSenseList([]);
|
||||
expect(output).toContain("No senses registered");
|
||||
});
|
||||
|
||||
it("shows sense count in header", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
expect(output).toContain("3");
|
||||
});
|
||||
|
||||
it("shows each sense name", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
expect(output).toContain("cpu-usage");
|
||||
expect(output).toContain("disk-usage");
|
||||
expect(output).toContain("active-tasks");
|
||||
});
|
||||
|
||||
it("shows group for each sense", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
expect(output).toContain("system");
|
||||
expect(output).toContain("tasks");
|
||||
});
|
||||
|
||||
it("shows throttle and timeout durations", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
// cpu-usage: throttle=5s, timeout=3s
|
||||
expect(output).toContain("5s");
|
||||
expect(output).toContain("3s");
|
||||
// disk-usage: timeout=null → '—'
|
||||
expect(output).toContain("—");
|
||||
});
|
||||
|
||||
it("shows trigger schedule from sense metadata", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
expect(output).toContain("trigger schedule:");
|
||||
expect(output).toContain("every 30s");
|
||||
expect(output).toContain("(none)");
|
||||
});
|
||||
|
||||
it("does not include last signal line", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
expect(output).not.toContain("last signal");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sensesFromConfig — static fallback from nerve.yaml
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("sensesFromConfig", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns empty array when file does not exist", () => {
|
||||
const result = sensesFromConfig(join(tmpDir, "nonexistent.yaml"));
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when file has invalid YAML", () => {
|
||||
const path = join(tmpDir, "nerve.yaml");
|
||||
writeFileSync(path, "not: valid: yaml: :::");
|
||||
const result = sensesFromConfig(path);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses senses from valid nerve.yaml", () => {
|
||||
const path = join(tmpDir, "nerve.yaml");
|
||||
writeFileSync(
|
||||
path,
|
||||
`
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system
|
||||
throttle: 5s
|
||||
timeout: 3s
|
||||
disk-usage:
|
||||
group: system
|
||||
throttle: 30s
|
||||
`.trim(),
|
||||
);
|
||||
const result = sensesFromConfig(path);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "cpu-usage",
|
||||
group: "system",
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
name: "disk-usage",
|
||||
group: "system",
|
||||
});
|
||||
});
|
||||
|
||||
it("populates throttle and timeout from config", () => {
|
||||
const path = join(tmpDir, "nerve.yaml");
|
||||
writeFileSync(
|
||||
path,
|
||||
`
|
||||
senses:
|
||||
my-sense:
|
||||
group: default
|
||||
throttle: 10s
|
||||
timeout: 5s
|
||||
`.trim(),
|
||||
);
|
||||
const result = sensesFromConfig(path);
|
||||
expect(result[0].throttle).toBe(10000);
|
||||
expect(result[0].timeout).toBe(5000);
|
||||
});
|
||||
|
||||
it("uses inline interval and on for trigger schedule labels", () => {
|
||||
const path = join(tmpDir, "nerve.yaml");
|
||||
writeFileSync(
|
||||
path,
|
||||
`
|
||||
senses:
|
||||
downstream:
|
||||
group: default
|
||||
interval: 15s
|
||||
on: [upstream]
|
||||
upstream:
|
||||
group: default
|
||||
`.trim(),
|
||||
);
|
||||
const result = sensesFromConfig(path);
|
||||
const downstream = result.find((s) => s.name === "downstream");
|
||||
const upstream = result.find((s) => s.name === "upstream");
|
||||
expect(downstream?.triggers).toEqual(["every 15s · on: upstream"]);
|
||||
expect(upstream?.triggers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listSensesViaDaemon — IPC round-trip via real Unix socket
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("listSensesViaDaemon", () => {
|
||||
let sockDir: string;
|
||||
let sockPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-list-ipc-"));
|
||||
sockPath = join(sockDir, "nerve.sock");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(sockDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolves with { ok: true, senses: [] } when daemon returns empty list", async () => {
|
||||
const server = createServer((s) => {
|
||||
s.on("data", (chunk: Buffer) => {
|
||||
const line = chunk.toString("utf8").trim();
|
||||
try {
|
||||
const req = JSON.parse(line) as { type: string };
|
||||
if (req.type === "list-senses") {
|
||||
s.write(`${JSON.stringify({ ok: true, senses: [] })}\n`);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
const result = await listSensesViaDaemon(sockPath);
|
||||
expect(result).toEqual({ ok: true, senses: [] });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves with populated senses array", async () => {
|
||||
const senses: SenseInfo[] = [
|
||||
{
|
||||
name: "cpu-usage",
|
||||
group: "system",
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
triggers: [],
|
||||
},
|
||||
];
|
||||
const server = createServer((s) => {
|
||||
s.on("data", () => {
|
||||
s.write(`${JSON.stringify({ ok: true, senses })}\n`);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
const result = await listSensesViaDaemon(sockPath);
|
||||
expect(result).toEqual({ ok: true, senses });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves with { ok: false, error } when daemon returns an error", async () => {
|
||||
const server = createServer((s) => {
|
||||
s.on("data", () => {
|
||||
s.write(`${JSON.stringify({ ok: false, error: "something went wrong" })}\n`);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
const result = await listSensesViaDaemon(sockPath);
|
||||
expect(result).toEqual({ ok: false, error: "something went wrong" });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when no daemon is listening on the socket", async () => {
|
||||
await expect(listSensesViaDaemon(sockPath)).rejects.toThrow(/Cannot connect to daemon/);
|
||||
});
|
||||
|
||||
it("sends a list-senses IPC message to the daemon", async () => {
|
||||
const received: unknown[] = [];
|
||||
const server = createServer((s) => {
|
||||
s.on("data", (chunk: Buffer) => {
|
||||
const line = chunk.toString("utf8").trim();
|
||||
try {
|
||||
received.push(JSON.parse(line));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
s.write(`${JSON.stringify({ ok: true, senses: [] })}\n`);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
await listSensesViaDaemon(sockPath);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({ type: "list-senses" });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* E2E-style tests for `nerve sense trigger` (issue #157): citty + workspace stubs
|
||||
* and a mock daemon on a real Unix socket — no running nerve process required.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { type Server, createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runCommand } from "citty";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { senseCommand } from "../commands/sense.js";
|
||||
import * as workspace from "../workspace.js";
|
||||
|
||||
describe("nerve sense trigger (e2e mock daemon)", () => {
|
||||
let sockDir: string;
|
||||
let sockPath: string;
|
||||
let server: Server;
|
||||
let ipcReceived: unknown[];
|
||||
let knownOkSenses: Set<string>;
|
||||
let stdoutBuf: string;
|
||||
let stderrBuf: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
ipcReceived = [];
|
||||
knownOkSenses = new Set(["cpu-usage"]);
|
||||
stdoutBuf = "";
|
||||
stderrBuf = "";
|
||||
|
||||
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-trigger-e2e-"));
|
||||
sockPath = join(sockDir, "nerve.sock");
|
||||
|
||||
server = createServer((socket) => {
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
const line = chunk.toString("utf8").trim();
|
||||
let req: unknown;
|
||||
try {
|
||||
req = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
ipcReceived.push(req);
|
||||
const r = req as { type?: string; sense?: string };
|
||||
if (
|
||||
r.type === "trigger-sense" &&
|
||||
typeof r.sense === "string" &&
|
||||
knownOkSenses.has(r.sense)
|
||||
) {
|
||||
socket.write(`${JSON.stringify({ ok: true })}\n`);
|
||||
} else if (r.type === "trigger-sense" && typeof r.sense === "string") {
|
||||
socket.write(`${JSON.stringify({ ok: false, error: `Unknown sense: "${r.sense}"` })}\n`);
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(sockPath, resolve);
|
||||
});
|
||||
|
||||
vi.spyOn(workspace, "isRunning").mockReturnValue(true);
|
||||
vi.spyOn(workspace, "getSocketPath").mockReturnValue(sockPath);
|
||||
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk, encodingOrCb?, cb?) => {
|
||||
stdoutBuf += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
||||
if (typeof encodingOrCb === "function") {
|
||||
encodingOrCb(null);
|
||||
} else if (cb !== undefined) {
|
||||
cb(null);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
vi.spyOn(process.stderr, "write").mockImplementation((chunk, encodingOrCb?, cb?) => {
|
||||
stderrBuf += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
||||
if (typeof encodingOrCb === "function") {
|
||||
encodingOrCb(null);
|
||||
} else if (cb !== undefined) {
|
||||
cb(null);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
|
||||
const c = typeof code === "number" ? code : 1;
|
||||
// Throw instead of actually exiting so the test runner stays alive;
|
||||
// the test asserts on this message to verify the exit code.
|
||||
throw new Error(`process.exit(${String(c)})`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
rmSync(sockDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("trigger known sense exits normally and stdout contains Triggered", async () => {
|
||||
await runCommand(senseCommand, { rawArgs: ["trigger", "cpu-usage"] });
|
||||
expect(stdoutBuf).toContain("Triggered");
|
||||
expect(stdoutBuf).toContain("✅");
|
||||
expect(stderrBuf).toBe("");
|
||||
});
|
||||
|
||||
it("trigger non-existent sense writes daemon error to stderr and exits 1", async () => {
|
||||
await expect(
|
||||
runCommand(senseCommand, { rawArgs: ["trigger", "no-such-sense"] }),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
expect(stdoutBuf).toBe("");
|
||||
expect(stderrBuf).toContain("Daemon rejected trigger");
|
||||
expect(stderrBuf).toContain("Unknown sense");
|
||||
expect(stderrBuf).toContain("no-such-sense");
|
||||
});
|
||||
|
||||
it("sends IPC { type: trigger-sense, sense: <name> } to the daemon", async () => {
|
||||
knownOkSenses.add("custom-sense");
|
||||
await runCommand(senseCommand, { rawArgs: ["trigger", "custom-sense"] });
|
||||
expect(ipcReceived).toHaveLength(1);
|
||||
expect(ipcReceived[0]).toEqual({ type: "trigger-sense", sense: "custom-sense" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Tests for the sense CLI helper — triggerSenseViaDaemon IPC round-trip.
|
||||
*
|
||||
* Uses a real Unix socket server to validate the full client/server
|
||||
* protocol without requiring a running daemon process.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { triggerSenseViaDaemon } from "../daemon-client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let sockDir: string;
|
||||
let sockPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
sockDir = mkdtempSync(join(tmpdir(), "nerve-sense-test-"));
|
||||
sockPath = join(sockDir, "nerve.sock");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(sockDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// triggerSenseViaDaemon — IPC round-trip via real Unix socket
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("triggerSenseViaDaemon", () => {
|
||||
it("resolves { ok: true } when daemon responds ok", async () => {
|
||||
const received: unknown[] = [];
|
||||
|
||||
const server = createServer((s) => {
|
||||
s.on("data", (chunk: Buffer) => {
|
||||
const line = chunk.toString("utf8").trim();
|
||||
try {
|
||||
received.push(JSON.parse(line));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
s.write(`${JSON.stringify({ ok: true })}\n`);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
const result = await triggerSenseViaDaemon(sockPath, "cpu-usage");
|
||||
expect(result).toEqual({ ok: true });
|
||||
// Verify the correct IPC message was sent
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({ type: "trigger-sense", sense: "cpu-usage" });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves { ok: false, error } when daemon rejects the sense", async () => {
|
||||
const server = createServer((s) => {
|
||||
s.on("data", () => {
|
||||
s.write(`${JSON.stringify({ ok: false, error: 'Unknown sense: "no-such-sense"' })}\n`);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
const result = await triggerSenseViaDaemon(sockPath, "no-such-sense");
|
||||
expect(result).toEqual({ ok: false, error: 'Unknown sense: "no-such-sense"' });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when no daemon is listening on the socket", async () => {
|
||||
await expect(triggerSenseViaDaemon(sockPath, "cpu-usage")).rejects.toThrow(
|
||||
/Cannot connect to daemon/,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the sense name exactly as provided", async () => {
|
||||
const received: unknown[] = [];
|
||||
|
||||
const server = createServer((s) => {
|
||||
s.on("data", (chunk: Buffer) => {
|
||||
const line = chunk.toString("utf8").trim();
|
||||
try {
|
||||
received.push(JSON.parse(line));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
s.write(`${JSON.stringify({ ok: true })}\n`);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
await triggerSenseViaDaemon(sockPath, "my-custom-sense");
|
||||
expect(received[0]).toMatchObject({ sense: "my-custom-sense" });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* RFC-003 Phase 5: nerve validate — workflow adapter usage and extract.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
validateAgentConfigurationLayer,
|
||||
workflowSourcesDeclareAdapterRoles,
|
||||
} from "../workflow-agent-validation.js";
|
||||
|
||||
function baseConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
||||
return {
|
||||
maxRounds: 10,
|
||||
senses: {},
|
||||
workflows: {},
|
||||
api: { port: null, token: null, host: "127.0.0.1" },
|
||||
extract: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateAgentConfigurationLayer", () => {
|
||||
let nerveRoot: string;
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(nerveRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("fails when workflow sources use adapters but extract is missing", () => {
|
||||
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
|
||||
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
|
||||
`
|
||||
const adapter = async () => "";
|
||||
const spec = {
|
||||
name: "demo",
|
||||
roles: {
|
||||
r: { adapter: adapter, prompt: "p", meta: {} as never },
|
||||
},
|
||||
moderator: () => "__end__" as never,
|
||||
};
|
||||
export default spec;
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = validateAgentConfigurationLayer(baseConfig(), nerveRoot);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.message).toMatch(/extract/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes when adapters are used and extract is configured", () => {
|
||||
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
|
||||
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "demo", "src", "index.ts"),
|
||||
`
|
||||
roles: { x: { adapter: foo, prompt: "", meta: {} as never } }
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = validateAgentConfigurationLayer(
|
||||
baseConfig({
|
||||
extract: { provider: "dashscope", model: "qwen-plus" },
|
||||
}),
|
||||
nerveRoot,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("passes when no adapter usage is detected", () => {
|
||||
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-val-adapters-"));
|
||||
mkdirSync(join(nerveRoot, "workflows", "demo", "src"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "demo", "src", "wf.ts"),
|
||||
`const role = { prompt: "x" };`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = validateAgentConfigurationLayer(baseConfig(), nerveRoot);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflowSourcesDeclareAdapterRoles", () => {
|
||||
let nerveRoot: string;
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(nerveRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("detects adapter: identifiers under workflows/*/src", () => {
|
||||
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-collect-adapters-"));
|
||||
mkdirSync(join(nerveRoot, "workflows", "w1", "src", "nested"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "w1", "src", "nested", "a.ts"),
|
||||
"adapter: foo\nadapter: bar",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(workflowSourcesDeclareAdapterRoles(nerveRoot)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Smoke / integration tests for `nerve workflow` and `nerve thread` citty handlers with a real HOME
|
||||
* layout and logs.db. `loadDaemonModule` is mocked so tests use workspace
|
||||
* `@uncaged/nerve-store` directly (no ~/.uncaged-nerve daemon install required).
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runCommand } from "citty";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../workspace-daemon.js", async () => {
|
||||
const { createLogStore } = await import("@uncaged/nerve-store");
|
||||
return {
|
||||
loadDaemonModule: vi.fn(async () => ({ createLogStore })),
|
||||
};
|
||||
});
|
||||
|
||||
import { createLogStore } from "@uncaged/nerve-store";
|
||||
|
||||
import { threadCommand } from "../commands/thread.js";
|
||||
|
||||
describe("nerve thread CLI (runCommand + temp HOME)", () => {
|
||||
let prevHome: string | undefined;
|
||||
let fakeHome: string;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn> | null;
|
||||
|
||||
beforeEach(() => {
|
||||
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
prevHome = process.env.HOME;
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-wf-cli-e2e-"));
|
||||
process.env.HOME = fakeHome;
|
||||
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
mkdirSync(join(nerveRoot, "data"), { recursive: true });
|
||||
const dbPath = join(nerveRoot, "data", "logs.db");
|
||||
const store = createLogStore(dbPath);
|
||||
|
||||
store.upsertWorkflowRun(
|
||||
{ source: "workflow", type: "started", refId: "e2e-run", payload: "{}", timestamp: 5000 },
|
||||
{ runId: "e2e-run", workflow: "demo", status: "completed", timestamp: 5000, exitCode: 0 },
|
||||
);
|
||||
store.append({
|
||||
source: "workflow",
|
||||
type: "completed",
|
||||
refId: "e2e-run",
|
||||
payload: null,
|
||||
timestamp: 5001,
|
||||
});
|
||||
store.append({
|
||||
source: "workflow",
|
||||
type: "thread_command_event",
|
||||
refId: "e2e-run",
|
||||
payload: JSON.stringify({ type: "step", role: "bot", content: "hello" }),
|
||||
timestamp: 5100,
|
||||
});
|
||||
store.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutSpy?.mockRestore();
|
||||
stdoutSpy = null;
|
||||
if (prevHome === undefined) {
|
||||
process.env.HOME = undefined;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("thread list --all completes without throwing", async () => {
|
||||
await expect(runCommand(threadCommand, { rawArgs: ["list", "--all"] })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("thread inspect <runId> completes without throwing", async () => {
|
||||
await expect(
|
||||
runCommand(threadCommand, { rawArgs: ["inspect", "e2e-run", "--limit", "10"] }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("thread show <runId> completes without throwing (role rounds path)", async () => {
|
||||
await expect(
|
||||
runCommand(threadCommand, { rawArgs: ["show", "e2e-run", "--budget", "50000"] }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* Tests for workflow CLI commands — pure logic helpers.
|
||||
*
|
||||
* Tests do NOT invoke the citty command handlers directly (they would call
|
||||
* process.exit / process.stdout.write against a real terminal). Instead we
|
||||
* test the exported pure helper functions that the command handlers delegate
|
||||
* to. The helpers use real LogStore / SQLite via temp directories.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createLogStore } from "@uncaged/nerve-store";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
|
||||
import {
|
||||
DEFAULT_THREAD_BUDGET_CHARS,
|
||||
UNKNOWN_TIMESTAMP_LABEL,
|
||||
buildInspectOutput,
|
||||
buildListOutput,
|
||||
buildThreadCommandOutput,
|
||||
formatRunLine,
|
||||
formatThreadRoundBlock,
|
||||
formatTs,
|
||||
getAllWorkflowRuns,
|
||||
parseIntArg,
|
||||
partitionWorkflowMessage,
|
||||
statusIcon,
|
||||
} from "../commands/workflow.js";
|
||||
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let tmpDir: string;
|
||||
let store: LogStore;
|
||||
|
||||
function upsertRun(
|
||||
runId: string,
|
||||
workflow: string,
|
||||
status: WorkflowRun["status"],
|
||||
timestampMs: number,
|
||||
): void {
|
||||
store.upsertWorkflowRun(
|
||||
{ source: "workflow", type: status, refId: runId, payload: null, timestamp: timestampMs },
|
||||
{ runId, workflow, status, timestamp: timestampMs, exitCode: null },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "nerve-cli-wf-test-"));
|
||||
store = createLogStore(join(tmpDir, "data", "logs.db"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatTs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("formatTs", () => {
|
||||
it("returns ISO 8601 string for a valid UTC instant", () => {
|
||||
const timestampMs = new Date("2026-01-01T00:00:00.000Z").getTime();
|
||||
expect(formatTs(timestampMs)).toBe("2026-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns placeholder for null and undefined", () => {
|
||||
expect(formatTs(null)).toBe(UNKNOWN_TIMESTAMP_LABEL);
|
||||
expect(formatTs(undefined)).toBe(UNKNOWN_TIMESTAMP_LABEL);
|
||||
});
|
||||
|
||||
it("returns placeholder for NaN and non-finite numbers", () => {
|
||||
expect(formatTs(Number.NaN)).toBe(UNKNOWN_TIMESTAMP_LABEL);
|
||||
expect(formatTs(Number.POSITIVE_INFINITY)).toBe(UNKNOWN_TIMESTAMP_LABEL);
|
||||
expect(formatTs(Number.NEGATIVE_INFINITY)).toBe(UNKNOWN_TIMESTAMP_LABEL);
|
||||
});
|
||||
|
||||
it("returns placeholder for non-number runtime values", () => {
|
||||
expect(formatTs("2024" as unknown as number)).toBe(UNKNOWN_TIMESTAMP_LABEL);
|
||||
expect(formatTs({} as unknown as number)).toBe(UNKNOWN_TIMESTAMP_LABEL);
|
||||
});
|
||||
|
||||
it("formats 0 as Unix epoch", () => {
|
||||
expect(formatTs(0)).toBe("1970-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("formats negative finite values as ISO strings", () => {
|
||||
expect(formatTs(-1)).toBe("1969-12-31T23:59:59.999Z");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatRunLine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("formatRunLine", () => {
|
||||
it("omits exit_code segment when exitCode is null", () => {
|
||||
const run: WorkflowRun = {
|
||||
runId: "r1",
|
||||
workflow: "wf",
|
||||
status: "started",
|
||||
timestamp: 1000,
|
||||
exitCode: null,
|
||||
};
|
||||
const line = formatRunLine(run);
|
||||
expect(line).not.toContain("exit_code");
|
||||
expect(line).toContain("timestamp=1970-01-01T00:00:01.000Z");
|
||||
});
|
||||
|
||||
it("includes exit_code when set", () => {
|
||||
const run: WorkflowRun = {
|
||||
runId: "r1",
|
||||
workflow: "wf",
|
||||
status: "failed",
|
||||
timestamp: 2000,
|
||||
exitCode: 7,
|
||||
};
|
||||
expect(formatRunLine(run)).toContain("exit_code=7");
|
||||
});
|
||||
|
||||
it("uses unknown timestamp label for bad run timestamps", () => {
|
||||
const run = {
|
||||
runId: "r-bad",
|
||||
workflow: "wf",
|
||||
status: "completed" as const,
|
||||
timestamp: Number.NaN,
|
||||
exitCode: null,
|
||||
} as WorkflowRun;
|
||||
expect(formatRunLine(run)).toContain(`timestamp=${UNKNOWN_TIMESTAMP_LABEL}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// statusIcon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("statusIcon", () => {
|
||||
it.each([
|
||||
["started", "▶"],
|
||||
["queued", "⏳"],
|
||||
["completed", "✅"],
|
||||
["failed", "❌"],
|
||||
["crashed", "💥"],
|
||||
["dropped", "🗑"],
|
||||
["interrupted", "⚠️"],
|
||||
["killed", "🛑"],
|
||||
] as const)("maps status=%s to icon=%s", (status, icon) => {
|
||||
expect(statusIcon(status)).toBe(icon);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAllWorkflowRuns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getAllWorkflowRuns", () => {
|
||||
it("returns empty array when no runs exist", () => {
|
||||
expect(getAllWorkflowRuns(store, null)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns all runs across statuses", () => {
|
||||
upsertRun("r1", "cleanup", "completed", 1000);
|
||||
upsertRun("r2", "cleanup", "started", 2000);
|
||||
upsertRun("r3", "deploy", "failed", 1500);
|
||||
|
||||
const runs = getAllWorkflowRuns(store, null);
|
||||
expect(runs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("deduplicates runs by runId (latest state only)", () => {
|
||||
upsertRun("r1", "cleanup", "started", 1000);
|
||||
upsertRun("r1", "cleanup", "completed", 2000);
|
||||
|
||||
const runs = getAllWorkflowRuns(store, null);
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].status).toBe("completed");
|
||||
});
|
||||
|
||||
it("filters by workflow name", () => {
|
||||
upsertRun("r1", "cleanup", "completed", 1000);
|
||||
upsertRun("r2", "deploy", "started", 2000);
|
||||
upsertRun("r3", "cleanup", "failed", 1500);
|
||||
|
||||
const runs = getAllWorkflowRuns(store, "cleanup");
|
||||
expect(runs).toHaveLength(2);
|
||||
for (const r of runs) {
|
||||
expect(r.workflow).toBe("cleanup");
|
||||
}
|
||||
});
|
||||
|
||||
it("sorts by timestamp descending (newest first)", () => {
|
||||
upsertRun("r1", "cleanup", "completed", 1000);
|
||||
upsertRun("r2", "cleanup", "started", 3000);
|
||||
upsertRun("r3", "cleanup", "failed", 2000);
|
||||
|
||||
const runs = getAllWorkflowRuns(store, null);
|
||||
expect(runs[0].timestamp).toBeGreaterThan(runs[1].timestamp);
|
||||
expect(runs[1].timestamp).toBeGreaterThan(runs[2].timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildListOutput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildListOutput", () => {
|
||||
function makeRun(
|
||||
runId: string,
|
||||
workflow: string,
|
||||
status: WorkflowRun["status"],
|
||||
timestampMs: number,
|
||||
): WorkflowRun {
|
||||
return { runId, workflow, status, timestamp: timestampMs, exitCode: null };
|
||||
}
|
||||
|
||||
it("returns empty message when no runs and --all=false", () => {
|
||||
const { lines, paginationHint } = buildListOutput([], 0, 20, false, null);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain("--all");
|
||||
expect(paginationHint).toBeNull();
|
||||
});
|
||||
|
||||
it("returns empty message when no runs and --all=true", () => {
|
||||
const { lines, paginationHint } = buildListOutput([], 0, 20, true, null);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).not.toContain("--all");
|
||||
expect(paginationHint).toBeNull();
|
||||
});
|
||||
|
||||
it("shows correct run count in header", () => {
|
||||
const runs = [
|
||||
makeRun("r1", "cleanup", "started", 1000),
|
||||
makeRun("r2", "cleanup", "queued", 2000),
|
||||
];
|
||||
const { lines } = buildListOutput(runs, 0, 20, false, null);
|
||||
expect(lines[0]).toContain("2 of 2");
|
||||
});
|
||||
|
||||
it("includes run details in lines", () => {
|
||||
const runs = [makeRun("run-abc", "my-workflow", "started", 1000)];
|
||||
const { lines } = buildListOutput(runs, 0, 20, false, null);
|
||||
const combined = lines.join("");
|
||||
expect(combined).toContain("run-abc");
|
||||
expect(combined).toContain("my-workflow");
|
||||
expect(combined).toContain("started");
|
||||
expect(combined).toContain("▶");
|
||||
});
|
||||
|
||||
it("paginates: shows only limit entries and provides hint", () => {
|
||||
const runs = Array.from({ length: 5 }, (_, i) => makeRun(`r${i}`, "wf", "completed", i * 1000));
|
||||
const { lines, paginationHint } = buildListOutput(runs, 0, 2, true, null);
|
||||
// header + 2 run lines
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(paginationHint).not.toBeNull();
|
||||
expect(paginationHint).toContain("nerve thread list");
|
||||
expect(paginationHint).toContain("--offset 2");
|
||||
expect(paginationHint).toContain("3 more");
|
||||
});
|
||||
|
||||
it("pagination hint includes --all flag when set", () => {
|
||||
const runs = Array.from({ length: 3 }, (_, i) => makeRun(`r${i}`, "wf", "completed", i * 1000));
|
||||
const { paginationHint } = buildListOutput(runs, 0, 1, true, null);
|
||||
expect(paginationHint).toContain("--all");
|
||||
});
|
||||
|
||||
it("pagination hint includes --workflow filter when set", () => {
|
||||
const runs = Array.from({ length: 3 }, (_, i) =>
|
||||
makeRun(`r${i}`, "cleanup", "completed", i * 1000),
|
||||
);
|
||||
const { paginationHint } = buildListOutput(runs, 0, 1, false, "cleanup");
|
||||
expect(paginationHint).toContain("--workflow cleanup");
|
||||
});
|
||||
|
||||
it("no pagination hint when all entries fit on one page", () => {
|
||||
const runs = [makeRun("r1", "wf", "started", 1000)];
|
||||
const { paginationHint } = buildListOutput(runs, 0, 20, false, null);
|
||||
expect(paginationHint).toBeNull();
|
||||
});
|
||||
|
||||
it("respects offset for pagination", () => {
|
||||
const runs = Array.from({ length: 5 }, (_, i) => makeRun(`r${i}`, "wf", "completed", i * 1000));
|
||||
const { lines, paginationHint } = buildListOutput(runs, 2, 2, true, null);
|
||||
// header + 2 run lines (offset=2, limit=2 gives items 2 and 3)
|
||||
expect(lines).toHaveLength(3);
|
||||
// 1 item remaining (index 4)
|
||||
expect(paginationHint).toContain("1 more");
|
||||
expect(paginationHint).toContain("--offset 4");
|
||||
});
|
||||
|
||||
it("does not throw when a run has null exit_code and invalid timestamp", () => {
|
||||
const runs: WorkflowRun[] = [
|
||||
{
|
||||
runId: "bad-ts",
|
||||
workflow: "wf",
|
||||
status: "completed",
|
||||
timestamp: null as unknown as number,
|
||||
exitCode: null,
|
||||
},
|
||||
];
|
||||
const { lines } = buildListOutput(runs, 0, 20, true, null);
|
||||
const text = lines.join("");
|
||||
expect(text).toContain(UNKNOWN_TIMESTAMP_LABEL);
|
||||
expect(text).not.toContain("exit_code");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildInspectOutput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildInspectOutput", () => {
|
||||
const baseRun: WorkflowRun = {
|
||||
runId: "run-xyz",
|
||||
workflow: "cleanup",
|
||||
status: "completed",
|
||||
timestamp: 1_700_000_000_000,
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
it("shows header with run details", () => {
|
||||
const { header } = buildInspectOutput(baseRun, [], 0, 20);
|
||||
const headerText = header.join("");
|
||||
expect(headerText).toContain("run-xyz");
|
||||
expect(headerText).toContain("cleanup");
|
||||
expect(headerText).toContain("completed");
|
||||
});
|
||||
|
||||
it("shows '(no events recorded)' when log is empty", () => {
|
||||
const { eventLines } = buildInspectOutput(baseRun, [], 0, 20);
|
||||
expect(eventLines.join("")).toContain("no events recorded");
|
||||
});
|
||||
|
||||
it("shows event lines with type and timestamp", () => {
|
||||
const logs = [{ timestamp: 1_700_000_001_000, type: "started", payload: null }];
|
||||
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
|
||||
const text = eventLines.join("");
|
||||
expect(text).toContain("type=started");
|
||||
});
|
||||
|
||||
it("truncates long payloads to 200 chars with ellipsis", () => {
|
||||
const longPayload = "x".repeat(250);
|
||||
const logs = [{ timestamp: 1000, type: "step_complete", payload: longPayload }];
|
||||
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
|
||||
const text = eventLines.join("");
|
||||
expect(text).toContain("…");
|
||||
expect(text).not.toContain("x".repeat(201));
|
||||
});
|
||||
|
||||
it("shows short payloads in full", () => {
|
||||
const logs = [{ timestamp: 1000, type: "step_complete", payload: '{"count":5}' }];
|
||||
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
|
||||
expect(eventLines.join("")).toContain('{"count":5}');
|
||||
});
|
||||
|
||||
it("paginates events with a hint", () => {
|
||||
const logs = Array.from({ length: 5 }, (_, i) => ({
|
||||
timestamp: 1000 + i,
|
||||
type: "step_complete",
|
||||
payload: null,
|
||||
}));
|
||||
const { eventLines, paginationHint } = buildInspectOutput(baseRun, logs, 0, 2);
|
||||
expect(eventLines).toHaveLength(2);
|
||||
expect(paginationHint).toContain("3 more");
|
||||
expect(paginationHint).toContain("--offset 2");
|
||||
expect(paginationHint).toContain("run-xyz");
|
||||
});
|
||||
|
||||
it("no pagination hint when all events fit on one page", () => {
|
||||
const logs = [{ timestamp: 1000, type: "started", payload: null }];
|
||||
const { paginationHint } = buildInspectOutput(baseRun, logs, 0, 20);
|
||||
expect(paginationHint).toBeNull();
|
||||
});
|
||||
|
||||
it("renders unknown labels for bad run and event timestamps without throwing", () => {
|
||||
const run: WorkflowRun = {
|
||||
...baseRun,
|
||||
timestamp: Number.NaN as unknown as number,
|
||||
};
|
||||
const logs = [{ timestamp: Number.NaN as unknown as number, type: "started", payload: null }];
|
||||
const { header, eventLines } = buildInspectOutput(run, logs, 0, 20);
|
||||
const all = [...header, ...eventLines].join("");
|
||||
expect(all).toContain(UNKNOWN_TIMESTAMP_LABEL);
|
||||
expect(all.match(new RegExp(UNKNOWN_TIMESTAMP_LABEL, "g"))).not.toBeNull();
|
||||
expect(
|
||||
(all.match(new RegExp(UNKNOWN_TIMESTAMP_LABEL, "g")) ?? []).length,
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration: getAllWorkflowRuns + buildListOutput end-to-end with real store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("workflow runs list — integration with real store", () => {
|
||||
it("lists active runs from the store", () => {
|
||||
upsertRun("r1", "cleanup", "started", 1000);
|
||||
upsertRun("r2", "cleanup", "queued", 2000);
|
||||
upsertRun("r3", "cleanup", "completed", 3000);
|
||||
|
||||
// Active only (getActiveWorkflowRuns)
|
||||
const activeRuns = store.getActiveWorkflowRuns();
|
||||
const { lines } = buildListOutput(activeRuns, 0, 20, false, null);
|
||||
const combined = lines.join("");
|
||||
expect(combined).toContain("r1");
|
||||
expect(combined).toContain("r2");
|
||||
expect(combined).not.toContain("r3");
|
||||
});
|
||||
|
||||
it("lists all runs with getAllWorkflowRuns", () => {
|
||||
upsertRun("r1", "cleanup", "started", 1000);
|
||||
upsertRun("r2", "cleanup", "completed", 2000);
|
||||
upsertRun("r3", "cleanup", "failed", 3000);
|
||||
|
||||
const allRuns = getAllWorkflowRuns(store, null);
|
||||
const { lines } = buildListOutput(allRuns, 0, 20, true, null);
|
||||
const combined = lines.join("");
|
||||
expect(combined).toContain("r1");
|
||||
expect(combined).toContain("r2");
|
||||
expect(combined).toContain("r3");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve workflow thread — formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("partitionWorkflowMessage", () => {
|
||||
it("extracts role, content, and meta", () => {
|
||||
const p = partitionWorkflowMessage({
|
||||
role: "scanner",
|
||||
content: "ok",
|
||||
meta: { items: [1, 2] },
|
||||
timestamp: 1,
|
||||
});
|
||||
expect(p.roleStr).toBe("scanner");
|
||||
expect(p.contentBody).toBe("ok");
|
||||
expect(p.meta).toEqual({ items: [1, 2] });
|
||||
});
|
||||
|
||||
it("passes through role and content as-is", () => {
|
||||
const p = partitionWorkflowMessage({
|
||||
role: "unknown",
|
||||
content: '{"n":1}',
|
||||
meta: null,
|
||||
timestamp: 0,
|
||||
});
|
||||
expect(p.roleStr).toBe("unknown");
|
||||
expect(p.contentBody).toBe('{"n":1}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatThreadRoundBlock", () => {
|
||||
const row: ThreadRoundRow = {
|
||||
round: 2,
|
||||
logId: 99,
|
||||
timestamp: new Date("2026-01-02T03:04:05.006Z").getTime(),
|
||||
message: { role: "bot", content: "hi", meta: { score: 0.5 }, timestamp: 1735783445006 },
|
||||
};
|
||||
|
||||
it("includes header, YAML frontmatter for meta, and body", () => {
|
||||
const text = formatThreadRoundBlock(row);
|
||||
expect(text).toContain("[#2 bot]");
|
||||
expect(text).toContain("---\n");
|
||||
expect(text).toContain("score: 0.5");
|
||||
expect(text).toContain("hi");
|
||||
});
|
||||
|
||||
it("uses unknown label when row timestamp is null (defensive)", () => {
|
||||
const badRow = {
|
||||
...row,
|
||||
timestamp: null as unknown as number,
|
||||
};
|
||||
expect(formatThreadRoundBlock(badRow)).toContain(UNKNOWN_TIMESTAMP_LABEL);
|
||||
});
|
||||
|
||||
it("uses unknown label when row timestamp is NaN (defensive)", () => {
|
||||
const badRow = { ...row, timestamp: Number.NaN };
|
||||
expect(formatThreadRoundBlock(badRow)).toContain(UNKNOWN_TIMESTAMP_LABEL);
|
||||
});
|
||||
|
||||
it("uses unknown label when row timestamp is undefined (defensive)", () => {
|
||||
const badRow = { ...row, timestamp: undefined as unknown as number };
|
||||
expect(formatThreadRoundBlock(badRow)).toContain(UNKNOWN_TIMESTAMP_LABEL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildThreadCommandOutput", () => {
|
||||
function row(n: number, content: string): ThreadRoundRow {
|
||||
return {
|
||||
round: n,
|
||||
logId: 10 + n,
|
||||
timestamp: 1000 + n,
|
||||
message: { role: "r", content, meta: { extra: n }, timestamp: 1000 + n },
|
||||
};
|
||||
}
|
||||
|
||||
it("orders rounds chronologically (oldest first in output)", () => {
|
||||
const desc = [row(3, "ccc"), row(2, "bbb"), row(1, "aaa")];
|
||||
const prefix = ["HEADER\n"];
|
||||
const { lines, paginationHint } = buildThreadCommandOutput(prefix, desc, 50_000, "run-x");
|
||||
const text = lines.join("");
|
||||
const idxA = text.indexOf("\naaa\n");
|
||||
const idxB = text.indexOf("\nbbb\n");
|
||||
const idxC = text.indexOf("\nccc\n");
|
||||
expect(idxA).toBeGreaterThan(-1);
|
||||
expect(idxB).toBeGreaterThan(idxA);
|
||||
expect(idxC).toBeGreaterThan(idxB);
|
||||
expect(paginationHint).toBeNull();
|
||||
});
|
||||
|
||||
it("emits pagination hint with --before when oldest shown round is still > 1", () => {
|
||||
const desc = [row(4, "d"), row(3, "c")];
|
||||
const { paginationHint } = buildThreadCommandOutput([], desc, 50_000, "run-y");
|
||||
expect(paginationHint).toContain("--before 3");
|
||||
expect(paginationHint).toContain("run-y");
|
||||
});
|
||||
|
||||
it("respects budget and hints with non-default --budget in command", () => {
|
||||
const big = "y".repeat(500);
|
||||
const desc = [row(2, big), row(1, "a")];
|
||||
const { lines, paginationHint } = buildThreadCommandOutput([], desc, 400, "run-z");
|
||||
const text = lines.join("");
|
||||
expect(text).toContain("[#2");
|
||||
expect(text).not.toContain("[#1");
|
||||
expect(paginationHint).toContain("--before 2");
|
||||
expect(paginationHint).toContain("--budget 400");
|
||||
});
|
||||
|
||||
it("formats startRow first (chronologically before role rounds) and consumes budget first", () => {
|
||||
const start: ThreadRoundRow = {
|
||||
round: 0,
|
||||
logId: 1,
|
||||
timestamp: 100,
|
||||
message: { role: "__start__", content: "go", meta: {}, timestamp: 100 },
|
||||
};
|
||||
const desc = [row(2, "bbb"), row(1, "aaa")];
|
||||
const { lines, paginationHint } = buildThreadCommandOutput([], desc, 50_000, "run-s", start);
|
||||
const text = lines.join("");
|
||||
const idxStart = text.indexOf("[#0 __start__]");
|
||||
const idxA = text.indexOf("\naaa\n");
|
||||
const idxB = text.indexOf("\nbbb\n");
|
||||
expect(idxStart).toBeGreaterThan(-1);
|
||||
expect(idxA).toBeGreaterThan(idxStart);
|
||||
expect(idxB).toBeGreaterThan(idxA);
|
||||
expect(paginationHint).toBeNull();
|
||||
});
|
||||
|
||||
it("default budget constant matches workflow command default", () => {
|
||||
expect(DEFAULT_THREAD_BUDGET_CHARS).toBe(8000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseIntArg
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseIntArg", () => {
|
||||
it("parses a valid integer string", () => {
|
||||
expect(parseIntArg("5", 20)).toBe(5);
|
||||
});
|
||||
|
||||
it("returns fallback for non-numeric string", () => {
|
||||
expect(parseIntArg("abc", 20)).toBe(20);
|
||||
});
|
||||
|
||||
it("returns the value for '0' (not fallback)", () => {
|
||||
expect(parseIntArg("0", 20)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns fallback for empty string", () => {
|
||||
expect(parseIntArg("", 20)).toBe(20);
|
||||
});
|
||||
|
||||
it("parses negative integers", () => {
|
||||
expect(parseIntArg("-3", 20)).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAllWorkflowRuns — backed by real store's SQL query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getAllWorkflowRuns — uses store.getAllWorkflowRuns SQL path", () => {
|
||||
it("returns all runs regardless of status", () => {
|
||||
upsertRun("r1", "deploy", "completed", 1000);
|
||||
upsertRun("r2", "deploy", "failed", 2000);
|
||||
upsertRun("r3", "deploy", "started", 3000);
|
||||
upsertRun("r4", "deploy", "queued", 4000);
|
||||
upsertRun("r5", "deploy", "crashed", 5000);
|
||||
upsertRun("r6", "deploy", "dropped", 6000);
|
||||
upsertRun("r7", "deploy", "interrupted", 7000);
|
||||
|
||||
const runs = getAllWorkflowRuns(store, null);
|
||||
expect(runs).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("returns runs sorted by timestamp descending (newest first)", () => {
|
||||
upsertRun("r1", "deploy", "completed", 1000);
|
||||
upsertRun("r2", "deploy", "completed", 3000);
|
||||
upsertRun("r3", "deploy", "completed", 2000);
|
||||
|
||||
const runs = getAllWorkflowRuns(store, null);
|
||||
expect(runs[0].timestamp).toBe(3000);
|
||||
expect(runs[1].timestamp).toBe(2000);
|
||||
expect(runs[2].timestamp).toBe(1000);
|
||||
});
|
||||
|
||||
it("filters by workflow name", () => {
|
||||
upsertRun("r1", "alpha", "completed", 1000);
|
||||
upsertRun("r2", "beta", "completed", 2000);
|
||||
upsertRun("r3", "alpha", "failed", 3000);
|
||||
|
||||
const runs = getAllWorkflowRuns(store, "alpha");
|
||||
expect(runs).toHaveLength(2);
|
||||
for (const r of runs) expect(r.workflow).toBe("alpha");
|
||||
});
|
||||
|
||||
it("returns empty array when store has no runs", () => {
|
||||
expect(getAllWorkflowRuns(store, null)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// triggerWorkflowViaDaemon — IPC round-trip via real Unix socket
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("triggerWorkflowViaDaemon", () => {
|
||||
let sockDir: string;
|
||||
let sockPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
sockDir = mkdtempSync(join(tmpdir(), "nerve-ipc-test-"));
|
||||
sockPath = join(sockDir, "nerve.sock");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(sockDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolves { ok: true } when server responds ok", async () => {
|
||||
const server = createServer((s) => {
|
||||
s.on("data", () => {
|
||||
s.write(`${JSON.stringify({ ok: true })}\n`);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
const result = await triggerWorkflowViaDaemon(sockPath, "my-workflow", "", 100);
|
||||
expect(result).toEqual({ ok: true });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves { ok: false, error } when server responds with error", async () => {
|
||||
const server = createServer((s) => {
|
||||
s.on("data", () => {
|
||||
s.write(`${JSON.stringify({ ok: false, error: "unknown workflow" })}\n`);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
const result = await triggerWorkflowViaDaemon(sockPath, "missing", "", 100);
|
||||
expect(result).toEqual({ ok: false, error: "unknown workflow" });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects when no daemon is listening on the socket", async () => {
|
||||
await expect(triggerWorkflowViaDaemon(sockPath, "my-workflow", "", 100)).rejects.toThrow(
|
||||
/Cannot connect to daemon/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { resolveRemote } from "./remotes.js";
|
||||
|
||||
let cliDaemonHost: string | null = null;
|
||||
let cliDaemonApiToken: string | null = null;
|
||||
|
||||
function applyRemoteFlag(argv: string[], i: number): number | null {
|
||||
const read =
|
||||
readEqOrNextFlag(argv, i, "--remote=", "--remote", "--remote requires a remote name") ??
|
||||
readEqOrNextFlag(argv, i, "-r=", "-r", "-r requires a remote name");
|
||||
if (read === null) return null;
|
||||
const resolved = resolveRemote(read.value);
|
||||
if (resolved === null) {
|
||||
throw new Error(`Unknown remote: "${read.value}"`);
|
||||
}
|
||||
if (cliDaemonHost === null) cliDaemonHost = resolved.host;
|
||||
if (cliDaemonApiToken === null && resolved.token !== null) cliDaemonApiToken = resolved.token;
|
||||
return read.lastConsumedIndex;
|
||||
}
|
||||
|
||||
function readEqOrNextFlag(
|
||||
argv: string[],
|
||||
i: number,
|
||||
flagEq: string,
|
||||
flagWord: string,
|
||||
missingMsg: string,
|
||||
): { value: string; lastConsumedIndex: number } | null {
|
||||
const a = argv[i];
|
||||
if (a === undefined) return null;
|
||||
if (a.startsWith(flagEq)) {
|
||||
const value = a.slice(flagEq.length);
|
||||
if (value.length === 0 || value.startsWith("-")) {
|
||||
throw new Error(missingMsg);
|
||||
}
|
||||
return { value, lastConsumedIndex: i };
|
||||
}
|
||||
if (a === flagWord) {
|
||||
const v = argv[i + 1];
|
||||
if (v === undefined || v.length === 0 || v.startsWith("-")) {
|
||||
throw new Error(missingMsg);
|
||||
}
|
||||
return { value: v, lastConsumedIndex: i + 1 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes `--host` / `--api-token` from argv before citty parses subcommands.
|
||||
* Must run once at process startup (see `cli.ts`).
|
||||
*/
|
||||
export function consumeGlobalDaemonCliFlags(argv: string[]): string[] {
|
||||
cliDaemonHost = null;
|
||||
cliDaemonApiToken = null;
|
||||
const out: string[] = [];
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === undefined) continue;
|
||||
|
||||
const hostRead = readEqOrNextFlag(
|
||||
argv,
|
||||
i,
|
||||
"--host=",
|
||||
"--host",
|
||||
"--host requires a non-empty value (e.g. 192.168.1.1:9800)",
|
||||
);
|
||||
if (hostRead !== null) {
|
||||
cliDaemonHost = hostRead.value;
|
||||
i = hostRead.lastConsumedIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokenRead = readEqOrNextFlag(
|
||||
argv,
|
||||
i,
|
||||
"--api-token=",
|
||||
"--api-token",
|
||||
"--api-token requires a value",
|
||||
);
|
||||
if (tokenRead !== null) {
|
||||
cliDaemonApiToken = tokenRead.value.length > 0 ? tokenRead.value : null;
|
||||
i = tokenRead.lastConsumedIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteResult = applyRemoteFlag(argv, i);
|
||||
if (remoteResult !== null) {
|
||||
i = remoteResult;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(a);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isRemoteDaemonCli(): boolean {
|
||||
return cliDaemonHost !== null && cliDaemonHost.length > 0;
|
||||
}
|
||||
|
||||
export function getCliDaemonHost(): string | null {
|
||||
return cliDaemonHost;
|
||||
}
|
||||
|
||||
export function getCliDaemonApiToken(): string | null {
|
||||
return cliDaemonApiToken;
|
||||
}
|
||||
+53
-9
@@ -1,25 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
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";
|
||||
import { initCommand } from "./commands/init.js";
|
||||
import { startCommand } from "./commands/start.js";
|
||||
import { statusCommand } from "./commands/status.js";
|
||||
import { stopCommand } from "./commands/stop.js";
|
||||
import { knowledgeCommand } from "./commands/knowledge.js";
|
||||
import { remoteCommand } from "./commands/remote.js";
|
||||
import { senseCommand } from "./commands/sense.js";
|
||||
import { storeCommand } from "./commands/store.js";
|
||||
import { threadCommand } from "./commands/thread.js";
|
||||
import { validateCommand } from "./commands/validate.js";
|
||||
import { workflowCommand } from "./commands/workflow.js";
|
||||
|
||||
/**
|
||||
* Citty picks the first non-flag token as a subcommand name. Rewrite
|
||||
* `nerve init --from <url>` so the URL is not mistaken for `workflow`/`workspace`.
|
||||
*/
|
||||
function normalizeNerveArgv(argv: string[]): string[] {
|
||||
const initIdx = argv.indexOf("init");
|
||||
if (initIdx === -1) return argv;
|
||||
const tail = argv.slice(initIdx + 1);
|
||||
const fromAt = tail.indexOf("--from");
|
||||
if (fromAt === -1) return argv;
|
||||
const beforeFrom = tail.slice(0, fromAt);
|
||||
if (beforeFrom.some((a) => !a.startsWith("-"))) return argv;
|
||||
const next = tail[fromAt + 1];
|
||||
if (next === undefined || next.startsWith("-")) return argv;
|
||||
const reserved = new Set(["workflow", "workspace"]);
|
||||
if (reserved.has(next)) return argv;
|
||||
const mergedTail = [...tail.slice(0, fromAt), `--from=${next}`, ...tail.slice(fromAt + 2)];
|
||||
return [...argv.slice(0, initIdx + 1), ...mergedTail];
|
||||
}
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "nerve",
|
||||
description: "Nerve — an AI agent kernel",
|
||||
description:
|
||||
"Nerve — an AI agent kernel. Global options: --host <host:port> (remote HTTP), --api-token <secret> (Bearer auth).",
|
||||
},
|
||||
subCommands: {
|
||||
agent: agentCommand,
|
||||
init: initCommand,
|
||||
start: startCommand,
|
||||
stop: stopCommand,
|
||||
status: statusCommand,
|
||||
create: createCommand,
|
||||
daemon: daemonCommand,
|
||||
dev: devCommand,
|
||||
validate: validateCommand,
|
||||
knowledge: knowledgeCommand,
|
||||
sense: senseCommand,
|
||||
store: storeCommand,
|
||||
remote: remoteCommand,
|
||||
thread: threadCommand,
|
||||
workflow: workflowCommand,
|
||||
},
|
||||
});
|
||||
|
||||
runMain(main);
|
||||
let cliArgv = process.argv.slice(2);
|
||||
try {
|
||||
cliArgv = consumeGlobalDaemonCliFlags(cliArgv);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
runMain(main, { rawArgs: normalizeNerveArgv(cliArgv) });
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
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*-->/;
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
function printCursorStatusLine(projectDir: string): void {
|
||||
const rulesPath = join(projectDir, ".cursorrules");
|
||||
const label = `Cursor (${projectDir})`;
|
||||
if (!existsSync(rulesPath)) {
|
||||
process.stdout.write(` ${label}: ❌ not installed\n`);
|
||||
return;
|
||||
}
|
||||
const ver = readCursorInjectVersion(projectDir);
|
||||
if (ver === null) {
|
||||
process.stdout.write(
|
||||
` ${label}: ⚠️ installed (unknown version; run \`nerve agent inject cursor\`)\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ver === cliVersion()) {
|
||||
process.stdout.write(` ${label}: ✅ v${ver}\n`);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
` ${label}: ⚠️ v${ver} → v${cliVersion()} available (run \`nerve agent inject cursor\`)\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function printStatus(): void {
|
||||
process.stdout.write(`nerve agent skills (CLI v${cliVersion()})\n\n`);
|
||||
|
||||
printCursorStatusLine(process.cwd());
|
||||
process.stdout.write("\n");
|
||||
|
||||
// Default profile
|
||||
const defaultDir = getHermesSkillDir(null);
|
||||
const defaultVer = readVersionFile(defaultDir);
|
||||
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",
|
||||
},
|
||||
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;
|
||||
}
|
||||
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
|
||||
process.stderr.write(" Supported targets: hermes, cursor\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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
process.stderr.write(`❌ Unknown agent target: ${target}\n`);
|
||||
process.stderr.write(" Supported targets: hermes, cursor\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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { getNerveRoot } from "../workspace.js";
|
||||
|
||||
export const RESOURCE_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
export function validateResourceName(name: string, type: string): string | null {
|
||||
if (name.length === 0) return `${type} name must not be empty.`;
|
||||
if (name.length > 64) return `${type} name must be 64 characters or fewer.`;
|
||||
if (!RESOURCE_NAME_RE.test(name))
|
||||
return `${type} name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type WorkflowScaffoldFiles = {
|
||||
indexTs: string;
|
||||
roleMainIndexTs: string;
|
||||
roleMainPromptMd: string;
|
||||
};
|
||||
|
||||
export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
|
||||
return {
|
||||
indexTs: buildWorkflowIndexTs(name),
|
||||
roleMainIndexTs: buildWorkflowMainRoleIndexTs(name),
|
||||
roleMainPromptMd: buildWorkflowMainRolePromptMd(name),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkflowIndexTs(name: string): string {
|
||||
return `import type { ThreadContext, WorkflowDefinition } from "@uncaged/nerve-core";
|
||||
import { END } from "@uncaged/nerve-core";
|
||||
|
||||
import { mainRole } from "./roles/main/index.js";
|
||||
|
||||
type MainMeta = Record<string, unknown>;
|
||||
|
||||
const workflow: WorkflowDefinition<Record<"main", MainMeta>> = {
|
||||
name: "${name}",
|
||||
roles: {
|
||||
main: mainRole,
|
||||
},
|
||||
moderator(ctx: ThreadContext<Record<"main", MainMeta>>) {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "main";
|
||||
}
|
||||
return END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
`;
|
||||
}
|
||||
|
||||
function buildWorkflowMainRoleIndexTs(name: string): string {
|
||||
return `import type { RoleResult, ThreadContext } from "@uncaged/nerve-core";
|
||||
|
||||
/**
|
||||
* Main role — implement LLM calls, scripts, HTTP, etc.
|
||||
* Optional: align behavior with \`prompt.md\` in this directory.
|
||||
*/
|
||||
export async function mainRole(
|
||||
ctx: ThreadContext,
|
||||
): Promise<RoleResult<Record<string, unknown>>> {
|
||||
void ctx;
|
||||
// TODO: implement your role logic here
|
||||
return {
|
||||
content: "${name} started",
|
||||
meta: {},
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildWorkflowMainRolePromptMd(name: string): string {
|
||||
return `# ${name} — main role
|
||||
|
||||
Starter template for this role's system or task instructions.
|
||||
|
||||
The scaffolded \`index.ts\` returns a fixed content line; replace that with real logic
|
||||
and optionally load this file at runtime if you keep prompts outside code.
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildSenseIndexTs(_senseId: string): string {
|
||||
return `type SenseState = {
|
||||
lastRun: number | null;
|
||||
};
|
||||
|
||||
export const initialState: SenseState = { lastRun: null };
|
||||
|
||||
export async function compute(state: SenseState): Promise<{
|
||||
state: SenseState;
|
||||
workflow: null;
|
||||
}> {
|
||||
// TODO: implement sense logic
|
||||
return {
|
||||
state: { lastRun: Date.now() },
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
function spawnAsync(cmd: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { cwd, stdio: "inherit" });
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${cmd} exited with code ${String(code)}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const createWorkflowCommand = defineCommand({
|
||||
meta: {
|
||||
name: "workflow",
|
||||
description: "Scaffold a new workflow at ~/.uncaged-nerve/workflows/<name>/",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "Workflow name (must match the key in nerve.yaml workflows section)",
|
||||
},
|
||||
force: {
|
||||
type: "boolean",
|
||||
description: "Overwrite if the workflow directory already exists",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
const workflowDir = join(nerveRoot, "workflows", args.name);
|
||||
|
||||
const nameError = validateResourceName(args.name, "Workflow");
|
||||
if (nameError !== null) {
|
||||
process.stderr.write(`❌ Invalid workflow name: ${nameError}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (existsSync(workflowDir) && !args.force) {
|
||||
process.stderr.write(
|
||||
`⚠️ Workflow "${args.name}" already exists at ${workflowDir}. Use --force to overwrite.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(workflowDir, { recursive: true });
|
||||
const scaffold = buildWorkflowScaffold(args.name);
|
||||
writeFile(join(workflowDir, "index.ts"), scaffold.indexTs);
|
||||
writeFile(join(workflowDir, "roles", "main", "index.ts"), scaffold.roleMainIndexTs);
|
||||
writeFile(join(workflowDir, "roles", "main", "prompt.md"), scaffold.roleMainPromptMd);
|
||||
|
||||
process.stdout.write("✅ Workflow scaffolded:\n");
|
||||
process.stdout.write(` ${join(workflowDir, "index.ts")}\n`);
|
||||
process.stdout.write(` ${join(workflowDir, "roles", "main", "index.ts")}\n`);
|
||||
process.stdout.write(` ${join(workflowDir, "roles", "main", "prompt.md")}\n`);
|
||||
|
||||
process.stdout.write("\nBuilding workspace (workflows + senses)…\n");
|
||||
try {
|
||||
await spawnAsync("pnpm", ["run", "build"], nerveRoot);
|
||||
process.stdout.write(
|
||||
`✅ Build complete — ${join("dist", "workflows", args.name, "index.js")} ready.\n`,
|
||||
);
|
||||
} catch {
|
||||
process.stdout.write(`⚠️ Build failed. Run manually:\n cd ${nerveRoot} && pnpm run build\n`);
|
||||
}
|
||||
|
||||
process.stdout.write("\n💡 Next steps:\n");
|
||||
process.stdout.write(" 1. Add to nerve.yaml:\n");
|
||||
process.stdout.write(" workflows:\n");
|
||||
process.stdout.write(` ${args.name}:\n`);
|
||||
process.stdout.write(" concurrency: 1\n");
|
||||
process.stdout.write(" overflow: drop\n");
|
||||
process.stdout.write(
|
||||
` 2. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
` 3. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
` 4. After edits, run \`pnpm run build\` from the workspace root (${nerveRoot}); output is dist/workflows/<name>/index.js.\n`,
|
||||
);
|
||||
process.stdout.write(" 5. Run `nerve start` to launch the daemon.\n");
|
||||
},
|
||||
});
|
||||
|
||||
const createSenseCommand = defineCommand({
|
||||
meta: {
|
||||
name: "sense",
|
||||
description: "Scaffold a new sense at ~/.uncaged-nerve/senses/<name>/",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "Sense id (must match the key in nerve.yaml senses section)",
|
||||
},
|
||||
force: {
|
||||
type: "boolean",
|
||||
description: "Overwrite if the sense directory already exists",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
const senseDir = join(nerveRoot, "senses", args.name);
|
||||
|
||||
const nameError = validateResourceName(args.name, "Sense");
|
||||
if (nameError !== null) {
|
||||
process.stderr.write(`❌ Invalid sense name: ${nameError}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (existsSync(senseDir) && !args.force) {
|
||||
process.stderr.write(
|
||||
`⚠️ Sense "${args.name}" already exists at ${senseDir}. Use --force to overwrite.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(join(senseDir, "src"), { recursive: true });
|
||||
writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name));
|
||||
|
||||
process.stdout.write("✅ Sense scaffolded:\n");
|
||||
process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`);
|
||||
|
||||
process.stdout.write("\nBuilding workspace (senses + workflows)…\n");
|
||||
try {
|
||||
await spawnAsync("pnpm", ["run", "build"], nerveRoot);
|
||||
process.stdout.write(
|
||||
`✅ Build complete — ${join("dist", "senses", args.name, "index.js")} ready.\n`,
|
||||
);
|
||||
} catch {
|
||||
process.stdout.write(`⚠️ Build failed. Run manually:\n cd ${nerveRoot} && pnpm run build\n`);
|
||||
}
|
||||
|
||||
process.stdout.write("\n💡 Next steps:\n");
|
||||
process.stdout.write(" 1. Add to nerve.yaml under senses:\n");
|
||||
process.stdout.write(` ${args.name}:\n`);
|
||||
process.stdout.write(" group: default\n");
|
||||
process.stdout.write(" throttle: null\n");
|
||||
process.stdout.write(" timeout: 10s\n");
|
||||
process.stdout.write(" grace_period: null\n");
|
||||
process.stdout.write(
|
||||
` 2. Edit ${join(senseDir, "src", "index.ts")} to implement ${args.name}.\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
` 3. Re-run \`pnpm run build\` from the workspace root (${nerveRoot}) after edits.\n`,
|
||||
);
|
||||
process.stdout.write(" 4. Run `nerve start` to launch the daemon.\n");
|
||||
},
|
||||
});
|
||||
|
||||
export const createCommand = defineCommand({
|
||||
meta: {
|
||||
name: "create",
|
||||
description: "Scaffold a new workflow or sense in the Nerve workspace",
|
||||
},
|
||||
subCommands: {
|
||||
workflow: createWorkflowCommand,
|
||||
sense: createSenseCommand,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { logsCommand } from "./logs.js";
|
||||
import { daemonStartCommand, runDaemonStartCommand } from "./start.js";
|
||||
import { statusCommand } from "./status.js";
|
||||
import { runStopCommand, stopCommand } from "./stop.js";
|
||||
|
||||
const daemonRestartCommand = defineCommand({
|
||||
meta: {
|
||||
name: "restart",
|
||||
description: "Stop then start the nerve daemon",
|
||||
},
|
||||
async run() {
|
||||
await runStopCommand();
|
||||
await runDaemonStartCommand();
|
||||
},
|
||||
});
|
||||
|
||||
export const daemonCommand = defineCommand({
|
||||
meta: {
|
||||
name: "daemon",
|
||||
description: "Manage the nerve background daemon",
|
||||
},
|
||||
subCommands: {
|
||||
start: daemonStartCommand,
|
||||
stop: stopCommand,
|
||||
status: statusCommand,
|
||||
restart: daemonRestartCommand,
|
||||
logs: logsCommand,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import {
|
||||
type ForegroundSessionOptions,
|
||||
runForegroundKernelSession,
|
||||
} from "../run-foreground-kernel.js";
|
||||
import { loadDaemonModule } from "../workspace-daemon.js";
|
||||
import { getNerveRoot } from "../workspace.js";
|
||||
|
||||
export const devCommand = defineCommand({
|
||||
meta: {
|
||||
name: "dev",
|
||||
description: "Run the nerve kernel in the foreground (development mode)",
|
||||
},
|
||||
args: {
|
||||
port: {
|
||||
type: "string",
|
||||
description: "HTTP API port (overrides nerve.yaml api.port). Omit to use YAML / env only.",
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
const { createKernel } = await loadDaemonModule(nerveRoot);
|
||||
let sessionOpts: ForegroundSessionOptions = {};
|
||||
if (args.port.length > 0) {
|
||||
const n = Number.parseInt(args.port, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 65_535) {
|
||||
process.stderr.write(`❌ Invalid --port: ${args.port}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
sessionOpts = { httpApiPortOverride: n };
|
||||
}
|
||||
await runForegroundKernelSession(nerveRoot, createKernel, sessionOpts);
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
@@ -12,81 +14,231 @@ senses:
|
||||
throttle: 5s
|
||||
timeout: 10s
|
||||
grace_period: null
|
||||
|
||||
reflexes:
|
||||
- kind: sense
|
||||
sense: cpu-usage
|
||||
interval: 10s
|
||||
`;
|
||||
|
||||
const PACKAGE_JSON = `{
|
||||
"name": "my-nerve-workspace",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "latest",
|
||||
"drizzle-orm": "latest"
|
||||
const BIOME_JSON = `{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "latest"
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noConsole": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PACKAGE_JSON = `${JSON.stringify(
|
||||
{
|
||||
name: "my-nerve-workspace",
|
||||
version: "0.0.1",
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build: "node scripts/build.mjs",
|
||||
},
|
||||
dependencies: {
|
||||
"@uncaged/nerve-core": "latest",
|
||||
"@uncaged/nerve-daemon": "latest",
|
||||
zod: "^4.3.6",
|
||||
},
|
||||
devDependencies: {
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/node": "^22.0.0",
|
||||
esbuild: "^0.27.0",
|
||||
typescript: "^5.7.0",
|
||||
},
|
||||
pnpm: {
|
||||
onlyBuiltDependencies: ["esbuild"],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
|
||||
const BUILD_MJS = `import * as esbuild from "esbuild";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const dist = path.join(root, "dist");
|
||||
|
||||
const opts = {
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
};
|
||||
|
||||
function listDirs(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => !name.startsWith(".") && !name.startsWith("_"))
|
||||
.map((name) => ({ name, full: path.join(dir, name) }))
|
||||
.filter(({ full }) => fs.statSync(full).isDirectory());
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Clean dist/
|
||||
fs.rmSync(dist, { recursive: true, force: true });
|
||||
|
||||
for (const { name, full } of listDirs(path.join(root, "senses"))) {
|
||||
const entry = path.join(full, "src", "index.ts");
|
||||
if (!fs.existsSync(entry)) continue;
|
||||
const outfile = path.join(dist, "senses", name, "index.js");
|
||||
fs.mkdirSync(path.dirname(outfile), { recursive: true });
|
||||
await esbuild.build({ ...opts, entryPoints: [entry], outfile });
|
||||
}
|
||||
|
||||
for (const { name, full } of listDirs(path.join(root, "workflows"))) {
|
||||
const entry = path.join(full, "index.ts");
|
||||
if (!fs.existsSync(entry)) continue;
|
||||
const outfile = path.join(dist, "workflows", name, "index.js");
|
||||
fs.mkdirSync(path.dirname(outfile), { recursive: true });
|
||||
await esbuild.build({ ...opts, entryPoints: [entry], outfile });
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
`;
|
||||
|
||||
const GITIGNORE = `data/
|
||||
logs/
|
||||
nerve.pid
|
||||
node_modules/
|
||||
knowledge.db
|
||||
`;
|
||||
|
||||
const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
/** Generated at workspace root so agents can \`cat AGENT.md\` instead of npm skill paths. */
|
||||
const AGENT_MD = `# Nerve workspace — agent guide
|
||||
|
||||
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(),
|
||||
});
|
||||
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 CPU_INDEX_TS = `import { cpus } from "node:os";
|
||||
const NERVE_SKILLS_MDC = `---
|
||||
description: >-
|
||||
Where Agent Skills live in this Nerve workspace and how to use them with Cursor
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
export async function compute(): Promise<unknown> {
|
||||
const cpuList = cpus();
|
||||
# Nerve Agent Skills
|
||||
|
||||
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;
|
||||
}
|
||||
**Agent Skills** are directories that contain a \`SKILL.md\` (with YAML frontmatter). Cursor loads them from **Project Skills** paths (for example \`.cursor/skills/\` or your global skills directory).
|
||||
|
||||
const loadPercent = totalTick === 0 ? 0 : ((totalTick - totalIdle) / totalTick) * 100;
|
||||
## Getting Nerve-oriented skills
|
||||
|
||||
return {
|
||||
model: cpuList[0]?.model ?? "unknown",
|
||||
loadPercent: Math.round(loadPercent * 100) / 100,
|
||||
ts: Date.now(),
|
||||
};
|
||||
There is no separate npm package for skills in the default workspace. To align with Nerve CLI, daemon, and monorepo conventions:
|
||||
|
||||
1. Copy or symlink skill folders from the **Nerve** repository (e.g. \`packages/skills/*/\`) into \`.cursor/skills/\`, **or**
|
||||
2. Follow project documentation and \`CLAUDE.md\` / \`.cursor/rules/\` in this repo.
|
||||
|
||||
## How to use in an agent
|
||||
|
||||
1. When a task matches a skill’s **description** (in \`SKILL.md\` frontmatter), open that file and follow its steps.
|
||||
2. Prefer those conventions for sense/workflow layout, \`nerve.yaml\`, and tooling over generic guesses.
|
||||
3. Keep skills versioned with your dotfiles or project; update them when you upgrade Nerve.
|
||||
`;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CPU_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;
|
||||
workflow: null;
|
||||
}> {
|
||||
const [oneMin] = loadavg();
|
||||
const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0;
|
||||
const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }];
|
||||
return { state: { samples: newSamples }, workflow: null };
|
||||
}
|
||||
`;
|
||||
|
||||
const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
load_percent REAL NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
async function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {
|
||||
const { spawn } = await import("node:child_process");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(cmd, args, { cwd, stdio: "inherit" });
|
||||
child.on("close", (code) => {
|
||||
@@ -97,27 +249,23 @@ async function runCommand(cmd: string, args: string[], cwd: string): Promise<voi
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPackageManager(): Promise<{ cmd: string; args: string[] }> {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function detectPackageManager(): Promise<{ cmd: string; installArgs: string[] }> {
|
||||
for (const pm of ["pnpm", "yarn", "npm"]) {
|
||||
try {
|
||||
await execFileAsync(pm, ["--version"]);
|
||||
const args = pm === "pnpm" ? ["install", "--no-cache"] : ["install"];
|
||||
return { cmd: pm, args };
|
||||
const installArgs = pm === "pnpm" ? ["install", "--no-cache"] : ["install"];
|
||||
return { cmd: pm, installArgs };
|
||||
} catch {
|
||||
// not available, try next
|
||||
}
|
||||
}
|
||||
return { cmd: "npm", args: ["install"] };
|
||||
return { cmd: "npm", installArgs: ["install"] };
|
||||
}
|
||||
|
||||
export const initCommand = defineCommand({
|
||||
const initWorkspaceCommand = defineCommand({
|
||||
meta: {
|
||||
name: "init",
|
||||
description: "Initialize the ~/.uncaged-nerve/ workspace",
|
||||
name: "workspace",
|
||||
description: "Initialize the ~/.uncaged-nerve/ workspace (default)",
|
||||
},
|
||||
args: {
|
||||
force: {
|
||||
@@ -125,46 +273,198 @@ export const initCommand = defineCommand({
|
||||
description: "Reinitialize even if workspace already exists (preserves data/)",
|
||||
default: false,
|
||||
},
|
||||
"skip-install": {
|
||||
type: "boolean",
|
||||
description: "Skip dependency installation (for testing or offline use)",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
|
||||
if (existsSync(nerveRoot) && !args.force) {
|
||||
process.stderr.write("⚠️ ~/.uncaged-nerve/ already exists. Use --force to reinitialize.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(join(nerveRoot, "data"), { 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, ".gitignore"), GITIGNORE);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "schema.ts"), CPU_SCHEMA_TS);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "index.ts"), CPU_INDEX_TS);
|
||||
writeFile(
|
||||
join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"),
|
||||
CPU_MIGRATION_SQL,
|
||||
);
|
||||
|
||||
process.stdout.write("Installing dependencies…\n");
|
||||
try {
|
||||
const { cmd, args } = await detectPackageManager();
|
||||
await runCommand(cmd, args, nerveRoot);
|
||||
} catch {
|
||||
process.stdout.write("⚠️ Install failed — you may need to install dependencies manually.\n");
|
||||
}
|
||||
|
||||
if (!existsSync(join(nerveRoot, ".git"))) {
|
||||
try {
|
||||
await runCommand("git", ["init"], nerveRoot);
|
||||
} catch {
|
||||
process.stdout.write("⚠️ git init failed — skipping.\n");
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
"✅ Workspace created at ~/.uncaged-nerve/\n 1 example sense: cpu-usage\n Run `nerve start` to launch the daemon.\n",
|
||||
);
|
||||
await runInitWorkspace(args.force, args["skip-install"]);
|
||||
},
|
||||
});
|
||||
|
||||
/** Verify built-in `node:sqlite` (Node.js ≥22.5) loads in a child process. */
|
||||
async function verifyNodeSqlite(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync(
|
||||
"node",
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"import { DatabaseSync } from 'node:sqlite'; new DatabaseSync(':memory:').exec('SELECT 1');",
|
||||
],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isNerveRootNonEmpty(nerveRoot: string): boolean {
|
||||
if (!existsSync(nerveRoot)) return false;
|
||||
return readdirSync(nerveRoot).length > 0;
|
||||
}
|
||||
|
||||
async function runInitFromGit(url: string): Promise<void> {
|
||||
const trimmed = url.trim();
|
||||
if (trimmed.length === 0) {
|
||||
process.stderr.write("❌ --from requires a non-empty git URL.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const nerveRoot = getNerveRoot();
|
||||
if (isNerveRootNonEmpty(nerveRoot)) {
|
||||
process.stderr.write(
|
||||
`❌ ${nerveRoot} already exists and is not empty. Remove it (or empty it) before using --from.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync("git", ["--version"]);
|
||||
} catch {
|
||||
process.stderr.write("❌ git is not available. Install git and retry.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await execFileAsync("pnpm", ["--version"]);
|
||||
} catch {
|
||||
process.stderr.write("❌ pnpm is not available. Install pnpm and retry.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`Cloning ${trimmed} → ${nerveRoot} …\n`);
|
||||
try {
|
||||
await runCommand("git", ["clone", trimmed, nerveRoot], process.cwd());
|
||||
} catch {
|
||||
process.stderr.write("❌ git clone failed.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(join(nerveRoot, "nerve.yaml"))) {
|
||||
process.stdout.write(`⚠️ ${join(nerveRoot, "nerve.yaml")} not found after clone.\n`);
|
||||
}
|
||||
if (!existsSync(join(nerveRoot, "package.json"))) {
|
||||
process.stdout.write(`⚠️ ${join(nerveRoot, "package.json")} not found after clone.\n`);
|
||||
}
|
||||
|
||||
process.stdout.write("Installing dependencies with pnpm …\n");
|
||||
try {
|
||||
await runCommand("pnpm", ["install", "--no-cache"], nerveRoot);
|
||||
} catch {
|
||||
process.stdout.write(
|
||||
`⚠️ pnpm install failed. Try manually:\n cd ${nerveRoot} && pnpm install --no-cache\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyNodeSqlite())) {
|
||||
process.stdout.write(
|
||||
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
|
||||
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`✅ Workspace cloned to ${nerveRoot}\n\n💡 Next steps:\n 1. Review nerve.yaml and install any missing tooling.\n 2. Run \`nerve start\` to launch the daemon.\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runInitWorkspace(force: boolean, skipInstall = false): Promise<void> {
|
||||
const nerveRoot = getNerveRoot();
|
||||
|
||||
if (existsSync(nerveRoot) && !force) {
|
||||
process.stderr.write("⚠️ ~/.uncaged-nerve/ already exists. Use --force to reinitialize.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(join(nerveRoot, "data"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "src"), { 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, ".cursor", "rules", "nerve-skills.mdc"), NERVE_SKILLS_MDC);
|
||||
|
||||
if (!skipInstall) {
|
||||
process.stdout.write("Installing dependencies…\n");
|
||||
const { cmd, installArgs } = await detectPackageManager();
|
||||
try {
|
||||
await runCommand(cmd, installArgs, nerveRoot);
|
||||
} catch {
|
||||
process.stdout.write(
|
||||
`⚠️ Install failed. Try manually:\n cd ${nerveRoot} && ${cmd} ${installArgs.join(" ")}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write("Building senses…\n");
|
||||
try {
|
||||
await runCommand("pnpm", ["run", "build"], nerveRoot);
|
||||
} catch {
|
||||
process.stdout.write(`⚠️ Build failed. Try manually:\n cd ${nerveRoot} && pnpm run build\n`);
|
||||
}
|
||||
|
||||
if (!(await verifyNodeSqlite())) {
|
||||
process.stdout.write(
|
||||
"⚠️ Built-in SQLite (node:sqlite) is not available in this Node.js build. " +
|
||||
"The daemon requires Node.js 22.5 or newer with SQLite enabled.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(join(nerveRoot, ".git"))) {
|
||||
try {
|
||||
await runCommand("git", ["init"], nerveRoot);
|
||||
await runCommand("git", ["add", "."], nerveRoot);
|
||||
await runCommand("git", ["commit", "-m", "Initial nerve workspace"], nerveRoot);
|
||||
} catch {
|
||||
process.stdout.write("⚠️ git init failed — skipping.\n");
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
"✅ Workspace created at ~/.uncaged-nerve/\n 1 example sense: cpu-usage\n Run `nerve start` to launch the daemon.\n",
|
||||
);
|
||||
}
|
||||
|
||||
export const initCommand = defineCommand({
|
||||
meta: {
|
||||
name: "init",
|
||||
description:
|
||||
"Initialize workspace (nerve init), clone from git (nerve init --from <url>), or reinit workspace (nerve init workspace)",
|
||||
},
|
||||
args: {
|
||||
force: {
|
||||
type: "boolean",
|
||||
description: "Reinitialize even if workspace already exists (preserves data/)",
|
||||
default: false,
|
||||
},
|
||||
from: {
|
||||
type: "string",
|
||||
description: "Clone an existing git repo into ~/.uncaged-nerve instead of scaffolding",
|
||||
required: false,
|
||||
},
|
||||
"skip-install": {
|
||||
type: "boolean",
|
||||
description: "Skip dependency installation (for testing or offline use)",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
subCommands: {
|
||||
workspace: initWorkspaceCommand,
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.from !== undefined) {
|
||||
await runInitFromGit(String(args.from));
|
||||
return;
|
||||
}
|
||||
await runInitWorkspace(args.force, args["skip-install"]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { KNOWLEDGE_DB } from "../knowledge/paths.js";
|
||||
import { queryKnowledgeGlobal, queryKnowledgeRepo } from "../knowledge/query.js";
|
||||
import { listRegisteredKnowledgeRoots } from "../knowledge/registry.js";
|
||||
import { findKnowledgeRepoRoot } from "../knowledge/repo-root.js";
|
||||
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
export function parseKnowledgeQueryLimit(raw: string | undefined): number {
|
||||
if (raw === undefined || raw.trim().length === 0) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
export async function runKnowledgeQueryGlobal(queryText: string, limit: number): Promise<void> {
|
||||
const roots = listRegisteredKnowledgeRoots();
|
||||
if (roots.length === 0) {
|
||||
process.stderr.write(
|
||||
"❌ No registered repos — run `nerve knowledge sync` in each repo first.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const hits = await queryKnowledgeGlobal(roots, KNOWLEDGE_DB, queryText, limit);
|
||||
if (hits.length === 0) {
|
||||
process.stdout.write("No results.\n");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < hits.length; i++) {
|
||||
const h = hits[i];
|
||||
if (h === undefined) continue;
|
||||
const prefix = h.repoRoot !== null ? `[${h.repoRoot}] ` : "";
|
||||
process.stdout.write(
|
||||
`${String(i + 1)}. score=${h.score.toFixed(4)} ${prefix}${h.path} (${h.slug})\n${h.text}\n---\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runKnowledgeQueryScoped(
|
||||
repoFlag: string | undefined,
|
||||
queryText: string,
|
||||
limit: number,
|
||||
): Promise<void> {
|
||||
let repoRoot: string | null = null;
|
||||
if (repoFlag !== undefined && String(repoFlag).trim().length > 0) {
|
||||
repoRoot = resolve(String(repoFlag).trim());
|
||||
} else {
|
||||
repoRoot = findKnowledgeRepoRoot(process.cwd());
|
||||
}
|
||||
|
||||
if (repoRoot === null) {
|
||||
process.stderr.write("❌ No knowledge.yaml found — use -r <path> or run from a repo root.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dbPath = `${repoRoot}/${KNOWLEDGE_DB}`;
|
||||
if (!existsSync(dbPath)) {
|
||||
process.stderr.write(
|
||||
`❌ No ${KNOWLEDGE_DB} in ${repoRoot} — run \`nerve knowledge sync\` first.\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hits = await queryKnowledgeRepo(repoRoot, dbPath, queryText, limit);
|
||||
if (hits.length === 0) {
|
||||
process.stdout.write("No results.\n");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < hits.length; i++) {
|
||||
const h = hits[i];
|
||||
if (h === undefined) continue;
|
||||
process.stdout.write(
|
||||
`${String(i + 1)}. score=${h.score.toFixed(4)} ${h.path} (${h.slug})\n${h.text}\n---\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { knowledgeQueryScopeConflictMessage } from "../knowledge/query-scope.js";
|
||||
import { findKnowledgeRepoRoot } from "../knowledge/repo-root.js";
|
||||
import { runKnowledgeSync } from "../knowledge/sync.js";
|
||||
import {
|
||||
parseKnowledgeQueryLimit,
|
||||
runKnowledgeQueryGlobal,
|
||||
runKnowledgeQueryScoped,
|
||||
} from "./knowledge-query-run.js";
|
||||
|
||||
const syncCommand = defineCommand({
|
||||
meta: {
|
||||
name: "sync",
|
||||
description: "Chunk matching files from knowledge.yaml and rebuild knowledge.db",
|
||||
},
|
||||
async run() {
|
||||
const repoRoot = findKnowledgeRepoRoot(process.cwd());
|
||||
if (repoRoot === null) {
|
||||
process.stderr.write(
|
||||
"❌ No knowledge.yaml found — run from a repo that contains knowledge.yaml.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const result = await runKnowledgeSync(repoRoot);
|
||||
process.stdout.write(
|
||||
`✅ Indexed ${String(result.filesIndexed)} file(s), ${String(result.chunksWritten)} chunk(s) → ${result.dbPath}\n`,
|
||||
);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ knowledge sync failed: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const queryCommand = defineCommand({
|
||||
meta: {
|
||||
name: "query",
|
||||
description: "Search indexed knowledge (word overlap placeholder until embeddings)",
|
||||
},
|
||||
args: {
|
||||
query: {
|
||||
type: "positional",
|
||||
required: true,
|
||||
description: "Search text",
|
||||
},
|
||||
repo: {
|
||||
type: "string",
|
||||
description: "Use knowledge.db from another repo root (--repo /path)",
|
||||
required: false,
|
||||
},
|
||||
g: {
|
||||
type: "boolean",
|
||||
description: "Search across all repos registered via prior sync",
|
||||
default: false,
|
||||
},
|
||||
limit: {
|
||||
type: "string",
|
||||
description: "Max hits (default 10)",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const conflict = knowledgeQueryScopeConflictMessage(args.repo, args.g);
|
||||
if (conflict !== null) {
|
||||
process.stderr.write(`${conflict}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const queryText = args.query;
|
||||
const limit = parseKnowledgeQueryLimit(args.limit);
|
||||
|
||||
if (args.g) {
|
||||
await runKnowledgeQueryGlobal(queryText, limit);
|
||||
return;
|
||||
}
|
||||
|
||||
await runKnowledgeQueryScoped(args.repo as string, queryText, limit);
|
||||
},
|
||||
});
|
||||
|
||||
export const knowledgeCommand = defineCommand({
|
||||
meta: {
|
||||
name: "knowledge",
|
||||
description: "Project knowledge index (knowledge.yaml + knowledge.db, RFC-003)",
|
||||
},
|
||||
subCommands: {
|
||||
sync: syncCommand,
|
||||
query: queryCommand,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { getLogPath } from "../workspace.js";
|
||||
|
||||
export const DEFAULT_LOG_LINES = 50;
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Read all lines from a file. Returns empty array if file does not exist.
|
||||
*
|
||||
* TODO: For tail mode (offset=0), avoid reading the whole file into memory by
|
||||
* seeking to the last N bytes via createReadStream({ start: max(0, size - CHUNK) }).
|
||||
*/
|
||||
export async function readAllLines(filePath: string): Promise<string[]> {
|
||||
if (!existsSync(filePath)) return [];
|
||||
const lines: string[] = [];
|
||||
const rl = createInterface({
|
||||
input: createReadStream(filePath, { encoding: "utf8" }),
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
for await (const line of rl) {
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a log line array respecting offset + limit semantics.
|
||||
*
|
||||
* When offset is 0 the function returns the *last* `limit` lines (tail mode).
|
||||
* When offset > 0 it is treated as a 1-based line number and the slice starts
|
||||
* there (for pagination of earlier pages from the tail).
|
||||
*
|
||||
* Returns the selected lines plus metadata used to build the footer.
|
||||
*/
|
||||
export type LogSlice = {
|
||||
lines: string[];
|
||||
total: number;
|
||||
startLine: number; // 1-based, inclusive
|
||||
endLine: number; // 1-based, inclusive
|
||||
nextOffset: number | null; // null when no previous page exists
|
||||
};
|
||||
|
||||
export function sliceLogs(allLines: string[], offset: number, limit: number): LogSlice {
|
||||
const total = allLines.length;
|
||||
|
||||
if (total === 0) {
|
||||
return { lines: [], total: 0, startLine: 0, endLine: 0, nextOffset: null };
|
||||
}
|
||||
|
||||
let start: number;
|
||||
if (offset === 0) {
|
||||
// Tail mode: last `limit` lines
|
||||
start = Math.max(0, total - limit);
|
||||
} else {
|
||||
// offset is 1-based line number
|
||||
start = Math.max(0, offset - 1);
|
||||
}
|
||||
|
||||
const end = Math.min(start + limit, total);
|
||||
const lines = allLines.slice(start, end);
|
||||
|
||||
const startLine = start + 1;
|
||||
const endLine = end;
|
||||
|
||||
// nextOffset points to lines *before* current slice (earlier in file)
|
||||
const nextOffset = start > 0 ? Math.max(1, startLine - limit) : null;
|
||||
|
||||
return { lines, total, startLine, endLine, nextOffset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the footer string shown after the log lines.
|
||||
*/
|
||||
export function buildLogFooter(slice: LogSlice, nArg: number, logPath: string): string {
|
||||
if (slice.total === 0) {
|
||||
return "📭 Log file is empty.\n";
|
||||
}
|
||||
|
||||
const rangeStr = `lines ${slice.startLine}-${slice.endLine} of ${slice.total}`;
|
||||
let footer = `\n📄 ${rangeStr} | ${logPath}\n`;
|
||||
|
||||
if (slice.nextOffset !== null) {
|
||||
footer += "⏩ Earlier lines available. Fetch previous page:\n";
|
||||
footer += ` nerve logs --offset ${slice.nextOffset} -n ${nArg}\n`;
|
||||
}
|
||||
|
||||
return footer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve logs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const logsCommand = defineCommand({
|
||||
meta: {
|
||||
name: "logs",
|
||||
description: "Show daemon log output",
|
||||
},
|
||||
args: {
|
||||
n: {
|
||||
type: "string",
|
||||
description: `Number of lines to show (default: ${DEFAULT_LOG_LINES})`,
|
||||
default: String(DEFAULT_LOG_LINES),
|
||||
},
|
||||
offset: {
|
||||
type: "string",
|
||||
description: "Start from line N (1-based, for pagination)",
|
||||
default: "0",
|
||||
},
|
||||
follow: {
|
||||
type: "boolean",
|
||||
alias: "f",
|
||||
description: "Stream new log lines in real time",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const logPath = getLogPath();
|
||||
const nLines = Math.max(1, Number.parseInt(args.n, 10) || DEFAULT_LOG_LINES);
|
||||
const rawOffset = Number.parseInt(args.offset, 10) || 0;
|
||||
|
||||
if (rawOffset < 0) {
|
||||
process.stderr.write(`❌ --offset must be a non-negative integer, got: ${args.offset}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const offset = rawOffset;
|
||||
|
||||
if (!existsSync(logPath)) {
|
||||
process.stderr.write(`❌ Log file not found: ${logPath}\n`);
|
||||
process.stderr.write(" Has the daemon been started? Try: nerve start\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.follow) {
|
||||
await followLog(logPath, nLines);
|
||||
return;
|
||||
}
|
||||
|
||||
const allLines = await readAllLines(logPath);
|
||||
const slice = sliceLogs(allLines, offset, nLines);
|
||||
|
||||
for (const line of slice.lines) {
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
|
||||
process.stdout.write(buildLogFooter(slice, nLines, logPath));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Stream new lines from a log file as they are appended.
|
||||
* Shows the last `tailLines` lines first, then watches for new content.
|
||||
*/
|
||||
async function followLog(logPath: string, tailLines: number): Promise<void> {
|
||||
const allLines = await readAllLines(logPath);
|
||||
const initial = allLines.slice(Math.max(0, allLines.length - tailLines));
|
||||
for (const line of initial) {
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
|
||||
let size = statSync(logPath).size;
|
||||
|
||||
process.stdout.write(`\n👁 Following ${logPath} — press Ctrl+C to stop\n`);
|
||||
|
||||
let stopped = false;
|
||||
process.once("SIGINT", () => {
|
||||
stopped = true;
|
||||
});
|
||||
|
||||
while (!stopped) {
|
||||
await sleep(300);
|
||||
if (stopped) break;
|
||||
try {
|
||||
const newSize = statSync(logPath).size;
|
||||
if (newSize < size) {
|
||||
// Log rotation: file was truncated or replaced, read from the beginning
|
||||
size = 0;
|
||||
}
|
||||
if (newSize <= size) continue;
|
||||
|
||||
const stream = createReadStream(logPath, { start: size, encoding: "utf8" });
|
||||
const rl = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
|
||||
for await (const line of rl) {
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
size = newSize;
|
||||
} catch {
|
||||
stopped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { defineCommand } from "citty";
|
||||
import { loadRemotes, resolveRemote, saveRemotes } from "../remotes.js";
|
||||
|
||||
const remoteAddCommand = defineCommand({
|
||||
meta: { name: "add", description: "Add a named remote" },
|
||||
args: {
|
||||
name: { type: "positional", description: "Remote name" },
|
||||
host: { type: "positional", description: "host:port" },
|
||||
token: { type: "string", description: "API token", default: "" },
|
||||
},
|
||||
run({ args }) {
|
||||
const config = loadRemotes();
|
||||
if (config.remotes[args.name] !== undefined) {
|
||||
process.stderr.write(`Remote "${args.name}" already exists.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
config.remotes[args.name] = {
|
||||
host: args.host,
|
||||
token: args.token.length > 0 ? args.token : null,
|
||||
};
|
||||
saveRemotes(config);
|
||||
process.stdout.write(`Added remote "${args.name}" → ${args.host}\n`);
|
||||
},
|
||||
});
|
||||
|
||||
const remoteListCommand = defineCommand({
|
||||
meta: { name: "list", description: "List all remotes" },
|
||||
run() {
|
||||
const config = loadRemotes();
|
||||
const names = Object.keys(config.remotes);
|
||||
if (names.length === 0) {
|
||||
process.stdout.write("No remotes configured.\n");
|
||||
return;
|
||||
}
|
||||
for (const name of names) {
|
||||
const entry = config.remotes[name];
|
||||
if (entry === undefined) continue;
|
||||
const def = config.default === name ? " (default)" : "";
|
||||
const tok = entry.token !== null ? " token=***" : "";
|
||||
process.stdout.write(`${name}\t${entry.host}${tok}${def}\n`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const remoteShowCommand = defineCommand({
|
||||
meta: { name: "show", description: "Show remote details" },
|
||||
args: {
|
||||
name: { type: "positional", description: "Remote name" },
|
||||
},
|
||||
run({ args }) {
|
||||
const entry = resolveRemote(args.name);
|
||||
if (entry === null) {
|
||||
process.stderr.write(`Remote "${args.name}" not found.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`name: ${args.name}\n`);
|
||||
process.stdout.write(`host: ${entry.host}\n`);
|
||||
process.stdout.write(`token: ${entry.token !== null ? "***" : "(none)"}\n`);
|
||||
},
|
||||
});
|
||||
|
||||
const remoteSetUrlCommand = defineCommand({
|
||||
meta: { name: "set-url", description: "Update remote host" },
|
||||
args: {
|
||||
name: { type: "positional", description: "Remote name" },
|
||||
host: { type: "positional", description: "New host:port" },
|
||||
},
|
||||
run({ args }) {
|
||||
const config = loadRemotes();
|
||||
const entry = config.remotes[args.name];
|
||||
if (entry === undefined) {
|
||||
process.stderr.write(`Remote "${args.name}" not found.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
entry.host = args.host;
|
||||
saveRemotes(config);
|
||||
process.stdout.write(`Updated "${args.name}" → ${args.host}\n`);
|
||||
},
|
||||
});
|
||||
|
||||
const remoteSetTokenCommand = defineCommand({
|
||||
meta: { name: "set-token", description: "Update remote token" },
|
||||
args: {
|
||||
name: { type: "positional", description: "Remote name" },
|
||||
token: { type: "positional", description: "New token" },
|
||||
},
|
||||
run({ args }) {
|
||||
const config = loadRemotes();
|
||||
const entry = config.remotes[args.name];
|
||||
if (entry === undefined) {
|
||||
process.stderr.write(`Remote "${args.name}" not found.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
entry.token = args.token;
|
||||
saveRemotes(config);
|
||||
process.stdout.write(`Updated token for "${args.name}".\n`);
|
||||
},
|
||||
});
|
||||
|
||||
const remoteRemoveCommand = defineCommand({
|
||||
meta: { name: "remove", description: "Remove a remote" },
|
||||
args: {
|
||||
name: { type: "positional", description: "Remote name" },
|
||||
},
|
||||
run({ args }) {
|
||||
const config = loadRemotes();
|
||||
if (config.remotes[args.name] === undefined) {
|
||||
process.stderr.write(`Remote "${args.name}" not found.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
delete config.remotes[args.name];
|
||||
if (config.default === args.name) {
|
||||
config.default = null;
|
||||
}
|
||||
saveRemotes(config);
|
||||
process.stdout.write(`Removed remote "${args.name}".\n`);
|
||||
},
|
||||
});
|
||||
|
||||
const remoteDefaultCommand = defineCommand({
|
||||
meta: { name: "default", description: "Set or show default remote" },
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "Remote name (omit to show current)",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
const config = loadRemotes();
|
||||
if (!args.name || args.name.length === 0) {
|
||||
if (config.default !== null) {
|
||||
process.stdout.write(`${config.default}\n`);
|
||||
} else {
|
||||
process.stdout.write("No default remote set.\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (config.remotes[args.name] === undefined) {
|
||||
process.stderr.write(`Remote "${args.name}" not found.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
config.default = args.name;
|
||||
saveRemotes(config);
|
||||
process.stdout.write(`Default remote set to "${args.name}".\n`);
|
||||
},
|
||||
});
|
||||
|
||||
export const remoteCommand = defineCommand({
|
||||
meta: { name: "remote", description: "Manage named remote connections" },
|
||||
subCommands: {
|
||||
add: remoteAddCommand,
|
||||
list: remoteListCommand,
|
||||
show: remoteShowCommand,
|
||||
"set-url": remoteSetUrlCommand,
|
||||
"set-token": remoteSetTokenCommand,
|
||||
remove: remoteRemoveCommand,
|
||||
default: remoteDefaultCommand,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
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 { getNerveRoot, isRunning } from "../workspace.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers (exported for tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatDuration(ms: number | null): string {
|
||||
if (ms === null) return "—";
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes < 60) return `${minutes}m ${seconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
export function formatSenseList(senses: SenseInfo[]): string {
|
||||
if (senses.length === 0) {
|
||||
return "📭 No senses registered in nerve.yaml.\n";
|
||||
}
|
||||
|
||||
const lines: string[] = [`📡 Registered senses (${senses.length}):\n`];
|
||||
for (const s of senses) {
|
||||
lines.push(`\n ${s.name}\n`);
|
||||
lines.push(` group: ${s.group}\n`);
|
||||
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
|
||||
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
|
||||
lines.push(
|
||||
` trigger schedule: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`,
|
||||
);
|
||||
}
|
||||
return lines.join("");
|
||||
}
|
||||
|
||||
/** Build a SenseInfo list from nerve.yaml when daemon is not running. */
|
||||
export function sensesFromConfig(configPath: string): SenseInfo[] {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, "utf8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const result = parseNerveConfig(raw);
|
||||
if (!result.ok) return [];
|
||||
const { senses } = result.value;
|
||||
return Object.entries(senses).map(([name, cfg]) => ({
|
||||
name,
|
||||
group: cfg.group,
|
||||
throttle: cfg.throttle,
|
||||
timeout: cfg.timeout,
|
||||
triggers: senseTriggerLabels(name, senses),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const senseListCommand = defineCommand({
|
||||
meta: {
|
||||
name: "list",
|
||||
description: "List all registered senses and their status",
|
||||
},
|
||||
async run() {
|
||||
if (!isRemoteDaemonCli() && !isRunning()) {
|
||||
process.stderr.write(
|
||||
"⚠️ Daemon is not running — showing static config from nerve.yaml only.\n\n",
|
||||
);
|
||||
const configPath = join(getNerveRoot(), "nerve.yaml");
|
||||
const senses = sensesFromConfig(configPath);
|
||||
process.stdout.write(formatSenseList(senses));
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = resolveDaemonTransport();
|
||||
let senses: SenseInfo[];
|
||||
try {
|
||||
senses = await transport.listSenses();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(formatSenseList(senses));
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense trigger <name>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const senseTriggerCommand = defineCommand({
|
||||
meta: {
|
||||
name: "trigger",
|
||||
description: "Manually trigger a sense compute by sending an IPC message to the running daemon",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "The sense name to trigger",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
if (!isRemoteDaemonCli() && !isRunning()) {
|
||||
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const transport = resolveDaemonTransport();
|
||||
let response: { ok: true } | { ok: false; error: string };
|
||||
try {
|
||||
response = await transport.triggerSense(args.name);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.stderr.write(`❌ Daemon rejected trigger: ${response.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`✅ Triggered sense "${args.name}" via daemon.\n`);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense (parent command)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const senseCommand = defineCommand({
|
||||
meta: {
|
||||
name: "sense",
|
||||
description: "Interact with sense computes",
|
||||
},
|
||||
subCommands: {
|
||||
list: senseListCommand,
|
||||
trigger: senseTriggerCommand,
|
||||
},
|
||||
});
|
||||
@@ -1,109 +1,102 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createWriteStream, existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import { createKernel } from "@uncaged/nerve-daemon";
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { getLogPath, getNerveRoot, isRunning, readPidFile, writePidFile } from "../workspace.js";
|
||||
import {
|
||||
getLogPath,
|
||||
getNerveRoot,
|
||||
getSocketPath,
|
||||
isRunning,
|
||||
readPidFile,
|
||||
removePidFile,
|
||||
writePidFile,
|
||||
} from "../workspace.js";
|
||||
|
||||
function readConfig(nerveRoot: string): ReturnType<typeof parseNerveConfig> {
|
||||
const configPath = join(nerveRoot, "nerve.yaml");
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, "utf8");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: new Error(`❌ Cannot read ${configPath}: ${msg}`) };
|
||||
}
|
||||
return parseNerveConfig(raw);
|
||||
function waitForSocket(socketPath: string, timeoutMs = 5000, intervalMs = 200): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const check = (): void => {
|
||||
if (existsSync(socketPath)) {
|
||||
resolve(true);
|
||||
} else if (Date.now() >= deadline) {
|
||||
resolve(false);
|
||||
} else {
|
||||
setTimeout(check, intervalMs);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function runForeground(nerveRoot: string): Promise<void> {
|
||||
const configResult = readConfig(nerveRoot);
|
||||
if (!configResult.ok) {
|
||||
process.stderr.write(`${configResult.error.message}\n`);
|
||||
process.exit(1);
|
||||
/** Path to the CLI entry script (used to locate dist/ next to bundled assets). */
|
||||
function cliEntryScript(): string {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const ext = here.endsWith(".ts") ? ".ts" : ".js";
|
||||
const candidates = [join(dirname(here), `cli${ext}`), join(dirname(here), "..", `cli${ext}`)];
|
||||
const cliPath = candidates.find((p) => existsSync(p));
|
||||
if (!cliPath) {
|
||||
throw new Error(`CLI entry not found (searched: ${candidates.join(", ")})`);
|
||||
}
|
||||
return cliPath;
|
||||
}
|
||||
|
||||
const config = configResult.value;
|
||||
const kernel = createKernel(config, nerveRoot);
|
||||
|
||||
const senseNames = Object.keys(config.senses);
|
||||
const groups = [...kernel.groups];
|
||||
|
||||
process.stdout.write(
|
||||
`✅ Nerve starting — ${senseNames.length} sense(s), ${groups.length} group(s)\n`,
|
||||
function daemonBootstrapScript(): string {
|
||||
const cliPath = cliEntryScript();
|
||||
const dir = dirname(cliPath);
|
||||
const bootstrapJs = join(dir, "daemon-bootstrap.js");
|
||||
if (existsSync(bootstrapJs)) {
|
||||
return bootstrapJs;
|
||||
}
|
||||
throw new Error(
|
||||
`daemon-bootstrap.js not found next to CLI at ${bootstrapJs}. Build the CLI package (e.g. \`pnpm --filter @uncaged/nerve-cli build\`) before using \`nerve daemon start\`.`,
|
||||
);
|
||||
for (const group of groups) {
|
||||
const groupSenses = Object.entries(config.senses)
|
||||
.filter(([, sc]) => sc.group === group)
|
||||
.map(([name]) => name);
|
||||
process.stdout.write(` group "${group}": ${groupSenses.join(", ")}\n`);
|
||||
}
|
||||
process.stdout.write(" Press Ctrl+C to stop.\n");
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
process.stdout.write("\n[nerve] Shutting down…\n");
|
||||
await kernel.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
shutdown().catch((e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
shutdown().catch((e: unknown) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`[nerve] Shutdown error: ${msg}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
await kernel.ready;
|
||||
}
|
||||
|
||||
async function runDaemon(nerveRoot: string): Promise<void> {
|
||||
async function runDaemon(nerveRoot: string, cliHttpPort: number | null): Promise<void> {
|
||||
if (isRunning()) {
|
||||
const pid = readPidFile();
|
||||
process.stderr.write(`⚠️ Nerve daemon is already running (pid ${pid}).\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configResult = readConfig(nerveRoot);
|
||||
if (!configResult.ok) {
|
||||
process.stderr.write(`${configResult.error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const logPath = getLogPath();
|
||||
await mkdir(join(nerveRoot, "logs"), { recursive: true });
|
||||
|
||||
const { spawn } = await import("node:child_process");
|
||||
const logStream = createWriteStream(logPath, { flags: "a" });
|
||||
await new Promise<void>((resolve) => {
|
||||
if (logStream.pending) logStream.once("open", () => resolve());
|
||||
else resolve();
|
||||
});
|
||||
|
||||
const selfPath = fileURLToPath(import.meta.url);
|
||||
const bootstrapPath = daemonBootstrapScript();
|
||||
|
||||
const child = spawn(process.execPath, [selfPath, "start"], {
|
||||
const configPath = join(nerveRoot, "nerve.yaml");
|
||||
let yamlApiPort: number | null = null;
|
||||
try {
|
||||
const raw = readFileSync(configPath, "utf8");
|
||||
const parsed = parseNerveConfig(raw);
|
||||
if (parsed.ok) yamlApiPort = parsed.value.api.port;
|
||||
} catch {
|
||||
// kernel bootstrap will surface a clearer error if config is missing
|
||||
}
|
||||
const resolvedHttpPort = cliHttpPort ?? yamlApiPort;
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, NERVE_ROOT: nerveRoot };
|
||||
if (resolvedHttpPort !== null && resolvedHttpPort > 0) {
|
||||
env.NERVE_API_PORT = String(resolvedHttpPort);
|
||||
}
|
||||
|
||||
// After `open`, file-backed WriteStream has a numeric OS fd for spawn stdio; `@types/node` omits `fd` on this WriteStream alias.
|
||||
const logFd = (logStream as unknown as { fd: number }).fd;
|
||||
const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", bootstrapPath], {
|
||||
detached: true,
|
||||
stdio: ["ignore", logStream.fd, logStream.fd],
|
||||
env: { ...process.env, NERVE_DAEMON_MODE: "1" },
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
env,
|
||||
cwd: nerveRoot,
|
||||
});
|
||||
|
||||
child.unref();
|
||||
@@ -115,31 +108,49 @@ async function runDaemon(nerveRoot: string): Promise<void> {
|
||||
}
|
||||
|
||||
writePidFile(pid);
|
||||
|
||||
const ready = await waitForSocket(getSocketPath(), 5000);
|
||||
|
||||
if (!ready || !isRunning()) {
|
||||
removePidFile();
|
||||
process.stderr.write(
|
||||
`❌ Daemon process exited shortly after start. Check logs at:\n ${logPath}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`✅ Nerve daemon started (pid ${pid}).\n`);
|
||||
process.stdout.write(` Logs: ${logPath}\n`);
|
||||
process.stdout.write(" Run `nerve stop` to stop.\n");
|
||||
process.stdout.write(" Run `nerve daemon stop` (or `nerve stop`) to stop.\n");
|
||||
}
|
||||
|
||||
export const startCommand = defineCommand({
|
||||
/** Background daemon only — use `nerve dev` for foreground mode. */
|
||||
export async function runDaemonStartCommand(cliHttpPort: number | null = null): Promise<void> {
|
||||
await runDaemon(getNerveRoot(), cliHttpPort);
|
||||
}
|
||||
|
||||
export const daemonStartCommand = defineCommand({
|
||||
meta: {
|
||||
name: "start",
|
||||
description: "Start the nerve daemon",
|
||||
description: "Start the nerve daemon in the background",
|
||||
},
|
||||
args: {
|
||||
daemon: {
|
||||
type: "boolean",
|
||||
alias: "d",
|
||||
description: "Run as background daemon",
|
||||
default: false,
|
||||
port: {
|
||||
type: "string",
|
||||
description: "HTTP API port (overrides nerve.yaml api.port). Omit to use YAML / env only.",
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
|
||||
if (args.daemon) {
|
||||
await runDaemon(nerveRoot);
|
||||
} else {
|
||||
await runForeground(nerveRoot);
|
||||
let cliHttpPort: number | null = null;
|
||||
if (args.port.length > 0) {
|
||||
const n = Number.parseInt(args.port, 10);
|
||||
if (Number.isNaN(n) || n < 1 || n > 65_535) {
|
||||
process.stderr.write(`❌ Invalid --port: ${args.port}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
cliHttpPort = n;
|
||||
}
|
||||
await runDaemonStartCommand(cliHttpPort);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { join } from "node:path";
|
||||
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { isRemoteDaemonCli } from "../cli-global.js";
|
||||
import { resolveDaemonTransport } from "../daemon-client.js";
|
||||
import { getNerveRoot, getPidPath, isRunning, readPidFile } from "../workspace.js";
|
||||
|
||||
function formatUptime(ms: number): string {
|
||||
@@ -42,12 +44,33 @@ export const statusCommand = defineCommand({
|
||||
description: "Show nerve daemon status",
|
||||
},
|
||||
async run() {
|
||||
if (isRemoteDaemonCli()) {
|
||||
const transport = resolveDaemonTransport();
|
||||
try {
|
||||
const health = await transport.health();
|
||||
process.stdout.write("✅ Nerve daemon is reachable (remote HTTP).\n");
|
||||
process.stdout.write(` hostname: ${health.hostname}\n`);
|
||||
process.stdout.write(` version: ${health.version}\n`);
|
||||
process.stdout.write(` uptime: ${formatUptime(health.uptime * 1000)}\n`);
|
||||
process.stdout.write(` started: ${health.startedAt}\n`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ Cannot reach remote daemon: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRunning()) {
|
||||
process.stdout.write("😴 Nerve daemon is not running.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = readPidFile() as number;
|
||||
const pid = readPidFile();
|
||||
if (pid === null) {
|
||||
process.stdout.write("😴 Nerve daemon is not running.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = join(getNerveRoot(), "nerve.yaml");
|
||||
let senseList: string[] = [];
|
||||
@@ -74,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");
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user