Compare commits
231 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 145a747433 | |||
| 45f5dbe89e | |||
| dfb3c9ec18 | |||
| 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 | |||
| b15fc993f2 | |||
| fc76b862ad | |||
| 96188c8cda | |||
| f1458f8353 |
@@ -0,0 +1,189 @@
|
||||
---
|
||||
description: Nerve project coding conventions — style, patterns, and toolchain
|
||||
globs: packages/*/src/**/*.ts
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Nerve Coding Conventions
|
||||
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Reflex → 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 `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
|
||||
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
|
||||
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
|
||||
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
|
||||
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. |
|
||||
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
|
||||
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
|
||||
|
||||
### Architecture Rules
|
||||
|
||||
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
|
||||
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
|
||||
- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain.
|
||||
|
||||
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
### Functional-first
|
||||
|
||||
Use `function` + `type`, not `class` + `interface`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type Signal = {
|
||||
senseId: string;
|
||||
value: unknown;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function createSignal(senseId: string, value: unknown): Signal {
|
||||
return { senseId, value, ts: Date.now() };
|
||||
}
|
||||
|
||||
// ❌ Bad — no class, no interface
|
||||
class Signal implements ISignal { ... }
|
||||
```
|
||||
|
||||
### 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 ReflexConfig =
|
||||
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
|
||||
| { kind: "workflow"; workflow: string; on: string[] | null };
|
||||
```
|
||||
|
||||
## 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 | `signal-bus.ts` |
|
||||
| Types | PascalCase | `SignalBus` |
|
||||
| Functions/variables | camelCase | `createSignalBus` |
|
||||
| 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,180 @@
|
||||
# Nerve Coding Conventions
|
||||
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Reflex → 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 `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
|
||||
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
|
||||
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
|
||||
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
|
||||
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. |
|
||||
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
|
||||
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
|
||||
|
||||
### Architecture Rules
|
||||
|
||||
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
|
||||
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
|
||||
- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain.
|
||||
|
||||
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
### Functional-first
|
||||
|
||||
Use `function` + `type`, not `class` + `interface`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type Signal = {
|
||||
senseId: string;
|
||||
value: unknown;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function createSignal(senseId: string, value: unknown): Signal {
|
||||
return { senseId, value, ts: Date.now() };
|
||||
}
|
||||
|
||||
// ❌ Bad — no class, no interface
|
||||
class Signal implements ISignal { ... }
|
||||
```
|
||||
|
||||
### 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 ReflexConfig =
|
||||
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
|
||||
| { kind: "workflow"; workflow: string; on: string[] | null };
|
||||
```
|
||||
|
||||
## Modules & Exports
|
||||
|
||||
- Always named exports, never default exports
|
||||
- One module = one responsibility, filename = purpose
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Style | Example |
|
||||
|------|-------|---------|
|
||||
| Files | kebab-case | `signal-bus.ts` |
|
||||
| Types | PascalCase | `SignalBus` |
|
||||
| Functions/variables | camelCase | `createSignalBus` |
|
||||
| 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,4 @@ node_modules
|
||||
dist
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
pnpm check
|
||||
pnpm -r test
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Adapters (RFC-003)
|
||||
|
||||
Adapter = capability. Role = scenario. Workflows declare adapters directly via import.
|
||||
|
||||
## AgentFn Protocol
|
||||
|
||||
```ts
|
||||
type AgentFn = (prompt: string, context: WorkflowContext) => Promise<string>
|
||||
```
|
||||
|
||||
- Input: prompt + context (start frame, messages, workdir, AbortSignal)
|
||||
- Output: raw string — structured extraction is separate
|
||||
- Adapter handles tool-specific details internally
|
||||
|
||||
## Available Adapters
|
||||
|
||||
| Package | Adapter | Tool |
|
||||
|---------|---------|------|
|
||||
| `@uncaged/nerve-adapter-cursor` | `cursorAdapter` / `createCursorAdapter()` | cursor-agent CLI |
|
||||
| `@uncaged/nerve-adapter-hermes` | `hermesAdapter` / `createHermesAdapter()` | hermes chat CLI |
|
||||
|
||||
Each exports a **default instance** (sensible defaults) and a **factory** for custom config.
|
||||
|
||||
## 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`.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Nerve Architecture
|
||||
|
||||
Observation engine for autonomous agents — sense the world, react to changes, run workflows.
|
||||
|
||||
## Core Pipeline
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Reflex → Workflow → Log
|
||||
```
|
||||
|
||||
Causality is **one-directional**. Logs are the end of the chain — they cannot trigger Reflexes (prevents feedback loops).
|
||||
|
||||
## Three Orthogonal Extension Points
|
||||
|
||||
| Extension | Question | Nature |
|
||||
|-----------|----------|--------|
|
||||
| **Sense** | What to compute | `compute()` function |
|
||||
| **Reflex** | When to compute | Declarative YAML (interval / on) |
|
||||
| **Workflow** | What to do | Roles + Moderator |
|
||||
|
||||
Each is independent. Reflex doesn't know compute internals, Sense doesn't know when it's triggered, Workflow doesn't know why it was started.
|
||||
|
||||
## Two Event Types
|
||||
|
||||
- **Signal** — from Sense compute (non-null return). Pure fact, no intent. Drives the front half (perception).
|
||||
- **Command Event** — inside Workflow Threads. Has causal chain, must be responded to. Drives the back half (action).
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,118 @@
|
||||
# Nerve CLI
|
||||
|
||||
`nerve` — CLI entry point for nerve workspace management.
|
||||
|
||||
## Workspace Lifecycle
|
||||
|
||||
```bash
|
||||
nerve init # scaffold workspace (nerve.yaml, senses/, workflows/)
|
||||
nerve init --from <git-url> # clone existing workspace from git
|
||||
nerve init --force # reinitialize (preserves data/)
|
||||
nerve validate # validate nerve.yaml config
|
||||
nerve dev # run kernel foreground (development, Ctrl+C to stop)
|
||||
nerve dev --port 3000 # with HTTP API on specific port
|
||||
nerve start # start daemon (background)
|
||||
nerve start --port 3000 # with HTTP API port
|
||||
nerve stop # stop daemon
|
||||
nerve status # check daemon health (uptime, senses, workflows)
|
||||
nerve daemon restart # stop + start
|
||||
nerve daemon logs # alias for nerve logs
|
||||
```
|
||||
|
||||
## Sense Management
|
||||
|
||||
```bash
|
||||
nerve create sense <name> # scaffold a new sense (compute.ts + schema.ts)
|
||||
nerve create sense <name> --force # overwrite existing
|
||||
nerve sense list # list configured senses and status
|
||||
nerve sense trigger <name> # manually trigger a sense compute
|
||||
nerve sense schema <name> # print CREATE TABLE statements from sense SQLite
|
||||
nerve sense schema <name> --json # as JSON array
|
||||
nerve sense query <name> # inspect sense SQLite database (preview rows)
|
||||
nerve sense query <name> --json # rows as JSON
|
||||
```
|
||||
|
||||
## Workflow Management
|
||||
|
||||
```bash
|
||||
nerve create workflow <name> # scaffold a new workflow
|
||||
nerve create workflow <name> --force # overwrite existing
|
||||
nerve workflow list # list workflow definitions from nerve.yaml
|
||||
nerve workflow status # show live status (concurrency, active, queued)
|
||||
nerve workflow trigger <name> --prompt "..." [--max-rounds N] [--dry-run]
|
||||
```
|
||||
|
||||
## Thread Management
|
||||
|
||||
```bash
|
||||
nerve thread list # list active (queued/started) workflow runs
|
||||
nerve thread list --all # include completed/failed/crashed
|
||||
nerve thread list --workflow <name> # filter by workflow name
|
||||
nerve thread show <runId> # print role rounds for a run (agent-oriented)
|
||||
nerve thread show <runId> --before N # limit rounds (pagination)
|
||||
nerve thread inspect <runId> # show details and thread events
|
||||
nerve thread inspect <runId> --offset N --limit N # paginate events
|
||||
nerve thread kill <runId> # kill a running or queued thread
|
||||
```
|
||||
|
||||
## 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
|
||||
nerve knowledge query "text" --limit 20 # max hits (default 10)
|
||||
```
|
||||
|
||||
## Logs & Store
|
||||
|
||||
```bash
|
||||
nerve logs # show daemon log output (last 50 lines)
|
||||
nerve logs -n 200 # last N lines
|
||||
nerve logs --offset 100 # start from line N (pagination)
|
||||
nerve logs -f # follow logs (tail -f style)
|
||||
nerve store archive # archive logs older than 30 days to JSONL
|
||||
nerve store archive --vacuum # also run SQLite VACUUM after archiving
|
||||
```
|
||||
|
||||
## Remote Management
|
||||
|
||||
```bash
|
||||
nerve remote add <name> <host:port> [--token <token>] # add remote daemon
|
||||
nerve remote list # list all remotes
|
||||
nerve remote show <name> # show remote details
|
||||
nerve remote set-url <name> <host:port> # update remote host
|
||||
nerve remote set-token <name> <token> # update remote token
|
||||
nerve remote remove <name> # remove a remote
|
||||
nerve remote default [<name>] # set or show default remote
|
||||
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 # sense compute implementation
|
||||
schema.ts # Drizzle schema (single source of truth)
|
||||
migrations/ # auto-generated by drizzle-kit
|
||||
package.json # with esbuild build script
|
||||
index.js # bundled output (generated by pnpm build)
|
||||
workflows/
|
||||
cleanup/
|
||||
src/index.ts # workflow definition
|
||||
build.ts # factory function
|
||||
moderator.ts # moderator + meta types
|
||||
roles/ # one file per role
|
||||
package.json
|
||||
data/
|
||||
senses/ # per-sense SQLite databases
|
||||
archive/ # archived logs (JSONL)
|
||||
knowledge.db # generated by nerve knowledge sync
|
||||
.knowledge/ # curated knowledge cards
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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
|
||||
|
||||
## 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
|
||||
|
||||
## Async
|
||||
|
||||
- Always `async/await`, never `.then()` chains
|
||||
|
||||
## 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,38 @@
|
||||
# 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
|
||||
|
||||
- Remote service: configured via `EMBED_SERVICE_URL` env var (self-hosted Cloudflare Worker + KV cache)
|
||||
- Model: Dashscope text-embedding-v3 (1024 dims)
|
||||
- Cache: content-addressable (sha256 of model+text), never expires
|
||||
- Fallback: word-overlap scoring when embed service not configured
|
||||
|
||||
## 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, signal bus, scheduler
|
||||
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,29 @@
|
||||
# Sense
|
||||
|
||||
A `compute()` function that samples or derives external data. The only first-class citizen in nerve.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Returns `T | null` — non-null emits a Signal, null is silent (no storage write, no signal, no downstream trigger)
|
||||
- Each Sense has its own **independent SQLite database**
|
||||
- Cross-sense reads are read-only via `peers` parameter
|
||||
- Schema defined with Drizzle ORM (`schema.ts` is single source of truth)
|
||||
|
||||
## Sense → Workflow
|
||||
|
||||
If `compute()` returns an object with `workflow: "name|maxRounds|prompt"`, the engine starts that workflow and does **not** emit a Signal. `workflow: null` or `""` means emit signal normally.
|
||||
|
||||
See `routeSenseComputeOutput` / `parseSenseWorkflowDirective` in `@uncaged/nerve-core`.
|
||||
|
||||
## 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 on signals from other senses (optional)
|
||||
```
|
||||
@@ -0,0 +1,59 @@
|
||||
# Workflow Engine
|
||||
|
||||
Stateful multi-step execution driven by Roles and a Moderator.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **Workflow** — definition with concurrency strategy
|
||||
- **Thread** — one execution instance, unique `runId`
|
||||
- **Role** — executes actions (has side effects). `(start, messages) → { content, meta }`
|
||||
- **Moderator** — pure routing function. `(context) → 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 | ((start, messages) => Promise<string>)` — static or dynamic
|
||||
- `meta: z.ZodType<M>` — Zod schema, directly (no wrapper needed)
|
||||
- `extract: LlmExtractorConfig` — provider for structured extraction
|
||||
@@ -0,0 +1,180 @@
|
||||
# Nerve Coding Conventions
|
||||
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense → Signal → Reflex → 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 `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. |
|
||||
| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. |
|
||||
| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. |
|
||||
| **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. |
|
||||
| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. |
|
||||
| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. |
|
||||
| **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. |
|
||||
|
||||
### Architecture Rules
|
||||
|
||||
- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do)
|
||||
- **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other.
|
||||
- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain.
|
||||
|
||||
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
### Functional-first
|
||||
|
||||
Use `function` + `type`, not `class` + `interface`.
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type Signal = {
|
||||
senseId: string;
|
||||
value: unknown;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function createSignal(senseId: string, value: unknown): Signal {
|
||||
return { senseId, value, ts: Date.now() };
|
||||
}
|
||||
|
||||
// ❌ Bad — no class, no interface
|
||||
class Signal implements ISignal { ... }
|
||||
```
|
||||
|
||||
### 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 ReflexConfig =
|
||||
| { kind: "sense"; sense: string; interval: string | null; on: string[] | null }
|
||||
| { kind: "workflow"; workflow: string; on: string[] | null };
|
||||
```
|
||||
|
||||
## Modules & Exports
|
||||
|
||||
- Always named exports, never default exports
|
||||
- One module = one responsibility, filename = purpose
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Style | Example |
|
||||
|------|-------|---------|
|
||||
| Files | kebab-case | `signal-bus.ts` |
|
||||
| Types | PascalCase | `SignalBus` |
|
||||
| Functions/variables | camelCase | `createSignalBus` |
|
||||
| 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,189 @@
|
||||
# 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**, reacts via declarative **Reflexes**, and orchestrates multi-step **Workflows**. Built for the [Uncaged](https://github.com/uncaged) agent framework.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
```
|
||||
External World → Sense ─┬→ Signal → Reflex → Sense (scheduled compute)
|
||||
│
|
||||
└→ Workflow (Sense return with workflow directive) → Log
|
||||
```
|
||||
|
||||
| Concept | Metaphor | Role |
|
||||
|---------|----------|------|
|
||||
| **Sense** | 👁️ Perception | A `compute()` function that samples or derives data. Each sense has its own SQLite database. |
|
||||
| **Reflex** | ⚡ Reaction | Declarative rules that **only schedule Sense computes** (interval and/or `on` signal names). Reflex YAML cannot reference workflows. |
|
||||
| **Signal** | 📡 Notification | Emitted when a sense returns a non-null value that is routed as a normal signal (see Sense → Workflow below). Other reflexes can listen via `on`. |
|
||||
| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started from a Sense return value or from CLI/daemon IPC—not from reflex YAML. |
|
||||
| **Log** | 📝 Record | Immutable audit trail. Queryable by senses, but **cannot** trigger reflexes (prevents feedback loops). |
|
||||
|
||||
**Sense → Workflow:** if `compute()` returns a plain object with a string field `workflow` in the form `name|maxRounds|prompt` (only the first two `|` delimit name and rounds; the rest is the prompt), the engine starts that workflow and **does not** emit a Signal for that return. `workflow: null` or `""` means “emit a signal” and strip the key from the payload. Invalid `workflow` strings are treated like a normal signal (directive stripped). See `@uncaged/nerve-core` `routeSenseComputeOutput` / `parseSenseWorkflowDirective`.
|
||||
|
||||
Three extension points for **what / when / multi-step action** — reflexes never replace Sense-driven workflow launches.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, Sense→workflow routing, daemon IPC protocol |
|
||||
| [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows |
|
||||
| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, workers, signal bus, sense scheduler, workflow manager, file watcher, IPC |
|
||||
| [`@uncaged/nerve-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
|
||||
cat > senses/cpu-usage/compute.ts << 'EOF'
|
||||
export async function compute() {
|
||||
const [load] = (await import("node:os")).loadavg();
|
||||
return load > 2.0 ? { load } : null; // signal only when load is high
|
||||
}
|
||||
EOF
|
||||
|
||||
# Configure reflexes in nerve.yaml
|
||||
cat > nerve.yaml << 'EOF'
|
||||
senses:
|
||||
cpu-usage:
|
||||
group: system
|
||||
throttle: 10s
|
||||
|
||||
reflexes:
|
||||
- kind: sense
|
||||
sense: cpu-usage
|
||||
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, reflexes (sense-only), optional workflows (concurrency), and optional engine `max_rounds`:
|
||||
|
||||
```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
|
||||
|
||||
reflexes:
|
||||
- kind: sense
|
||||
sense: cpu-usage
|
||||
interval: 30s # periodic trigger
|
||||
on: [disk-pressure] # also trigger on signals from other senses
|
||||
|
||||
workflows:
|
||||
cleanup:
|
||||
concurrency: 1
|
||||
overflow: drop # discard if already running
|
||||
code-review:
|
||||
concurrency: 3
|
||||
overflow: queue
|
||||
max_queue: 20
|
||||
```
|
||||
|
||||
YAML must **not** include `workflow:` under `reflexes` — the parser rejects it. Declare workflows under `workflows:` and start them from Sense `compute()` or `nerve workflow trigger`.
|
||||
|
||||
**Example — Sense starts a workflow** (`senses/disk-pressure/compute.ts`):
|
||||
|
||||
```typescript
|
||||
export async function compute() {
|
||||
const full = await diskNearlyFull();
|
||||
if (!full) return null;
|
||||
return {
|
||||
path: "/data",
|
||||
workflow: "cleanup|10|Disk partition nearly full", // name|maxRounds|prompt
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 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 │ │ │ │
|
||||
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ │ └──────────────┼──────────────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────┐ │
|
||||
│ │ │ Signal Bus │ │
|
||||
│ │ └──────┬───────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────────┐ │
|
||||
│ │ │ Reflex Scheduler│ │
|
||||
│ │ └────────┬─────────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌───────────────────┐ │
|
||||
│ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │
|
||||
│ └───────────────────┘ (logs.db, …) │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Worker pool** — one child process per sense group; isolation between groups.
|
||||
- **Signal Bus** — in-memory pub/sub for signal distribution.
|
||||
- **Reflex Scheduler** — interval timers + signal subscriptions, with throttle/coalesce.
|
||||
- **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)
|
||||
- **Drizzle ORM** v1.0 for sense databases
|
||||
- **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) — Sense, Signal, Reflex model
|
||||
- [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": {
|
||||
|
||||
@@ -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`
|
||||
@@ -8,10 +8,9 @@ import { samples } from "./schema.js";
|
||||
* 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.
|
||||
* On every successful read a row is inserted and a Signal is emitted with the load value.
|
||||
*/
|
||||
export async function compute(db: DrizzleDB, _peers: PeerMap): Promise<number | null> {
|
||||
export async function compute(db: DrizzleDB, _peers: PeerMap) {
|
||||
const [oneMin] = loadavg();
|
||||
|
||||
if (typeof oneMin !== "number" || Number.isNaN(oneMin)) {
|
||||
@@ -19,5 +18,5 @@ export async function compute(db: DrizzleDB, _peers: PeerMap): Promise<number |
|
||||
}
|
||||
|
||||
await db.insert(samples).values({ ts: Date.now(), value: oneMin });
|
||||
return oneMin;
|
||||
return { signal: oneMin, workflow: null };
|
||||
}
|
||||
|
||||
+2
-12
@@ -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
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
* group: internal
|
||||
* throttle: 30s
|
||||
* timeout: 5s
|
||||
*
|
||||
* reflexes:
|
||||
* - sense: nerve-health
|
||||
* interval: 30s
|
||||
*/
|
||||
|
||||
@@ -25,9 +22,9 @@ export type NerveHealth = {
|
||||
workerUptime: number;
|
||||
};
|
||||
|
||||
export async function compute(): Promise<NerveHealth | null> {
|
||||
export async function compute() {
|
||||
const health = await requestHealthFromKernel();
|
||||
return health;
|
||||
return { signal: health, workflow: null };
|
||||
}
|
||||
|
||||
function requestHealthFromKernel(): Promise<NerveHealth> {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
include:
|
||||
- ".knowledge/**/*.md"
|
||||
|
||||
exclude: []
|
||||
+8
-2
@@ -1,14 +1,20 @@
|
||||
{
|
||||
"name": "nerve",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
"build": "pnpm -r run build",
|
||||
"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,116 @@
|
||||
import type { AgentConfig, AgentFn, WorkflowContext } 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;
|
||||
|
||||
/**
|
||||
* Builds a Cursor CLI `AgentFn` from adapter config (model, timeout).
|
||||
*/
|
||||
export function createCursorAdapter(config: AgentConfig): AgentFn {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (prompt: string, context: WorkflowContext): Promise<string> => {
|
||||
const run = await cursorAgent({
|
||||
prompt,
|
||||
mode: "default",
|
||||
model: config.model,
|
||||
cwd: context.workdir,
|
||||
env: null,
|
||||
timeoutMs,
|
||||
dryRun: context.start.meta.dryRun,
|
||||
abortSignal: context.signal,
|
||||
});
|
||||
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, WorkflowContext } 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 (prompt: string, context: WorkflowContext): Promise<string> => {
|
||||
const run = await hermesAgent({
|
||||
prompt,
|
||||
model: modelFromConfig,
|
||||
provider: null,
|
||||
skills: [],
|
||||
quiet: true,
|
||||
maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS,
|
||||
env: null,
|
||||
timeoutMs,
|
||||
dryRun: context.start.meta.dryRun,
|
||||
abortSignal: context.signal,
|
||||
});
|
||||
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
|
||||
nerve sense query <name> # Read-only SQL on data/senses/<name>.db (optional SQL args)
|
||||
nerve sense schema <name> # Print CREATE TABLE statements for that sense DB
|
||||
```
|
||||
|
||||
### 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,31 +1,35 @@
|
||||
{
|
||||
"name": "@uncaged/nerve-cli",
|
||||
"version": "0.1.7",
|
||||
"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"
|
||||
],
|
||||
"files": ["dist"],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
|
||||
"build": "tsup",
|
||||
"build": "rslib build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/nerve-core": "workspace:*",
|
||||
"citty": "^0.1.6"
|
||||
"@uncaged/nerve-store": "workspace:*",
|
||||
"citty": "^0.1.6",
|
||||
"picomatch": "^4.0.2",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@uncaged/nerve-daemon": "workspace:*",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@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,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,73 @@
|
||||
/**
|
||||
* Tests for nerve create sense template helpers.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildSenseIndexTs,
|
||||
buildSenseMigrationSql,
|
||||
buildSensePackageJson,
|
||||
buildSenseSchemaTs,
|
||||
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("buildSenseSchemaTs", () => {
|
||||
it("maps kebab-case id to snake table and camel export", () => {
|
||||
const src = buildSenseSchemaTs("my-sense");
|
||||
expect(src).toContain('sqliteTable("my_sense"');
|
||||
expect(src).toContain("export const mySense = ");
|
||||
});
|
||||
|
||||
it("handles single-segment id", () => {
|
||||
const src = buildSenseSchemaTs("metrics");
|
||||
expect(src).toContain('sqliteTable("metrics"');
|
||||
expect(src).toContain("export const metrics = ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSenseMigrationSql", () => {
|
||||
it("uses snake_case table name", () => {
|
||||
expect(buildSenseMigrationSql("disk-io")).toContain("CREATE TABLE IF NOT EXISTS disk_io");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSensePackageJson", () => {
|
||||
it("includes esbuild script and sense name", () => {
|
||||
const pkg = JSON.parse(buildSensePackageJson("my-sense"));
|
||||
expect(pkg.name).toBe("nerve-sense-my-sense");
|
||||
expect(pkg.scripts.build).toContain("esbuild");
|
||||
expect(pkg.scripts.build).toContain("src/index.ts");
|
||||
expect(pkg.devDependencies.esbuild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSenseIndexTs", () => {
|
||||
it("embeds sense id in stub with TypeScript types", () => {
|
||||
const ts = buildSenseIndexTs("my-sense");
|
||||
expect(ts).toContain("my-sense");
|
||||
expect(ts).toContain("export async function compute");
|
||||
expect(ts).toContain("LibSQLDatabase");
|
||||
expect(ts).toContain("Promise<SenseResult>");
|
||||
expect(ts).toContain('from "./schema.js"');
|
||||
});
|
||||
|
||||
it("imports the correct schema export", () => {
|
||||
const ts = buildSenseIndexTs("cpu-usage");
|
||||
expect(ts).toContain("cpuUsage");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 { buildWorkflowPackageJson, 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 and END", () => {
|
||||
const { indexTs } = buildWorkflowScaffold("test");
|
||||
expect(indexTs).toContain("moderator");
|
||||
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 function", () => {
|
||||
const { roleMainIndexTs } = buildWorkflowScaffold("test");
|
||||
expect(roleMainIndexTs).toContain("export async function mainRole");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("package.json defines esbuild bundling to dist/", () => {
|
||||
const pkg = JSON.parse(buildWorkflowPackageJson("my-flow")) as {
|
||||
scripts: { build: string };
|
||||
devDependencies: { esbuild: string };
|
||||
};
|
||||
expect(pkg.scripts.build).toContain("esbuild");
|
||||
expect(pkg.scripts.build).toContain("--outdir=dist");
|
||||
expect(pkg.devDependencies.esbuild).toBeTruthy();
|
||||
});
|
||||
|
||||
it("buildWorkflowScaffold includes package.json body", () => {
|
||||
const { packageJson } = buildWorkflowScaffold("wf");
|
||||
expect(JSON.parse(packageJson).scripts.build).toContain("esbuild");
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Compile-time check: daemon-types.ts stays in sync with @uncaged/nerve-daemon exports.
|
||||
* If the daemon package changes its public API, this file will fail to compile.
|
||||
*/
|
||||
|
||||
import type { SenseInfo } from "@uncaged/nerve-core";
|
||||
import type {
|
||||
ArchiveLogsDayResult as DaemonArchiveLogsDayResult,
|
||||
ArchiveLogsOptions as DaemonArchiveLogsOptions,
|
||||
ArchiveLogsResult as DaemonArchiveLogsResult,
|
||||
LogEntry as DaemonLogEntry,
|
||||
LogQuery as DaemonLogQuery,
|
||||
LogStore as DaemonLogStore,
|
||||
SenseInfo as DaemonSenseInfo,
|
||||
WorkflowRun as DaemonWorkflowRun,
|
||||
WorkflowRunStatus as DaemonWorkflowRunStatus,
|
||||
} from "@uncaged/nerve-daemon";
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
|
||||
import type {
|
||||
ArchiveLogsDayResult,
|
||||
ArchiveLogsOptions,
|
||||
ArchiveLogsResult,
|
||||
LogEntry,
|
||||
LogQuery,
|
||||
LogStore,
|
||||
WorkflowRun,
|
||||
WorkflowRunStatus,
|
||||
} from "../daemon-types.js";
|
||||
|
||||
describe("daemon-types drift guard", () => {
|
||||
it("SenseInfo matches daemon package export (list-senses IPC)", () => {
|
||||
expectTypeOf<SenseInfo>().toMatchTypeOf<DaemonSenseInfo>();
|
||||
expectTypeOf<DaemonSenseInfo>().toMatchTypeOf<SenseInfo>();
|
||||
});
|
||||
|
||||
it("WorkflowRunStatus is assignable both ways", () => {
|
||||
expectTypeOf<WorkflowRunStatus>().toMatchTypeOf<DaemonWorkflowRunStatus>();
|
||||
expectTypeOf<DaemonWorkflowRunStatus>().toMatchTypeOf<WorkflowRunStatus>();
|
||||
});
|
||||
|
||||
it("WorkflowRun is assignable both ways", () => {
|
||||
expectTypeOf<WorkflowRun>().toMatchTypeOf<DaemonWorkflowRun>();
|
||||
expectTypeOf<DaemonWorkflowRun>().toMatchTypeOf<WorkflowRun>();
|
||||
});
|
||||
|
||||
it("LogEntry is assignable both ways", () => {
|
||||
expectTypeOf<LogEntry>().toMatchTypeOf<DaemonLogEntry>();
|
||||
expectTypeOf<DaemonLogEntry>().toMatchTypeOf<LogEntry>();
|
||||
});
|
||||
|
||||
it("LogQuery is assignable both ways", () => {
|
||||
expectTypeOf<LogQuery>().toMatchTypeOf<DaemonLogQuery>();
|
||||
expectTypeOf<DaemonLogQuery>().toMatchTypeOf<LogQuery>();
|
||||
});
|
||||
|
||||
it("LogStore has all required methods", () => {
|
||||
expectTypeOf<LogStore>().toMatchTypeOf<
|
||||
Pick<
|
||||
DaemonLogStore,
|
||||
| "query"
|
||||
| "getWorkflowRun"
|
||||
| "getActiveWorkflowRuns"
|
||||
| "getAllWorkflowRuns"
|
||||
| "upsertWorkflowRun"
|
||||
| "archiveLogs"
|
||||
| "close"
|
||||
>
|
||||
>();
|
||||
});
|
||||
|
||||
it("ArchiveLogs types match daemon", () => {
|
||||
expectTypeOf<ArchiveLogsOptions>().toMatchTypeOf<DaemonArchiveLogsOptions>();
|
||||
expectTypeOf<DaemonArchiveLogsOptions>().toMatchTypeOf<ArchiveLogsOptions>();
|
||||
expectTypeOf<ArchiveLogsResult>().toMatchTypeOf<DaemonArchiveLogsResult>();
|
||||
expectTypeOf<DaemonArchiveLogsResult>().toMatchTypeOf<ArchiveLogsResult>();
|
||||
expectTypeOf<ArchiveLogsDayResult>().toMatchTypeOf<DaemonArchiveLogsDayResult>();
|
||||
expectTypeOf<DaemonArchiveLogsDayResult>().toMatchTypeOf<ArchiveLogsDayResult>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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 package.json with esbuild build",
|
||||
{ timeout: 10_000 },
|
||||
async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
|
||||
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
|
||||
const wf = await runTestCli(fakeHome, ["create", "workflow", "e2e-flow"]);
|
||||
expect(wf.exitCode).toBe(0);
|
||||
expect(wf.stdout).toContain("✅");
|
||||
|
||||
const pkgPath = join(nerveRoot, "workflows", "e2e-flow", "package.json");
|
||||
const indexPath = join(nerveRoot, "workflows", "e2e-flow", "index.ts");
|
||||
const mainRolePath = join(nerveRoot, "workflows", "e2e-flow", "roles", "main", "index.ts");
|
||||
expect(existsSync(pkgPath)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(pkgPath, "utf8")).scripts.build).toContain("esbuild");
|
||||
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");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"create sense scaffolds src/index.ts, src/schema.ts, package.json and migration",
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-"));
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
|
||||
await runTestCli(fakeHome, ["init", "--force", "--skip-install"]);
|
||||
|
||||
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(true);
|
||||
expect(existsSync(join(base, "src", "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(base, "src", "schema.ts"))).toBe(true);
|
||||
expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true);
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(base, "package.json"), "utf8"));
|
||||
expect(pkg.scripts.build).toContain("esbuild");
|
||||
|
||||
// pnpm install + build should produce index.js
|
||||
expect(existsSync(join(base, "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,454 @@
|
||||
/**
|
||||
* Shared E2E harness: temp HOME layout (`.uncaged-nerve`), real kernel + sense worker,
|
||||
* IPC socket for CLI, and `runCommand` helpers with captured stdio.
|
||||
*
|
||||
* ## Signal persistence (CLI `nerve sense list`)
|
||||
*
|
||||
* The kernel appends a `source: "sense", type: "signal"` row to `data/logs.db` when a
|
||||
* worker emits a signal (see `packages/daemon/src/kernel.ts`). The daemon also
|
||||
* auto-persists each signal into a `_signals` table in the per-sense SQLite DB
|
||||
* (see `runtime.persistSignal` in `packages/daemon/src/sense-runtime.ts`).
|
||||
* `listSenses()` reads `lastSignalTimestamp` from the kernel's in-memory state,
|
||||
* while `sense query` reads from the `_signals` table (or a user-defined preview table).
|
||||
*
|
||||
* ## 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 (start, _messages) => {
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
const p = typeof start.content === "string" ? 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
|
||||
`;
|
||||
|
||||
/** Empty migration — counter sense uses only `_signals` (auto-created by daemon). */
|
||||
const counterMigration = `-- no-op migration for e2e counter sense
|
||||
SELECT 1;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Minimal counter sense — each compute returns an incrementing count.
|
||||
* Does NOT touch the DB directly; signal persistence is handled by the daemon
|
||||
* (`runtime.persistSignal`) which writes to `_signals` automatically.
|
||||
*/
|
||||
const counterIndexJs = `let _count = 0;
|
||||
export async function compute(_db, _peers, _options) {
|
||||
_count += 1;
|
||||
return { signal: { count: _count }, workflow: null };
|
||||
}
|
||||
`;
|
||||
|
||||
/** First trigger launches local noop workflow; later triggers emit a plain signal. */
|
||||
const counterIndexJsWithNoopWorkflow = `let _launched = false;
|
||||
export async function compute(_db, _peers, _options) {
|
||||
if (!_launched) {
|
||||
_launched = true;
|
||||
return {
|
||||
signal: { launched: true },
|
||||
workflow: {
|
||||
name: "noop",
|
||||
maxRounds: 3,
|
||||
prompt: "e2e-archive",
|
||||
dryRun: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { signal: { idle: true }, 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,
|
||||
retention: 10_000,
|
||||
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, "senses", "counter", "migrations"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "workflows", "echo", "dist"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "nerve.yaml"),
|
||||
withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "senses", "counter", "migrations", "001.sql"),
|
||||
counterMigration,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "senses", "counter", "index.js"),
|
||||
withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "echo", "dist", "index.js"),
|
||||
echoWorkflowIndexJs,
|
||||
"utf8",
|
||||
);
|
||||
if (withNoopWorkflow) {
|
||||
mkdirSync(join(nerveRoot, "workflows", "noop", "dist"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "workflows", "noop", "migrations"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(nerveRoot, "workflows", "noop", "dist", "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 linkDir = join(nerveRoot, "node_modules", "@uncaged");
|
||||
const linkPath = join(linkDir, "nerve-daemon");
|
||||
mkdirSync(linkDir, { recursive: true });
|
||||
if (existsSync(linkPath)) return;
|
||||
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,121 @@
|
||||
/**
|
||||
* E2E: `nerve sense query` against a real daemon + persisted `_signals` (issue #156).
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type TestDaemonHandle,
|
||||
pollUntil,
|
||||
runCli,
|
||||
startTestDaemon,
|
||||
stopTestDaemon,
|
||||
} from "./e2e-harness.js";
|
||||
|
||||
describe("e2e sense query", () => {
|
||||
let daemon: TestDaemonHandle | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
const h = daemon;
|
||||
daemon = null;
|
||||
if (h === null) return;
|
||||
await Promise.race([
|
||||
stopTestDaemon(h),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
async function waitForSignalsPersisted(handle: TestDaemonHandle): Promise<void> {
|
||||
const dbPath = join(handle.nerveRoot, "data", "senses", "counter.db");
|
||||
await pollUntil(() => {
|
||||
if (!existsSync(dbPath)) return false;
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as
|
||||
| { cnt: number }
|
||||
| undefined;
|
||||
db.close();
|
||||
return row !== undefined && row.cnt > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
/** Start daemon, trigger counter, wait until `_signals` has a row. */
|
||||
async function startDaemonWithPersistedSignal(): Promise<TestDaemonHandle> {
|
||||
const handle = await startTestDaemon();
|
||||
const triggerResult = await runCli(handle, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
expect(triggerResult.stdout).toContain("Triggered");
|
||||
await waitForSignalsPersisted(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
it(
|
||||
"after trigger, persisted _signals and sense query counter returns at least one row",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
|
||||
expect(queryResult.exitCode).toBe(0);
|
||||
expect(queryResult.stdout).not.toContain("(0 rows)");
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"default sense query output lists payload column and counter count in payload",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
|
||||
expect(queryResult.exitCode).toBe(0);
|
||||
expect(queryResult.stdout).toContain("payload");
|
||||
expect(queryResult.stdout).toMatch(/count/);
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"nerve sense query counter --json prints a JSON array with payload on each row",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const jsonResult = await runCli(daemon, ["sense", "query", "counter", "--json"]);
|
||||
expect(jsonResult.exitCode).toBe(0);
|
||||
const rows = JSON.parse(jsonResult.stdout.trim()) as unknown;
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
for (const row of rows as Record<string, unknown>[]) {
|
||||
expect(Object.keys(row)).toContain("payload");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"nerve sense query counter --sql runs custom read-only SQL and prints total column",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
daemon = await startDaemonWithPersistedSignal();
|
||||
|
||||
const sqlResult = await runCli(daemon, [
|
||||
"sense",
|
||||
"query",
|
||||
"counter",
|
||||
"--sql",
|
||||
"SELECT count(*) as total FROM _signals",
|
||||
]);
|
||||
expect(sqlResult.exitCode).toBe(0);
|
||||
expect(sqlResult.stdout).toContain("total");
|
||||
expect(sqlResult.stdout).not.toContain("(0 rows)");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Smoke test: start a real daemon with a counter sense, trigger it,
|
||||
* then verify CLI commands can list and query the persisted signal.
|
||||
*/
|
||||
|
||||
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 + sense query after trigger", { timeout: 30_000 }, async () => {
|
||||
daemon = await startTestDaemon();
|
||||
|
||||
// Trigger counter sense via IPC
|
||||
const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]);
|
||||
expect(triggerResult.exitCode).toBe(0);
|
||||
expect(triggerResult.stdout).toContain("Triggered");
|
||||
|
||||
// Wait for signal to be persisted (_signals table in the sense DB)
|
||||
const { existsSync } = await import("node:fs");
|
||||
const { join } = await import("node:path");
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
|
||||
const dbPath = join(daemon.nerveRoot, "data", "senses", "counter.db");
|
||||
await pollUntil(() => {
|
||||
if (!existsSync(dbPath)) return false;
|
||||
try {
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as
|
||||
| { cnt: number }
|
||||
| undefined;
|
||||
db.close();
|
||||
return row !== undefined && row.cnt > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
// nerve sense list — should show counter with a last signal timestamp
|
||||
const listResult = await runCli(daemon, ["sense", "list"]);
|
||||
expect(listResult.exitCode).toBe(0);
|
||||
expect(listResult.stdout).toContain("counter");
|
||||
expect(listResult.stdout).toContain("last signal:");
|
||||
// Should NOT say "(never)" since we triggered and persisted
|
||||
expect(listResult.stdout).not.toContain("(never)");
|
||||
|
||||
// nerve sense query counter — should return rows from _signals
|
||||
const queryResult = await runCli(daemon, ["sense", "query", "counter"]);
|
||||
expect(queryResult.exitCode).toBe(0);
|
||||
// Should have actual data rows (not "(0 rows)")
|
||||
expect(queryResult.stdout).not.toContain("(0 rows)");
|
||||
});
|
||||
});
|
||||
@@ -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,253 @@
|
||||
/**
|
||||
* 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, "pnpm-workspace.yaml"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "biome.json"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, ".gitignore"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "package.json"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"))).toBe(true);
|
||||
expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(existsSync(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"))).toBe(true);
|
||||
|
||||
const pkgJson = readFileSync(join(nerveRoot, "package.json"), "utf8");
|
||||
expect(pkgJson).toContain('"@uncaged/nerve-skills": "latest"');
|
||||
expect(pkgJson).toContain('"build": "pnpm -r build"');
|
||||
|
||||
const workspaceYaml = readFileSync(join(nerveRoot, "pnpm-workspace.yaml"), "utf8");
|
||||
expect(workspaceYaml).toContain("workflows/*");
|
||||
expect(workspaceYaml).toContain("senses/*");
|
||||
|
||||
const sensePkgJson = readFileSync(
|
||||
join(nerveRoot, "senses", "cpu-usage", "package.json"),
|
||||
"utf8",
|
||||
);
|
||||
expect(sensePkgJson).toContain("nerve-sense-cpu-usage");
|
||||
expect(sensePkgJson).toContain("esbuild");
|
||||
});
|
||||
|
||||
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,29 @@
|
||||
# nerve sense — E2E Scenarios
|
||||
|
||||
## sense list
|
||||
|
||||
- ✅ prints sense list with name, group, throttle, triggers, and last signal time
|
||||
- 🔲 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
|
||||
|
||||
## sense query
|
||||
|
||||
- ✅ after trigger, persisted `_signals` table has at least one row
|
||||
- ✅ default output lists payload column and counter count
|
||||
- ✅ `--json` prints valid JSON array with payload on each row
|
||||
- ✅ `--sql` runs custom read-only SQL and prints result
|
||||
- 🔲 query non-existent sense — error message
|
||||
- 🔲 `--limit` / `--offset` pagination
|
||||
|
||||
## sense schema
|
||||
|
||||
- ✅ prints CREATE TABLE statements for the sense database
|
||||
- ✅ includes `_signals` table in output
|
||||
- ✅ `--json` prints valid JSON array of SQL strings
|
||||
- 🔲 schema for non-existent sense — error message
|
||||
@@ -0,0 +1,6 @@
|
||||
# nerve smoke — E2E Scenarios
|
||||
|
||||
Full round-trip integration tests that exercise multiple subcommands together.
|
||||
|
||||
- ✅ sense list + sense query after trigger — registers sense, triggers, verifies persisted signal and query output
|
||||
- 🔲 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");
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Tests for nerve init 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 { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildWorkflowTemplate } from "../commands/init.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "nerve-cli-init-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("buildWorkflowTemplate", () => {
|
||||
it("includes the workflow name in the template", () => {
|
||||
const tpl = buildWorkflowTemplate("my-workflow");
|
||||
expect(tpl).toContain("my-workflow started");
|
||||
});
|
||||
|
||||
it("contains WorkflowDefinition type import", () => {
|
||||
const tpl = buildWorkflowTemplate("test");
|
||||
expect(tpl).toContain("WorkflowDefinition");
|
||||
expect(tpl).toContain("@uncaged/nerve-daemon");
|
||||
});
|
||||
|
||||
it("contains a moderate function that returns null to signal completion", () => {
|
||||
const tpl = buildWorkflowTemplate("test");
|
||||
expect(tpl).toContain("return null");
|
||||
expect(tpl).toContain("moderate");
|
||||
});
|
||||
|
||||
it("contains a roles map with main role", () => {
|
||||
const tpl = buildWorkflowTemplate("test");
|
||||
expect(tpl).toContain("roles:");
|
||||
expect(tpl).toContain("main:");
|
||||
});
|
||||
|
||||
it("uses different names per call", () => {
|
||||
const a = buildWorkflowTemplate("workflow-a");
|
||||
const b = buildWorkflowTemplate("workflow-b");
|
||||
expect(a).toContain("workflow-a started");
|
||||
expect(b).toContain("workflow-b started");
|
||||
expect(a).not.toContain("workflow-b");
|
||||
});
|
||||
|
||||
it("produces valid TypeScript syntax (no unclosed braces)", () => {
|
||||
const tpl = buildWorkflowTemplate("test");
|
||||
const opens = (tpl.match(/\{/g) ?? []).length;
|
||||
const closes = (tpl.match(/\}/g) ?? []).length;
|
||||
expect(opens).toBe(closes);
|
||||
});
|
||||
|
||||
it("ends with export default workflow", () => {
|
||||
const tpl = buildWorkflowTemplate("test");
|
||||
expect(tpl.trim().endsWith("export default workflow;")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow scaffold file writing (simulated)", () => {
|
||||
it("writes the template to disk correctly", () => {
|
||||
const { mkdirSync, writeFileSync } = require("node:fs");
|
||||
const workflowDir = join(tmpDir, "workflows", "my-task");
|
||||
mkdirSync(workflowDir, { recursive: true });
|
||||
const content = buildWorkflowTemplate("my-task");
|
||||
writeFileSync(join(workflowDir, "index.ts"), content, "utf8");
|
||||
|
||||
const read = readFileSync(join(workflowDir, "index.ts"), "utf8");
|
||||
expect(read).toContain("my-task started");
|
||||
expect(read).toContain("WorkflowDefinition");
|
||||
});
|
||||
});
|
||||
@@ -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 signal bus emits notifications",
|
||||
contentHash: contentHash("the signal bus emits notifications"),
|
||||
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, "signal bus", 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -234,7 +234,11 @@ describe("logsCommand negative offset", () => {
|
||||
|
||||
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 }),
|
||||
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");
|
||||
@@ -243,7 +247,11 @@ describe("logsCommand negative offset", () => {
|
||||
|
||||
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 }),
|
||||
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,139 @@
|
||||
/**
|
||||
* 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 LAST_SIGNAL_TS = 1_714_521_600_000; // fixed wall time for stable ISO in assertions
|
||||
|
||||
const daemonSenseRow: SenseInfo = {
|
||||
name: "cpu-usage",
|
||||
group: "system",
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
triggers: ["every 5s"],
|
||||
lastSignalTimestamp: LAST_SIGNAL_TS,
|
||||
};
|
||||
|
||||
function nerveYamlFixture(): string {
|
||||
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, trigger schedule, and last signal time", async () => {
|
||||
// With a real daemon, we would wait for a compute cycle; the mock server
|
||||
// returns SenseInfo as if one already produced lastSignalTimestamp.
|
||||
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("(never)");
|
||||
expect(out).toContain(new Date(LAST_SIGNAL_TS).toISOString());
|
||||
});
|
||||
});
|
||||
@@ -29,10 +29,25 @@ const SAMPLE_SENSES: SenseInfo[] = [
|
||||
group: "system",
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
lastSignalTs: 1_700_000_000_000,
|
||||
triggers: ["every 30s", "on: cpu-threshold"],
|
||||
lastSignalTimestamp: 1_700_000_000_000,
|
||||
},
|
||||
{
|
||||
name: "disk-usage",
|
||||
group: "system",
|
||||
throttle: 30000,
|
||||
timeout: null,
|
||||
triggers: [],
|
||||
lastSignalTimestamp: null,
|
||||
},
|
||||
{
|
||||
name: "active-tasks",
|
||||
group: "tasks",
|
||||
throttle: 10000,
|
||||
timeout: 30000,
|
||||
triggers: ["every 1m"],
|
||||
lastSignalTimestamp: null,
|
||||
},
|
||||
{ name: "disk-usage", group: "system", throttle: 30000, timeout: null, lastSignalTs: null },
|
||||
{ name: "active-tasks", group: "tasks", throttle: 10000, timeout: 30000, lastSignalTs: null },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -100,14 +115,21 @@ describe("formatSenseList", () => {
|
||||
expect(output).toContain("—");
|
||||
});
|
||||
|
||||
it("shows '(never)' when lastSignalTs is null", () => {
|
||||
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("shows '(never)' when lastSignalTimestamp is null", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
expect(output).toContain("(never)");
|
||||
});
|
||||
|
||||
it("shows ISO timestamp when lastSignalTs is set", () => {
|
||||
it("shows ISO timestamp when lastSignalTimestamp is set", () => {
|
||||
const output = formatSenseList(SAMPLE_SENSES);
|
||||
// cpu-usage has lastSignalTs = 1_700_000_000_000
|
||||
// cpu-usage has lastSignalTimestamp = 1_700_000_000_000
|
||||
expect(output).toContain(new Date(1_700_000_000_000).toISOString());
|
||||
});
|
||||
});
|
||||
@@ -152,16 +174,23 @@ senses:
|
||||
disk-usage:
|
||||
group: system
|
||||
throttle: 30s
|
||||
reflexes: []
|
||||
`.trim(),
|
||||
);
|
||||
const result = sensesFromConfig(path);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({ name: "cpu-usage", group: "system", lastSignalTs: null });
|
||||
expect(result[1]).toMatchObject({ name: "disk-usage", group: "system", lastSignalTs: null });
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "cpu-usage",
|
||||
group: "system",
|
||||
lastSignalTimestamp: null,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
name: "disk-usage",
|
||||
group: "system",
|
||||
lastSignalTimestamp: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("always sets lastSignalTs to null (static fallback)", () => {
|
||||
it("always sets lastSignalTimestamp to null (static fallback)", () => {
|
||||
const path = join(tmpDir, "nerve.yaml");
|
||||
writeFileSync(
|
||||
path,
|
||||
@@ -169,11 +198,10 @@ reflexes: []
|
||||
senses:
|
||||
my-sense:
|
||||
group: default
|
||||
reflexes: []
|
||||
`.trim(),
|
||||
);
|
||||
const result = sensesFromConfig(path);
|
||||
expect(result[0].lastSignalTs).toBeNull();
|
||||
expect(result[0].lastSignalTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it("populates throttle and timeout from config", () => {
|
||||
@@ -186,13 +214,33 @@ senses:
|
||||
group: default
|
||||
throttle: 10s
|
||||
timeout: 5s
|
||||
reflexes: []
|
||||
`.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([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -238,7 +286,14 @@ describe("listSensesViaDaemon", () => {
|
||||
|
||||
it("resolves with populated senses array", async () => {
|
||||
const senses: SenseInfo[] = [
|
||||
{ name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, lastSignalTs: 12345 },
|
||||
{
|
||||
name: "cpu-usage",
|
||||
group: "system",
|
||||
throttle: 5000,
|
||||
timeout: 3000,
|
||||
triggers: [],
|
||||
lastSignalTimestamp: 12345,
|
||||
},
|
||||
];
|
||||
const server = createServer((s) => {
|
||||
s.on("data", () => {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* E2E-style tests for `nerve sense schema` with a temp HOME and a real sense SQLite file.
|
||||
* `getNerveRoot()` uses `os.homedir()`, which respects `process.env.HOME` on POSIX.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { runCommand } from "citty";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { senseCommand } from "../commands/sense.js";
|
||||
|
||||
const SENSE_NAME = "e2e-schema-sense";
|
||||
|
||||
function createFakeSenseDb(nerveRoot: string): void {
|
||||
const sensesDir = join(nerveRoot, "data", "senses");
|
||||
mkdirSync(sensesDir, { recursive: true });
|
||||
const dbPath = join(sensesDir, `${SENSE_NAME}.db`);
|
||||
const db = new DatabaseSync(dbPath);
|
||||
db.exec(
|
||||
"CREATE TABLE _signals(id INTEGER PRIMARY KEY, sense TEXT, timestamp INTEGER, payload TEXT)",
|
||||
);
|
||||
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe("nerve sense schema CLI (runCommand + temp HOME)", () => {
|
||||
let prevHome: string | undefined;
|
||||
let fakeHome: string;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn> | null;
|
||||
let capturedStdout: string;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedStdout = "";
|
||||
stdoutSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(
|
||||
(chunk: string | Uint8Array, enc?: BufferEncoding, cb?: (err?: Error | null) => void) => {
|
||||
if (typeof chunk === "string") {
|
||||
capturedStdout += chunk;
|
||||
} else {
|
||||
capturedStdout += Buffer.from(chunk).toString(typeof enc === "string" ? enc : "utf8");
|
||||
}
|
||||
if (typeof cb === "function") {
|
||||
cb();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
prevHome = process.env.HOME;
|
||||
fakeHome = mkdtempSync(join(tmpdir(), "nerve-sense-schema-e2e-"));
|
||||
process.env.HOME = fakeHome;
|
||||
|
||||
const nerveRoot = join(fakeHome, ".uncaged-nerve");
|
||||
createFakeSenseDb(nerveRoot);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutSpy?.mockRestore();
|
||||
stdoutSpy = null;
|
||||
if (prevHome === undefined) {
|
||||
// biome-ignore lint/performance/noDelete: semantically correct for env cleanup
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
rmSync(fakeHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prints CREATE TABLE statements for the sense database", async () => {
|
||||
await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] });
|
||||
expect(capturedStdout).toMatch(/CREATE TABLE/i);
|
||||
});
|
||||
|
||||
it("includes the _signals table in output", async () => {
|
||||
await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] });
|
||||
expect(capturedStdout).toContain("_signals");
|
||||
});
|
||||
|
||||
it("with --json prints a valid JSON array of SQL strings", async () => {
|
||||
await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME, "--json"] });
|
||||
const parsed: unknown = JSON.parse(capturedStdout.trim());
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
const arr = parsed as unknown[];
|
||||
expect(arr.length).toBeGreaterThanOrEqual(1);
|
||||
for (const item of arr) {
|
||||
expect(typeof item).toBe("string");
|
||||
expect(item).toMatch(/CREATE TABLE/i);
|
||||
}
|
||||
const joined = arr.join("\n");
|
||||
expect(joined).toContain("_signals");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`.
|
||||
*/
|
||||
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertSenseDbExists,
|
||||
collectColumnKeys,
|
||||
defaultPreviewSql,
|
||||
formatRowsAsAlignedTable,
|
||||
listTableSqlStatements,
|
||||
parseSenseQueryArgs,
|
||||
pickDefaultPreviewTable,
|
||||
senseDbPath,
|
||||
} from "../sense-sqlite.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = join(
|
||||
tmpdir(),
|
||||
`nerve-sense-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
mkdirSync(join(tmpDir, "data", "senses"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("senseDbPath", () => {
|
||||
it("points at data/senses/<name>.db under the given root", () => {
|
||||
expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSenseDbExists", () => {
|
||||
it("throws when the file is missing", () => {
|
||||
expect(() => assertSenseDbExists(tmpDir, "nope")).toThrow(/No database at/);
|
||||
});
|
||||
|
||||
it("returns the path when the file exists", () => {
|
||||
const p = join(tmpDir, "data", "senses", "x.db");
|
||||
new DatabaseSync(p).close();
|
||||
expect(assertSenseDbExists(tmpDir, "x")).toBe(p);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listTableSqlStatements", () => {
|
||||
it("returns CREATE statements ordered by tbl_name", () => {
|
||||
const p = join(tmpDir, "data", "senses", "t.db");
|
||||
const db = new DatabaseSync(p);
|
||||
db.exec("CREATE TABLE zebra (id INTEGER)");
|
||||
db.exec("CREATE TABLE alpha (id INTEGER)");
|
||||
const stmts = listTableSqlStatements(db);
|
||||
db.close();
|
||||
expect(stmts).toHaveLength(2);
|
||||
expect(stmts[0]).toMatch(/^CREATE TABLE alpha/i);
|
||||
expect(stmts[1]).toMatch(/^CREATE TABLE zebra/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDefaultPreviewTable", () => {
|
||||
it("prefers non-_migrations tables when both exist", () => {
|
||||
const p = join(tmpDir, "data", "senses", "t.db");
|
||||
const db = new DatabaseSync(p);
|
||||
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
|
||||
db.exec("CREATE TABLE readings (id INTEGER)");
|
||||
expect(pickDefaultPreviewTable(db)).toBe("readings");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("uses _migrations when it is the only table", () => {
|
||||
const p = join(tmpDir, "data", "senses", "t.db");
|
||||
const db = new DatabaseSync(p);
|
||||
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
|
||||
expect(pickDefaultPreviewTable(db)).toBe("_migrations");
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultPreviewSql", () => {
|
||||
it("quotes identifiers for SQL safety", () => {
|
||||
expect(defaultPreviewSql(`weird"name`)).toContain(`weird""name`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSenseQueryArgs", () => {
|
||||
it("parses sense name only", () => {
|
||||
expect(parseSenseQueryArgs(["cpu"])).toEqual({ name: "cpu", sql: undefined });
|
||||
});
|
||||
|
||||
it("strips --json", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "--json"])).toEqual({ name: "cpu", sql: undefined });
|
||||
expect(parseSenseQueryArgs(["--json", "cpu"])).toEqual({ name: "cpu", sql: undefined });
|
||||
});
|
||||
|
||||
it("joins remaining tokens into SQL", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" });
|
||||
});
|
||||
|
||||
it("uses --sql value instead of positional SQL", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT 2"])).toEqual({
|
||||
name: "cpu",
|
||||
sql: "SELECT 2",
|
||||
});
|
||||
expect(parseSenseQueryArgs(["cpu", "--sql=SELECT 3"])).toEqual({
|
||||
name: "cpu",
|
||||
sql: "SELECT 3",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers --sql over trailing positional SQL", () => {
|
||||
expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT a", "SELECT b"])).toEqual({
|
||||
name: "cpu",
|
||||
sql: "SELECT a",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when --sql has no value", () => {
|
||||
expect(() => parseSenseQueryArgs(["cpu", "--sql"])).toThrow(/Missing value for --sql/);
|
||||
});
|
||||
|
||||
it("throws when name is missing", () => {
|
||||
expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRowsAsAlignedTable", () => {
|
||||
it("shows empty marker for no rows", () => {
|
||||
expect(formatRowsAsAlignedTable([])).toContain("(0 rows)");
|
||||
});
|
||||
|
||||
it("aligns columns from row data", () => {
|
||||
const out = formatRowsAsAlignedTable([
|
||||
{ a: 1, b: "x" },
|
||||
{ a: 22, b: "yy" },
|
||||
]);
|
||||
expect(out).toContain("a");
|
||||
expect(out).toContain("b");
|
||||
expect(out).toContain("22");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectColumnKeys", () => {
|
||||
it("preserves key order from first row then appends new keys", () => {
|
||||
expect(
|
||||
collectColumnKeys([
|
||||
{ z: 1, a: 2 },
|
||||
{ a: 3, b: 4 },
|
||||
]),
|
||||
).toEqual(["z", "a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readonly query integration", () => {
|
||||
it("runs default preview SQL on a real db", () => {
|
||||
const p = join(tmpDir, "data", "senses", "demo.db");
|
||||
const rw = new DatabaseSync(p);
|
||||
rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)");
|
||||
rw.exec("INSERT INTO items (v) VALUES ('a'), ('b')");
|
||||
rw.close();
|
||||
|
||||
const db = new DatabaseSync(p, { readOnly: true });
|
||||
const table = pickDefaultPreviewTable(db);
|
||||
expect(table).toBe("items");
|
||||
if (table === null) {
|
||||
throw new Error("expected items table");
|
||||
}
|
||||
const sql = defaultPreviewSql(table);
|
||||
const rows = db.prepare(sql).all() as Record<string, unknown>[];
|
||||
db.close();
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -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,114 @@
|
||||
/**
|
||||
* RFC-003 Phase 5: nerve validate — WorkflowSpec 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"),
|
||||
`
|
||||
import type { WorkflowSpec } from "@uncaged/nerve-core";
|
||||
const adapter = async () => "";
|
||||
const spec: WorkflowSpec<{ r: { x: number } }> = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -12,19 +12,25 @@ import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createLogStore } from "@uncaged/nerve-daemon";
|
||||
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";
|
||||
import type { LogStore, WorkflowRun } from "../daemon-types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
@@ -37,11 +43,11 @@ function upsertRun(
|
||||
runId: string,
|
||||
workflow: string,
|
||||
status: WorkflowRun["status"],
|
||||
ts: number,
|
||||
timestampMs: number,
|
||||
): void {
|
||||
store.upsertWorkflowRun(
|
||||
{ source: "workflow", type: status, refId: runId, payload: null, ts },
|
||||
{ runId, workflow, status, ts },
|
||||
{ source: "workflow", type: status, refId: runId, payload: null, timestamp: timestampMs },
|
||||
{ runId, workflow, status, timestamp: timestampMs, exitCode: null },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,9 +66,74 @@ afterEach(() => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("formatTs", () => {
|
||||
it("returns ISO 8601 string", () => {
|
||||
const ts = new Date("2026-01-01T00:00:00.000Z").getTime();
|
||||
expect(formatTs(ts)).toBe("2026-01-01T00:00:00.000Z");
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +150,7 @@ describe("statusIcon", () => {
|
||||
["crashed", "💥"],
|
||||
["dropped", "🗑"],
|
||||
["interrupted", "⚠️"],
|
||||
["killed", "🛑"],
|
||||
] as const)("maps status=%s to icon=%s", (status, icon) => {
|
||||
expect(statusIcon(status)).toBe(icon);
|
||||
});
|
||||
@@ -123,14 +195,14 @@ describe("getAllWorkflowRuns", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("sorts by ts descending (newest first)", () => {
|
||||
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].ts).toBeGreaterThan(runs[1].ts);
|
||||
expect(runs[1].ts).toBeGreaterThan(runs[2].ts);
|
||||
expect(runs[0].timestamp).toBeGreaterThan(runs[1].timestamp);
|
||||
expect(runs[1].timestamp).toBeGreaterThan(runs[2].timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,9 +215,9 @@ describe("buildListOutput", () => {
|
||||
runId: string,
|
||||
workflow: string,
|
||||
status: WorkflowRun["status"],
|
||||
ts: number,
|
||||
timestampMs: number,
|
||||
): WorkflowRun {
|
||||
return { runId, workflow, status, ts };
|
||||
return { runId, workflow, status, timestamp: timestampMs, exitCode: null };
|
||||
}
|
||||
|
||||
it("returns empty message when no runs and --all=false", () => {
|
||||
@@ -187,6 +259,7 @@ describe("buildListOutput", () => {
|
||||
// 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");
|
||||
});
|
||||
@@ -220,6 +293,22 @@ describe("buildListOutput", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -231,7 +320,8 @@ describe("buildInspectOutput", () => {
|
||||
runId: "run-xyz",
|
||||
workflow: "cleanup",
|
||||
status: "completed",
|
||||
ts: 1_700_000_000_000,
|
||||
timestamp: 1_700_000_000_000,
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
it("shows header with run details", () => {
|
||||
@@ -247,8 +337,8 @@ describe("buildInspectOutput", () => {
|
||||
expect(eventLines.join("")).toContain("no events recorded");
|
||||
});
|
||||
|
||||
it("shows event lines with type and ts", () => {
|
||||
const logs = [{ ts: 1_700_000_001_000, type: "started", payload: null }];
|
||||
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");
|
||||
@@ -256,7 +346,7 @@ describe("buildInspectOutput", () => {
|
||||
|
||||
it("truncates long payloads to 200 chars with ellipsis", () => {
|
||||
const longPayload = "x".repeat(250);
|
||||
const logs = [{ ts: 1000, type: "step_complete", payload: longPayload }];
|
||||
const logs = [{ timestamp: 1000, type: "step_complete", payload: longPayload }];
|
||||
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
|
||||
const text = eventLines.join("");
|
||||
expect(text).toContain("…");
|
||||
@@ -264,14 +354,14 @@ describe("buildInspectOutput", () => {
|
||||
});
|
||||
|
||||
it("shows short payloads in full", () => {
|
||||
const logs = [{ ts: 1000, type: "step_complete", payload: '{"count":5}' }];
|
||||
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) => ({
|
||||
ts: 1000 + i,
|
||||
timestamp: 1000 + i,
|
||||
type: "step_complete",
|
||||
payload: null,
|
||||
}));
|
||||
@@ -283,17 +373,32 @@ describe("buildInspectOutput", () => {
|
||||
});
|
||||
|
||||
it("no pagination hint when all events fit on one page", () => {
|
||||
const logs = [{ ts: 1000, type: "started", payload: null }];
|
||||
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 list — integration 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);
|
||||
@@ -322,6 +427,136 @@ describe("workflow list — integration with real store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -366,15 +601,15 @@ describe("getAllWorkflowRuns — uses store.getAllWorkflowRuns SQL path", () =>
|
||||
expect(runs).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("returns runs sorted by ts descending (newest first)", () => {
|
||||
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].ts).toBe(3000);
|
||||
expect(runs[1].ts).toBe(2000);
|
||||
expect(runs[2].ts).toBe(1000);
|
||||
expect(runs[0].timestamp).toBe(3000);
|
||||
expect(runs[1].timestamp).toBe(2000);
|
||||
expect(runs[2].timestamp).toBe(1000);
|
||||
});
|
||||
|
||||
it("filters by workflow name", () => {
|
||||
@@ -418,7 +653,7 @@ describe("triggerWorkflowViaDaemon", () => {
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
const result = await triggerWorkflowViaDaemon(sockPath, "my-workflow", {});
|
||||
const result = await triggerWorkflowViaDaemon(sockPath, "my-workflow", "", 100);
|
||||
expect(result).toEqual({ ok: true });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
@@ -434,7 +669,7 @@ describe("triggerWorkflowViaDaemon", () => {
|
||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||
|
||||
try {
|
||||
const result = await triggerWorkflowViaDaemon(sockPath, "missing", {});
|
||||
const result = await triggerWorkflowViaDaemon(sockPath, "missing", "", 100);
|
||||
expect(result).toEqual({ ok: false, error: "unknown workflow" });
|
||||
} finally {
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
@@ -442,7 +677,7 @@ describe("triggerWorkflowViaDaemon", () => {
|
||||
});
|
||||
|
||||
it("rejects when no daemon is listening on the socket", async () => {
|
||||
await expect(triggerWorkflowViaDaemon(sockPath, "my-workflow", {})).rejects.toThrow(
|
||||
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;
|
||||
}
|
||||
+42
-10
@@ -1,35 +1,67 @@
|
||||
import "@uncaged/nerve-daemon/experimental-warning-suppression.js";
|
||||
|
||||
import { defineCommand, runMain } from "citty";
|
||||
|
||||
import { consumeGlobalDaemonCliFlags } from "./cli-global.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 { logsCommand } from "./commands/logs.js";
|
||||
import { knowledgeCommand } from "./commands/knowledge.js";
|
||||
import { remoteCommand } from "./commands/remote.js";
|
||||
import { senseCommand } from "./commands/sense.js";
|
||||
import { daemonStartCommand } from "./commands/start.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 { 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: {
|
||||
init: initCommand,
|
||||
create: createCommand,
|
||||
daemon: daemonCommand,
|
||||
dev: devCommand,
|
||||
start: daemonStartCommand,
|
||||
stop: stopCommand,
|
||||
status: statusCommand,
|
||||
logs: logsCommand,
|
||||
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,360 @@
|
||||
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;
|
||||
packageJson: string;
|
||||
};
|
||||
|
||||
export function buildWorkflowPackageJson(name: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: `nerve-workflow-${name}`,
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build:
|
||||
"esbuild index.ts --bundle --platform=node --format=esm --outdir=dist --packages=external",
|
||||
},
|
||||
devDependencies: {
|
||||
esbuild: "^0.27.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function buildWorkflowScaffold(name: string): WorkflowScaffoldFiles {
|
||||
return {
|
||||
indexTs: buildWorkflowIndexTs(name),
|
||||
roleMainIndexTs: buildWorkflowMainRoleIndexTs(name),
|
||||
roleMainPromptMd: buildWorkflowMainRolePromptMd(name),
|
||||
packageJson: buildWorkflowPackageJson(name),
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkflowIndexTs(name: string): string {
|
||||
return `import type { 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({ steps }) {
|
||||
if (steps.length === 0) {
|
||||
return "main";
|
||||
}
|
||||
return END;
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
`;
|
||||
}
|
||||
|
||||
function buildWorkflowMainRoleIndexTs(name: string): string {
|
||||
return `import type { RoleResult, StartStep, WorkflowMessage } 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(
|
||||
start: StartStep,
|
||||
messages: WorkflowMessage[],
|
||||
): Promise<RoleResult<Record<string, unknown>>> {
|
||||
void start;
|
||||
void messages;
|
||||
// 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.
|
||||
`;
|
||||
}
|
||||
|
||||
function senseIdToSqlTableName(id: string): string {
|
||||
return id.replaceAll("-", "_");
|
||||
}
|
||||
|
||||
function senseIdToSchemaExportName(id: string): string {
|
||||
const parts = id.split("-");
|
||||
return parts
|
||||
.map((part, index) =>
|
||||
index === 0 ? part : part.length === 0 ? "" : part.charAt(0).toUpperCase() + part.slice(1),
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function buildSenseSchemaTs(senseId: string): string {
|
||||
const table = senseIdToSqlTableName(senseId);
|
||||
const exportName = senseIdToSchemaExportName(senseId);
|
||||
return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const ${exportName} = sqliteTable("${table}", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ts: integer("ts").notNull(),
|
||||
label: text("label").notNull(),
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildSensePackageJson(name: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: `nerve-sense-${name}`,
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build:
|
||||
"esbuild src/index.ts --bundle --platform=node --format=esm --outdir=. --out-extension:.js=.js --packages=external",
|
||||
},
|
||||
devDependencies: {
|
||||
esbuild: "^0.27.0",
|
||||
"drizzle-orm": "*",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function buildSenseIndexTs(senseId: string): string {
|
||||
const exportName = senseIdToSchemaExportName(senseId);
|
||||
return `import type { LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
|
||||
import { ${exportName} } from "./schema.js";
|
||||
|
||||
type SenseResult = {
|
||||
signal: { label: string; ts: number };
|
||||
workflow: null;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* ${senseId} — replace this stub with your sampling logic.
|
||||
* Returns non-null to emit a signal, null to stay silent.
|
||||
*/
|
||||
export async function compute(
|
||||
db: LibSQLDatabase,
|
||||
_peers: Record<string, LibSQLDatabase>,
|
||||
_options: { signal: AbortSignal },
|
||||
): Promise<SenseResult> {
|
||||
void ${exportName};
|
||||
return {
|
||||
signal: {
|
||||
label: "${senseId}",
|
||||
ts: Date.now(),
|
||||
},
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildSenseMigrationSql(senseId: string): string {
|
||||
const table = senseIdToSqlTableName(senseId);
|
||||
return `CREATE TABLE IF NOT EXISTS ${table} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
label TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
}
|
||||
|
||||
function writeFile(filePath: string, content: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
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, "package.json"), scaffold.packageJson);
|
||||
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, "package.json")}\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("\n💡 Next steps:\n");
|
||||
process.stdout.write(
|
||||
` 1. In ${workflowDir}, run \`npm install\` then \`npm run build\` (bundles to dist/index.js).\n`,
|
||||
);
|
||||
process.stdout.write(" 2. 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(
|
||||
` 3. Edit ${join(workflowDir, "roles", "main", "index.ts")} (and optional prompt.md).\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
` 4. Adjust moderator routing in ${join(workflowDir, "index.ts")} if you add roles.\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 });
|
||||
mkdirSync(join(senseDir, "migrations"), { recursive: true });
|
||||
writeFile(join(senseDir, "package.json"), buildSensePackageJson(args.name));
|
||||
writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name));
|
||||
writeFile(join(senseDir, "src", "schema.ts"), buildSenseSchemaTs(args.name));
|
||||
writeFile(join(senseDir, "migrations", "0001_init.sql"), buildSenseMigrationSql(args.name));
|
||||
|
||||
process.stdout.write("✅ Sense scaffolded:\n");
|
||||
process.stdout.write(` ${join(senseDir, "package.json")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "src", "schema.ts")}\n`);
|
||||
process.stdout.write(` ${join(senseDir, "migrations", "0001_init.sql")}\n`);
|
||||
|
||||
process.stdout.write("\nInstalling sense dependencies and building…\n");
|
||||
try {
|
||||
await spawnAsync("pnpm", ["install", "--no-cache", "--ignore-workspace"], senseDir);
|
||||
await spawnAsync("pnpm", ["run", "build"], senseDir);
|
||||
process.stdout.write("✅ Build complete — index.js ready.\n");
|
||||
} catch {
|
||||
process.stdout.write(
|
||||
`⚠️ Build failed. Run manually:\n cd ${senseDir} && pnpm install --no-cache --ignore-workspace && 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\` in ${senseDir} 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,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { runForegroundKernelSession } from "../run-foreground-kernel.js";
|
||||
import {
|
||||
type ForegroundSessionOptions,
|
||||
runForegroundKernelSession,
|
||||
} from "../run-foreground-kernel.js";
|
||||
import { loadDaemonModule } from "../workspace-daemon.js";
|
||||
import { getNerveRoot } from "../workspace.js";
|
||||
|
||||
@@ -9,9 +12,25 @@ export const devCommand = defineCommand({
|
||||
name: "dev",
|
||||
description: "Run the nerve kernel in the foreground (development mode)",
|
||||
},
|
||||
async run() {
|
||||
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);
|
||||
await runForegroundKernelSession(nerveRoot, createKernel);
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
+245
-147
@@ -1,5 +1,5 @@
|
||||
import { spawn, execFile } from "node:child_process";
|
||||
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";
|
||||
|
||||
@@ -14,34 +14,100 @@ 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",
|
||||
"@uncaged/nerve-daemon": "latest",
|
||||
"drizzle-orm": "latest"
|
||||
const PNPM_WORKSPACE_YAML = `packages:
|
||||
- 'workflows/*'
|
||||
- 'senses/*'
|
||||
`;
|
||||
|
||||
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"
|
||||
}
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
|
||||
"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: "pnpm -r build",
|
||||
},
|
||||
dependencies: {
|
||||
"@uncaged/nerve-core": "latest",
|
||||
"@uncaged/nerve-daemon": "latest",
|
||||
"@uncaged/nerve-skills": "latest",
|
||||
"drizzle-orm": "latest",
|
||||
},
|
||||
devDependencies: {
|
||||
"@biomejs/biome": "latest",
|
||||
"drizzle-kit": "latest",
|
||||
},
|
||||
pnpm: {
|
||||
onlyBuiltDependencies: ["esbuild"],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
|
||||
const GITIGNORE = `data/
|
||||
logs/
|
||||
nerve.pid
|
||||
node_modules/
|
||||
knowledge.db
|
||||
`;
|
||||
|
||||
const NERVE_SKILLS_MDC = `---
|
||||
description: >-
|
||||
Nerve skills package — where bundled Agent Skills live in this workspace and how to use them
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Nerve skills (\`@uncaged/nerve-skills\`)
|
||||
|
||||
This workspace lists **@uncaged/nerve-skills** in \`package.json\`. It ships **Agent Skills** (one directory per skill, each with a \`SKILL.md\`) for Nerve development and related tasks.
|
||||
|
||||
## After install
|
||||
|
||||
Run your package manager in this workspace (e.g. \`pnpm install\`, \`npm install\` — whatever \`nerve init\` used). Then skills are on disk at:
|
||||
|
||||
- \`node_modules/@uncaged/nerve-skills/<skill-id>/SKILL.md\`
|
||||
|
||||
Example (current catalog):
|
||||
|
||||
- **nerve-dev** — Nerve architecture, CLI, sense/workflow patterns, \`nerve.yaml\`, and conventions: read \`node_modules/@uncaged/nerve-skills/nerve-dev/SKILL.md\`.
|
||||
|
||||
## How to use in an agent
|
||||
|
||||
1. For tasks that match a skill’s **description** (in the \`SKILL.md\` frontmatter), open that \`SKILL.md\` and follow its structure and checklists.
|
||||
2. Prefer the skill as the **source of truth** for Nerve-specific conventions over generic assumptions.
|
||||
3. If the catalog grows, new skills appear as new sibling directories under \`node_modules/@uncaged/nerve-skills/\`.
|
||||
|
||||
Do not commit \`node_modules\`; the dependency is the supported way to get and update skills to match \`@uncaged/nerve-skills\` on npm.
|
||||
`;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -56,9 +122,14 @@ export const cpuUsage = sqliteTable("cpu_usage", {
|
||||
});
|
||||
`;
|
||||
|
||||
const CPU_INDEX_JS = `import { cpus } from "node:os";
|
||||
const CPU_INDEX_TS = `import { cpus } from "node:os";
|
||||
|
||||
export async function compute() {
|
||||
type SenseResult = {
|
||||
signal: { model: string; loadPercent: number; ts: number };
|
||||
workflow: null;
|
||||
};
|
||||
|
||||
export async function compute(): Promise<SenseResult> {
|
||||
const cpuList = cpus();
|
||||
|
||||
let totalIdle = 0;
|
||||
@@ -73,13 +144,34 @@ export async function compute() {
|
||||
const loadPercent = totalTick === 0 ? 0 : ((totalTick - totalIdle) / totalTick) * 100;
|
||||
|
||||
return {
|
||||
model: cpuList[0]?.model ?? "unknown",
|
||||
loadPercent: Math.round(loadPercent * 100) / 100,
|
||||
ts: Date.now(),
|
||||
signal: {
|
||||
model: cpuList[0]?.model ?? "unknown",
|
||||
loadPercent: Math.round(loadPercent * 100) / 100,
|
||||
ts: Date.now(),
|
||||
},
|
||||
workflow: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
const CPU_SENSE_PACKAGE_JSON = `${JSON.stringify(
|
||||
{
|
||||
name: "nerve-sense-cpu-usage",
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build:
|
||||
"esbuild src/index.ts --bundle --platform=node --format=esm --outdir=. --out-extension:.js=.js --packages=external",
|
||||
},
|
||||
devDependencies: {
|
||||
esbuild: "^0.27.0",
|
||||
"drizzle-orm": "*",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
|
||||
const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
@@ -117,90 +209,6 @@ async function detectPackageManager(): Promise<{ cmd: string; installArgs: strin
|
||||
return { cmd: "npm", installArgs: ["install"] };
|
||||
}
|
||||
|
||||
export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
export function validateWorkflowName(name: string): string | null {
|
||||
if (name.length === 0) return "Workflow name must not be empty.";
|
||||
if (name.length > 64) return "Workflow name must be 64 characters or fewer.";
|
||||
if (!WORKFLOW_NAME_RE.test(name))
|
||||
return "Workflow name must contain only lowercase letters, digits, and hyphens, and must not start or end with a hyphen.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildWorkflowTemplate(name: string): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/nerve-daemon";
|
||||
|
||||
const workflow: WorkflowDefinition = {
|
||||
roles: {
|
||||
main: {
|
||||
async execute(prompt, ctx) {
|
||||
ctx.log("${name} started");
|
||||
// TODO: implement your role logic here
|
||||
return { type: "done" };
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
moderate(thread, event) {
|
||||
if (event.type === "thread_start") {
|
||||
return { role: "main", prompt: {} };
|
||||
}
|
||||
return null; // workflow complete
|
||||
},
|
||||
};
|
||||
|
||||
export default workflow;
|
||||
`;
|
||||
}
|
||||
|
||||
const initWorkflowCommand = defineCommand({
|
||||
meta: {
|
||||
name: "workflow",
|
||||
description: "Scaffold a new workflow template in ~/.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 = validateWorkflowName(args.name);
|
||||
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 });
|
||||
writeFile(join(workflowDir, "index.ts"), buildWorkflowTemplate(args.name));
|
||||
|
||||
process.stdout.write(`✅ Workflow scaffolded: ${workflowDir}/index.ts\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 ${workflowDir}/index.ts to implement your roles.\n`);
|
||||
process.stdout.write(" 3. Run `nerve start` to launch the daemon.\n");
|
||||
},
|
||||
});
|
||||
|
||||
const initWorkspaceCommand = defineCommand({
|
||||
meta: {
|
||||
name: "workspace",
|
||||
@@ -212,27 +220,106 @@ const initWorkspaceCommand = 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 }) {
|
||||
await runInitWorkspace(args.force);
|
||||
await runInitWorkspace(args.force, args["skip-install"]);
|
||||
},
|
||||
});
|
||||
|
||||
async function tryRequireSqlite(nerveRoot: string): Promise<boolean> {
|
||||
/** Verify built-in `node:sqlite` (Node.js ≥22.5) loads in a child process. */
|
||||
async function verifyNodeSqlite(): Promise<boolean> {
|
||||
try {
|
||||
const modulePath = join(nerveRoot, "node_modules", "better-sqlite3");
|
||||
// Use a child process to test if the native module loads
|
||||
await execFileAsync("node", ["-e", `require(${JSON.stringify(modulePath)})`], {
|
||||
cwd: nerveRoot,
|
||||
timeout: 10_000,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function runInitWorkspace(force: boolean): Promise<void> {
|
||||
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) {
|
||||
@@ -242,47 +329,45 @@ async function runInitWorkspace(force: boolean): Promise<void> {
|
||||
|
||||
mkdirSync(join(nerveRoot, "data"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "src"), { recursive: true });
|
||||
mkdirSync(join(nerveRoot, "senses", "cpu-usage", "migrations"), { recursive: true });
|
||||
|
||||
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
|
||||
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
|
||||
writeFile(join(nerveRoot, "pnpm-workspace.yaml"), PNPM_WORKSPACE_YAML);
|
||||
writeFile(join(nerveRoot, "biome.json"), BIOME_JSON);
|
||||
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "schema.ts"), CPU_SCHEMA_TS);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "index.js"), CPU_INDEX_JS);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "package.json"), CPU_SENSE_PACKAGE_JSON);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"), CPU_INDEX_TS);
|
||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"), CPU_SCHEMA_TS);
|
||||
writeFile(
|
||||
join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"),
|
||||
CPU_MIGRATION_SQL,
|
||||
);
|
||||
writeFile(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"), NERVE_SKILLS_MDC);
|
||||
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify better-sqlite3 native module — rebuild up to 2 times if broken
|
||||
const sqlitePath = join(nerveRoot, "node_modules", "better-sqlite3");
|
||||
if (existsSync(sqlitePath)) {
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
if (await tryRequireSqlite(nerveRoot)) break;
|
||||
if (!skipInstall) {
|
||||
process.stdout.write("Installing dependencies…\n");
|
||||
const { cmd, installArgs } = await detectPackageManager();
|
||||
try {
|
||||
await runCommand(cmd, installArgs, nerveRoot);
|
||||
} catch {
|
||||
process.stdout.write(
|
||||
`${attempt === 1 ? "Building" : "Retrying build of"} native module better-sqlite3 (attempt ${attempt}/2)…\n`,
|
||||
`⚠️ Install failed. Try manually:\n cd ${nerveRoot} && ${cmd} ${installArgs.join(" ")}\n`,
|
||||
);
|
||||
try {
|
||||
await runCommand(cmd, ["rebuild", "better-sqlite3"], nerveRoot);
|
||||
} catch {
|
||||
// will be caught by the verify below
|
||||
}
|
||||
}
|
||||
if (!(await tryRequireSqlite(nerveRoot))) {
|
||||
|
||||
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(
|
||||
`⚠️ better-sqlite3 native module is not working. The daemon will fail to start.\n` +
|
||||
` Fix: cd ${nerveRoot} && ${cmd} rebuild better-sqlite3\n` +
|
||||
` Or: npm install --build-from-source better-sqlite3\n`,
|
||||
"⚠️ 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -306,7 +391,7 @@ export const initCommand = defineCommand({
|
||||
meta: {
|
||||
name: "init",
|
||||
description:
|
||||
"Initialize workspace (nerve init) or scaffold templates (nerve init workflow <name>)",
|
||||
"Initialize workspace (nerve init), clone from git (nerve init --from <url>), or reinit workspace (nerve init workspace)",
|
||||
},
|
||||
args: {
|
||||
force: {
|
||||
@@ -314,12 +399,25 @@ export const initCommand = defineCommand({
|
||||
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: {
|
||||
workflow: initWorkflowCommand,
|
||||
workspace: initWorkspaceCommand,
|
||||
},
|
||||
async run({ args }) {
|
||||
await runInitWorkspace(args.force);
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -85,7 +85,7 @@ export function buildLogFooter(slice: LogSlice, nArg: number, logPath: string):
|
||||
let footer = `\n📄 ${rangeStr} | ${logPath}\n`;
|
||||
|
||||
if (slice.nextOffset !== null) {
|
||||
footer += `⏩ Earlier lines available. Fetch previous page:\n`;
|
||||
footer += "⏩ Earlier lines available. Fetch previous page:\n";
|
||||
footer += ` nerve logs --offset ${slice.nextOffset} -n ${nArg}\n`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,26 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { type SenseInfo, parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import {
|
||||
type SenseInfo,
|
||||
isPlainRecord,
|
||||
parseNerveConfig,
|
||||
senseTriggerLabels,
|
||||
} from "@uncaged/nerve-core";
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js";
|
||||
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
|
||||
import { isRemoteDaemonCli } from "../cli-global.js";
|
||||
import { resolveDaemonTransport } from "../daemon-client.js";
|
||||
import {
|
||||
defaultPreviewSql,
|
||||
formatRowsAsAlignedTable,
|
||||
listTableSqlStatements,
|
||||
openSenseDb,
|
||||
parseSenseQueryArgs,
|
||||
pickDefaultPreviewTable,
|
||||
} from "../sense-sqlite.js";
|
||||
import { getNerveRoot, isRunning } from "../workspace.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers (exported for tests)
|
||||
@@ -34,7 +49,11 @@ export function formatSenseList(senses: SenseInfo[]): string {
|
||||
lines.push(` group: ${s.group}\n`);
|
||||
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
|
||||
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
|
||||
const lastSignal = s.lastSignalTs !== null ? new Date(s.lastSignalTs).toISOString() : "(never)";
|
||||
lines.push(
|
||||
` trigger schedule: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`,
|
||||
);
|
||||
const lastSignal =
|
||||
s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)";
|
||||
lines.push(` last signal: ${lastSignal}\n`);
|
||||
}
|
||||
return lines.join("");
|
||||
@@ -50,12 +69,14 @@ export function sensesFromConfig(configPath: string): SenseInfo[] {
|
||||
}
|
||||
const result = parseNerveConfig(raw);
|
||||
if (!result.ok) return [];
|
||||
return Object.entries(result.value.senses).map(([name, cfg]) => ({
|
||||
const { senses } = result.value;
|
||||
return Object.entries(senses).map(([name, cfg]) => ({
|
||||
name,
|
||||
group: cfg.group,
|
||||
throttle: cfg.throttle,
|
||||
timeout: cfg.timeout,
|
||||
lastSignalTs: null,
|
||||
triggers: senseTriggerLabels(name, senses),
|
||||
lastSignalTimestamp: null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -69,8 +90,7 @@ const senseListCommand = defineCommand({
|
||||
description: "List all registered senses and their status",
|
||||
},
|
||||
async run() {
|
||||
if (!isRunning()) {
|
||||
// Daemon not running — show static info from nerve.yaml
|
||||
if (!isRemoteDaemonCli() && !isRunning()) {
|
||||
process.stderr.write(
|
||||
"⚠️ Daemon is not running — showing static config only (no last signal time).\n\n",
|
||||
);
|
||||
@@ -80,22 +100,17 @@ const senseListCommand = defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const socketPath = getSocketPath();
|
||||
let response: { ok: true; senses: SenseInfo[] } | { ok: false; error: string };
|
||||
const transport = resolveDaemonTransport();
|
||||
let senses: SenseInfo[];
|
||||
try {
|
||||
response = await listSensesViaDaemon(socketPath);
|
||||
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);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
process.stderr.write(`❌ Daemon error: ${response.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(formatSenseList(response.senses));
|
||||
process.stdout.write(formatSenseList(senses));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -115,15 +130,15 @@ const senseTriggerCommand = defineCommand({
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
if (!isRunning()) {
|
||||
if (!isRemoteDaemonCli() && !isRunning()) {
|
||||
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socketPath = getSocketPath();
|
||||
const transport = resolveDaemonTransport();
|
||||
let response: { ok: true } | { ok: false; error: string };
|
||||
try {
|
||||
response = await triggerSenseViaDaemon(socketPath, args.name);
|
||||
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`);
|
||||
@@ -139,6 +154,116 @@ const senseTriggerCommand = defineCommand({
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense schema <name>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const senseSchemaCommand = defineCommand({
|
||||
meta: {
|
||||
name: "schema",
|
||||
description: "Print CREATE TABLE statements from a sense SQLite database",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
|
||||
},
|
||||
json: {
|
||||
type: "boolean",
|
||||
description: "Print JSON array of CREATE TABLE SQL strings",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
let db: DatabaseSync | undefined;
|
||||
try {
|
||||
db = openSenseDb(nerveRoot, args.name);
|
||||
const statements = listTableSqlStatements(db);
|
||||
if (args.json) {
|
||||
process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`);
|
||||
} else if (statements.length === 0) {
|
||||
process.stdout.write("(no tables)\n");
|
||||
} else {
|
||||
for (const sql of statements) {
|
||||
process.stdout.write(`${sql};\n\n`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ ${msg}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense query <name> [sql...]
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const senseQueryCommand = defineCommand({
|
||||
meta: {
|
||||
name: "query",
|
||||
description:
|
||||
'Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name, or use --sql "…".',
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "positional",
|
||||
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
|
||||
},
|
||||
json: {
|
||||
type: "boolean",
|
||||
description: "Print result rows as JSON",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args, rawArgs }) {
|
||||
const nerveRoot = getNerveRoot();
|
||||
let db: DatabaseSync | undefined;
|
||||
try {
|
||||
let parsed: { name: string; sql: string | undefined };
|
||||
try {
|
||||
parsed = parseSenseQueryArgs(rawArgs);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db = openSenseDb(nerveRoot, args.name);
|
||||
|
||||
let sql = parsed.sql?.trim();
|
||||
if (!sql) {
|
||||
const table = pickDefaultPreviewTable(db);
|
||||
if (table === null) {
|
||||
process.stderr.write("❌ No tables found in database.\n");
|
||||
process.exit(1);
|
||||
} else {
|
||||
sql = defaultPreviewSql(table);
|
||||
}
|
||||
}
|
||||
|
||||
const rawRows: unknown[] = db.prepare(sql).all();
|
||||
const rows: Record<string, unknown>[] = rawRows.filter(isPlainRecord);
|
||||
|
||||
if (args.json) {
|
||||
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
||||
} else {
|
||||
process.stdout.write(formatRowsAsAlignedTable(rows));
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ ${msg}\n`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve sense (parent command)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,5 +276,7 @@ export const senseCommand = defineCommand({
|
||||
subCommands: {
|
||||
list: senseListCommand,
|
||||
trigger: senseTriggerCommand,
|
||||
schema: senseSchemaCommand,
|
||||
query: senseQueryCommand,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createWriteStream, existsSync } from "node:fs";
|
||||
import { createWriteStream, existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import {
|
||||
@@ -56,7 +57,7 @@ function daemonBootstrapScript(): string {
|
||||
);
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -74,10 +75,27 @@ async function runDaemon(nerveRoot: string): Promise<void> {
|
||||
|
||||
const bootstrapPath = daemonBootstrapScript();
|
||||
|
||||
const child = spawn(process.execPath, [bootstrapPath], {
|
||||
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_ROOT: nerveRoot },
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
env,
|
||||
cwd: nerveRoot,
|
||||
});
|
||||
|
||||
@@ -107,8 +125,8 @@ async function runDaemon(nerveRoot: string): Promise<void> {
|
||||
}
|
||||
|
||||
/** Background daemon only — use `nerve dev` for foreground mode. */
|
||||
export async function runDaemonStartCommand(): Promise<void> {
|
||||
await runDaemon(getNerveRoot());
|
||||
export async function runDaemonStartCommand(cliHttpPort: number | null = null): Promise<void> {
|
||||
await runDaemon(getNerveRoot(), cliHttpPort);
|
||||
}
|
||||
|
||||
export const daemonStartCommand = defineCommand({
|
||||
@@ -116,7 +134,23 @@ export const daemonStartCommand = defineCommand({
|
||||
name: "start",
|
||||
description: "Start the nerve daemon in the background",
|
||||
},
|
||||
async run() {
|
||||
await runDaemonStartCommand();
|
||||
args: {
|
||||
port: {
|
||||
type: "string",
|
||||
description: "HTTP API port (overrides nerve.yaml api.port). Omit to use YAML / env only.",
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
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[] = [];
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { isRemoteDaemonCli } from "../cli-global.js";
|
||||
import { resolveDaemonTransport } from "../daemon-client.js";
|
||||
import { isRunning } from "../workspace.js";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_THREAD_BUDGET_CHARS,
|
||||
THREAD_ROUNDS_FETCH_LIMIT,
|
||||
buildInspectOutput,
|
||||
buildListOutput,
|
||||
buildThreadCommandOutput,
|
||||
getAllWorkflowRuns,
|
||||
openStore,
|
||||
parseIntArg,
|
||||
} from "./workflow.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve thread list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const threadListCommand = defineCommand({
|
||||
meta: {
|
||||
name: "list",
|
||||
description: "List active (queued/started) workflow runs from logs",
|
||||
},
|
||||
args: {
|
||||
all: {
|
||||
type: "boolean",
|
||||
description: "Include completed/failed/crashed runs",
|
||||
default: false,
|
||||
},
|
||||
workflow: {
|
||||
type: "string",
|
||||
description: "Filter by workflow name",
|
||||
default: "",
|
||||
},
|
||||
limit: {
|
||||
type: "string",
|
||||
description: `Max runs to show (default: ${DEFAULT_PAGE_SIZE})`,
|
||||
default: String(DEFAULT_PAGE_SIZE),
|
||||
},
|
||||
offset: {
|
||||
type: "string",
|
||||
description: "Skip first N runs (for pagination)",
|
||||
default: "0",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const store = await openStore();
|
||||
|
||||
try {
|
||||
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
|
||||
const offset = Math.max(0, parseIntArg(args.offset, 0));
|
||||
const filterWorkflow = args.workflow.length > 0 ? args.workflow : null;
|
||||
|
||||
const runs = args.all
|
||||
? getAllWorkflowRuns(store, filterWorkflow)
|
||||
: store.getActiveWorkflowRuns(filterWorkflow ?? undefined);
|
||||
|
||||
const { lines, paginationHint } = buildListOutput(
|
||||
runs,
|
||||
offset,
|
||||
limit,
|
||||
args.all,
|
||||
filterWorkflow,
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
if (paginationHint !== null) {
|
||||
process.stdout.write(paginationHint);
|
||||
}
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve thread show <runId>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const threadShowCommand = defineCommand({
|
||||
meta: {
|
||||
name: "show",
|
||||
description: "Print role rounds for a workflow run (agent-oriented, budget-limited)",
|
||||
},
|
||||
args: {
|
||||
runId: {
|
||||
type: "positional",
|
||||
description: "The run ID to dump role rounds for",
|
||||
},
|
||||
before: {
|
||||
type: "string",
|
||||
description:
|
||||
"Exclusive upper bound on 1-based round index (use with hint from prior output to load older rounds)",
|
||||
default: "0",
|
||||
},
|
||||
budget: {
|
||||
type: "string",
|
||||
description: `Max output characters including header (default: ${String(DEFAULT_THREAD_BUDGET_CHARS)})`,
|
||||
default: String(DEFAULT_THREAD_BUDGET_CHARS),
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const store = await openStore();
|
||||
|
||||
try {
|
||||
const before = Math.max(0, parseIntArg(args.before, 0));
|
||||
const budgetChars = Math.max(1, parseIntArg(args.budget, DEFAULT_THREAD_BUDGET_CHARS));
|
||||
|
||||
const run = store.getWorkflowRun(args.runId);
|
||||
if (run === null) {
|
||||
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const startRow = before === 0 ? store.getThreadStartMessage(args.runId) : null;
|
||||
const totalRoleRounds = store.getThreadRoundCount(args.runId);
|
||||
if (totalRoleRounds === 0 && startRow === null) {
|
||||
process.stdout.write(
|
||||
`🧵 Workflow thread: ${run.runId}\n workflow: ${run.workflow}\n status: ${run.status}\n\n📭 No role rounds recorded for this run.\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const descRows = store.getThreadRounds(args.runId, {
|
||||
before,
|
||||
limit: THREAD_ROUNDS_FETCH_LIMIT,
|
||||
});
|
||||
|
||||
const prefixLines = [
|
||||
"🧵 Role rounds (workflow thread)\n",
|
||||
` runId: ${run.runId}\n`,
|
||||
` workflow: ${run.workflow}\n`,
|
||||
` status: ${run.status}\n`,
|
||||
` rounds: ${String(totalRoleRounds)} role event(s) total\n\n`,
|
||||
];
|
||||
|
||||
const { lines, paginationHint } = buildThreadCommandOutput(
|
||||
prefixLines,
|
||||
descRows,
|
||||
budgetChars,
|
||||
args.runId,
|
||||
startRow,
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
if (paginationHint !== null) {
|
||||
process.stdout.write(paginationHint);
|
||||
}
|
||||
|
||||
if (descRows.length === 0 && before > 0) {
|
||||
process.stdout.write(`\n📭 No rounds with index < ${String(before)}.\n`);
|
||||
}
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve thread inspect <runId>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const threadInspectCommand = defineCommand({
|
||||
meta: {
|
||||
name: "inspect",
|
||||
description: "Show details and thread events for a workflow run",
|
||||
},
|
||||
args: {
|
||||
runId: {
|
||||
type: "positional",
|
||||
description: "The run ID to inspect",
|
||||
},
|
||||
limit: {
|
||||
type: "string",
|
||||
description: `Max log entries to show (default: ${DEFAULT_PAGE_SIZE})`,
|
||||
default: String(DEFAULT_PAGE_SIZE),
|
||||
},
|
||||
offset: {
|
||||
type: "string",
|
||||
description: "Skip first N log entries (for pagination)",
|
||||
default: "0",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const store = await openStore();
|
||||
|
||||
try {
|
||||
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
|
||||
const offset = Math.max(0, parseIntArg(args.offset, 0));
|
||||
|
||||
const run = store.getWorkflowRun(args.runId);
|
||||
if (run === null) {
|
||||
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allLogs = store.query({ source: "workflow", refId: args.runId });
|
||||
const { header, eventLines, paginationHint } = buildInspectOutput(
|
||||
run,
|
||||
allLogs,
|
||||
offset,
|
||||
limit,
|
||||
);
|
||||
|
||||
for (const line of [...header, ...eventLines]) {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
if (paginationHint !== null) {
|
||||
process.stdout.write(paginationHint);
|
||||
}
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve thread kill <runId>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const threadKillCommand = defineCommand({
|
||||
meta: {
|
||||
name: "kill",
|
||||
description: "Kill a running or queued workflow thread by runId",
|
||||
},
|
||||
args: {
|
||||
runId: {
|
||||
type: "positional",
|
||||
description: "The run ID to kill",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
if (!isRemoteDaemonCli() && !isRunning()) {
|
||||
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve daemon start`.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const transport = resolveDaemonTransport();
|
||||
let response: { ok: true } | { ok: false; error: string };
|
||||
try {
|
||||
response = await transport.killWorkflow(args.runId);
|
||||
} 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(`❌ Kill failed: ${response.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(`✅ Kill signal sent for run "${args.runId}".\n`);
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve thread (parent command)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const threadCommand = defineCommand({
|
||||
meta: {
|
||||
name: "thread",
|
||||
description: "Inspect and manage workflow threads (runs)",
|
||||
},
|
||||
subCommands: {
|
||||
list: threadListCommand,
|
||||
show: threadShowCommand,
|
||||
inspect: threadInspectCommand,
|
||||
kill: threadKillCommand,
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { validateAgentConfigurationLayer } from "../workflow-agent-validation.js";
|
||||
import { getNerveRoot } from "../workspace.js";
|
||||
|
||||
export const validateCommand = defineCommand({
|
||||
@@ -12,7 +13,8 @@ export const validateCommand = defineCommand({
|
||||
description: "Validate nerve.yaml configuration",
|
||||
},
|
||||
async run() {
|
||||
const configPath = join(getNerveRoot(), "nerve.yaml");
|
||||
const nerveRoot = getNerveRoot();
|
||||
const configPath = join(nerveRoot, "nerve.yaml");
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, "utf8");
|
||||
@@ -29,12 +31,20 @@ export const validateCommand = defineCommand({
|
||||
}
|
||||
|
||||
const config = result.value;
|
||||
const agentLayer = validateAgentConfigurationLayer(config, nerveRoot);
|
||||
if (!agentLayer.ok) {
|
||||
process.stderr.write(`❌ Config validation failed: ${agentLayer.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const senseCount = Object.keys(config.senses).length;
|
||||
const reflexCount = config.reflexes.length;
|
||||
const workflowCount = config.workflows ? Object.keys(config.workflows).length : 0;
|
||||
const triggerScheduleCount = Object.values(config.senses).filter(
|
||||
(s) => s.interval !== null || s.on.length > 0,
|
||||
).length;
|
||||
const workflowCount = Object.keys(config.workflows).length;
|
||||
|
||||
process.stdout.write(
|
||||
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`,
|
||||
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${triggerScheduleCount} sense trigger schedule(s), ${workflowCount} workflow(s)\n`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { DEFAULT_ENGINE_MAX_ROUNDS, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
|
||||
import { defineCommand } from "citty";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
|
||||
import type { LogStore, WorkflowRun } from "../daemon-types.js";
|
||||
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
|
||||
import { isRemoteDaemonCli } from "../cli-global.js";
|
||||
import { resolveDaemonTransport } from "../daemon-client.js";
|
||||
import { formatRowsAsAlignedTable } from "../sense-sqlite.js";
|
||||
import { loadDaemonModule } from "../workspace-daemon.js";
|
||||
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
|
||||
import { getNerveRoot, isRunning } from "../workspace.js";
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
/** Default max characters for `nerve thread show` output (including run header). */
|
||||
export const DEFAULT_THREAD_BUDGET_CHARS = 8000;
|
||||
|
||||
/** Max role-round rows read from SQLite per invocation (DESC by round). */
|
||||
export const THREAD_ROUNDS_FETCH_LIMIT = 8192;
|
||||
|
||||
export function parseIntArg(raw: string, fallback: number): number {
|
||||
const v = Number.parseInt(raw, 10);
|
||||
return Number.isNaN(v) ? fallback : v;
|
||||
@@ -19,11 +29,28 @@ export function getDbPath(): string {
|
||||
return join(getNerveRoot(), "data", "logs.db");
|
||||
}
|
||||
|
||||
export function formatTs(ts: number): string {
|
||||
return new Date(ts).toISOString();
|
||||
/** Human-readable placeholder when a timestamp is missing or not representable as ISO 8601. */
|
||||
export const UNKNOWN_TIMESTAMP_LABEL = "(unknown)";
|
||||
|
||||
/**
|
||||
* Format epoch milliseconds as UTC ISO 8601, or {@link UNKNOWN_TIMESTAMP_LABEL} when the value
|
||||
* is nullish, not a finite number, or cannot be converted (defensive against bad DB / test data).
|
||||
*/
|
||||
export function formatTs(timestampMs: number | null | undefined): string {
|
||||
if (timestampMs === null || timestampMs === undefined) {
|
||||
return UNKNOWN_TIMESTAMP_LABEL;
|
||||
}
|
||||
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) {
|
||||
return UNKNOWN_TIMESTAMP_LABEL;
|
||||
}
|
||||
try {
|
||||
return new Date(timestampMs).toISOString();
|
||||
} catch {
|
||||
return UNKNOWN_TIMESTAMP_LABEL;
|
||||
}
|
||||
}
|
||||
|
||||
async function openStore(): Promise<LogStore> {
|
||||
export async function openStore(): Promise<LogStore> {
|
||||
const nerveRoot = getNerveRoot();
|
||||
const dbPath = getDbPath();
|
||||
if (!existsSync(dbPath)) {
|
||||
@@ -50,6 +77,8 @@ export function statusIcon(status: WorkflowRun["status"]): string {
|
||||
return "🗑";
|
||||
case "interrupted":
|
||||
return "⚠️";
|
||||
case "killed":
|
||||
return "🛑";
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
return `?(${_exhaustive})`;
|
||||
@@ -58,7 +87,7 @@ export function statusIcon(status: WorkflowRun["status"]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all workflow runs from the store, sorted by ts descending (newest first).
|
||||
* Retrieve all workflow runs from the store, sorted by timestamp descending (newest first).
|
||||
* Delegates to the store's efficient SQL query on the workflow_runs table.
|
||||
*/
|
||||
export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | null): WorkflowRun[] {
|
||||
@@ -70,7 +99,8 @@ export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | nul
|
||||
*/
|
||||
export function formatRunLine(run: WorkflowRun): string {
|
||||
const icon = statusIcon(run.status);
|
||||
return ` ${icon} ${run.runId} workflow=${run.workflow} status=${run.status} ts=${formatTs(run.ts)}\n`;
|
||||
const exitCodeStr = run.exitCode !== null ? ` exit_code=${run.exitCode}` : "";
|
||||
return ` ${icon} ${run.runId} workflow=${run.workflow} status=${run.status}${exitCodeStr} timestamp=${formatTs(run.timestamp)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +143,7 @@ export function buildListOutput(
|
||||
const allFlagStr = allFlag ? " --all" : "";
|
||||
paginationHint =
|
||||
`\n⏩ ${remaining} more run(s) not shown. Fetch next page:\n` +
|
||||
` nerve workflow list --offset ${offset + limit}${allFlagStr}${wfFlag}\n`;
|
||||
` nerve thread list --offset ${offset + limit}${allFlagStr}${wfFlag}\n`;
|
||||
}
|
||||
|
||||
return { lines, paginationHint };
|
||||
@@ -130,7 +160,7 @@ export type InspectOutput = {
|
||||
|
||||
export function buildInspectOutput(
|
||||
run: WorkflowRun,
|
||||
allLogs: Array<{ ts: number; type: string; payload: string | null }>,
|
||||
allLogs: Array<{ timestamp: number; type: string; payload: string | null }>,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): InspectOutput {
|
||||
@@ -143,7 +173,7 @@ export function buildInspectOutput(
|
||||
`🔍 Workflow run: ${run.runId}\n`,
|
||||
` workflow: ${run.workflow}\n`,
|
||||
` status: ${run.status}\n`,
|
||||
` ts: ${formatTs(run.ts)}\n`,
|
||||
` timestamp: ${formatTs(run.timestamp)}\n`,
|
||||
`\n📜 Thread events (${shown} of ${total}):\n`,
|
||||
];
|
||||
|
||||
@@ -158,7 +188,7 @@ export function buildInspectOutput(
|
||||
: entry.payload.length <= 200
|
||||
? ` payload=${entry.payload}`
|
||||
: ` payload=${entry.payload.slice(0, 200)}…`;
|
||||
eventLines.push(` [${formatTs(entry.ts)}] type=${entry.type}${payloadStr}\n`);
|
||||
eventLines.push(` [${formatTs(entry.timestamp)}] type=${entry.type}${payloadStr}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,130 +196,231 @@ export function buildInspectOutput(
|
||||
if (remaining > 0) {
|
||||
paginationHint =
|
||||
`\n⏩ ${remaining} more event(s) not shown. Fetch next page:\n` +
|
||||
` nerve workflow inspect ${run.runId} --offset ${offset + limit}\n`;
|
||||
` nerve thread inspect ${run.runId} --offset ${offset + limit}\n`;
|
||||
}
|
||||
|
||||
return { header, eventLines, paginationHint };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve workflow list
|
||||
// nerve thread show <runId> — agent-oriented role rounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PartitionedMessage = {
|
||||
roleStr: string;
|
||||
contentBody: string;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract display fields from a WorkflowMessage-shaped object.
|
||||
* `role` and `content` are used for header/body; `meta` is serialized as YAML frontmatter.
|
||||
*/
|
||||
export function partitionWorkflowMessage(msg: {
|
||||
role: string;
|
||||
content: string;
|
||||
meta: unknown;
|
||||
timestamp: number;
|
||||
}): PartitionedMessage {
|
||||
const roleStr = msg.role;
|
||||
const contentBody = msg.content;
|
||||
const meta =
|
||||
msg.meta !== null && msg.meta !== undefined && typeof msg.meta === "object"
|
||||
? isPlainRecord(msg.meta)
|
||||
? msg.meta
|
||||
: (msg.meta as Record<string, unknown>)
|
||||
: ({} as Record<string, unknown>);
|
||||
return { roleStr, contentBody, meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* One role round as plain text: header line, YAML frontmatter (meta only), body (content).
|
||||
*/
|
||||
export function formatThreadRoundBlock(row: ThreadRoundRow): string {
|
||||
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
|
||||
const yamlBlock =
|
||||
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
|
||||
return `[#${row.round} ${roleStr}] ${formatTs(row.timestamp)}\n---\n${yamlBlock}---\n${contentBody}\n\n`;
|
||||
}
|
||||
|
||||
export type ThreadCommandOutput = {
|
||||
lines: string[];
|
||||
paginationHint: string | null;
|
||||
};
|
||||
|
||||
function buildTruncatedSingleRound(
|
||||
row: ThreadRoundRow,
|
||||
remaining: number,
|
||||
prefixLines: string[],
|
||||
runId: string,
|
||||
budgetFlag: string,
|
||||
): ThreadCommandOutput {
|
||||
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
|
||||
const yamlBlock =
|
||||
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
|
||||
const header = `[#${row.round} ${roleStr}] ${formatTs(row.timestamp)}\n---\n${yamlBlock}---\n`;
|
||||
const maxBody = Math.max(0, remaining - header.length - "[truncated]\n".length);
|
||||
const truncated =
|
||||
maxBody > 0 && contentBody.length > maxBody
|
||||
? `${contentBody.slice(0, maxBody)}\n[truncated]\n`
|
||||
: `${contentBody}\n[truncated]\n`;
|
||||
const single = `${header + truncated}\n`;
|
||||
const hintRound = row.round;
|
||||
return {
|
||||
lines: [...prefixLines, single],
|
||||
paginationHint:
|
||||
hintRound > 1
|
||||
? `\n⏩ Older rounds exist. Fetch with:\n nerve thread show ${runId} --before ${String(hintRound)}${budgetFlag}\n`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build stdout lines for `nerve thread show`: newest-first selection from
|
||||
* `descRows` until `budgetChars` (including `prefixLines`), then chronological order.
|
||||
* When `startRow` is set (typically the persisted `__start__` frame on the first page only),
|
||||
* it is formatted first and its length is subtracted from the budget before consuming `descRows`.
|
||||
*/
|
||||
export function buildThreadCommandOutput(
|
||||
prefixLines: string[],
|
||||
descRows: ThreadRoundRow[],
|
||||
budgetChars: number,
|
||||
runId: string,
|
||||
startRow: ThreadRoundRow | null = null,
|
||||
): ThreadCommandOutput {
|
||||
const prefixText = prefixLines.join("");
|
||||
let remaining = Math.max(0, budgetChars - prefixText.length);
|
||||
const leadingRoundBlocks: string[] = [];
|
||||
|
||||
const budgetFlag =
|
||||
budgetChars === DEFAULT_THREAD_BUDGET_CHARS ? "" : ` --budget ${String(budgetChars)}`;
|
||||
|
||||
if (startRow !== null) {
|
||||
const startBlock = formatThreadRoundBlock(startRow);
|
||||
if (startBlock.length <= remaining) {
|
||||
leadingRoundBlocks.push(startBlock);
|
||||
remaining -= startBlock.length;
|
||||
} else {
|
||||
return buildTruncatedSingleRound(startRow, remaining, prefixLines, runId, budgetFlag);
|
||||
}
|
||||
}
|
||||
|
||||
const picked: ThreadRoundRow[] = [];
|
||||
for (const row of descRows) {
|
||||
const block = formatThreadRoundBlock(row);
|
||||
if (block.length <= remaining) {
|
||||
picked.push(row);
|
||||
remaining -= block.length;
|
||||
continue;
|
||||
}
|
||||
if (picked.length === 0) {
|
||||
return buildTruncatedSingleRound(
|
||||
row,
|
||||
remaining,
|
||||
[...prefixLines, ...leadingRoundBlocks],
|
||||
runId,
|
||||
budgetFlag,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const blocksAsc = picked.map(formatThreadRoundBlock).reverse();
|
||||
const shownMinRound = picked.length === 0 ? null : Math.min(...picked.map((r) => r.round));
|
||||
let paginationHint: string | null = null;
|
||||
if (shownMinRound !== null && shownMinRound > 1) {
|
||||
paginationHint = `\n⏩ Older rounds not shown. Fetch with:\n nerve thread show ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`;
|
||||
}
|
||||
|
||||
return { lines: [...prefixLines, ...leadingRoundBlocks, ...blocksAsc], paginationHint };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve workflow list (reads workflow definitions from workspace YAML)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const workflowListCommand = defineCommand({
|
||||
meta: {
|
||||
name: "list",
|
||||
description: "List active (queued/started) workflow runs",
|
||||
description: "List workflow definitions from nerve.yaml",
|
||||
},
|
||||
args: {
|
||||
all: {
|
||||
type: "boolean",
|
||||
description: "Include completed/failed/crashed runs",
|
||||
default: false,
|
||||
},
|
||||
workflow: {
|
||||
type: "string",
|
||||
description: "Filter by workflow name",
|
||||
default: "",
|
||||
},
|
||||
limit: {
|
||||
type: "string",
|
||||
description: `Max runs to show (default: ${DEFAULT_PAGE_SIZE})`,
|
||||
default: String(DEFAULT_PAGE_SIZE),
|
||||
},
|
||||
offset: {
|
||||
type: "string",
|
||||
description: "Skip first N runs (for pagination)",
|
||||
default: "0",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const store = await openStore();
|
||||
|
||||
async run() {
|
||||
const configPath = join(getNerveRoot(), "nerve.yaml");
|
||||
let raw: string;
|
||||
try {
|
||||
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
|
||||
const offset = Math.max(0, parseIntArg(args.offset, 0));
|
||||
const filterWorkflow = args.workflow.length > 0 ? args.workflow : null;
|
||||
|
||||
const runs = args.all
|
||||
? getAllWorkflowRuns(store, filterWorkflow)
|
||||
: store.getActiveWorkflowRuns(filterWorkflow ?? undefined);
|
||||
|
||||
const { lines, paginationHint } = buildListOutput(
|
||||
runs,
|
||||
offset,
|
||||
limit,
|
||||
args.all,
|
||||
filterWorkflow,
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
if (paginationHint !== null) {
|
||||
process.stdout.write(paginationHint);
|
||||
}
|
||||
} finally {
|
||||
store.close();
|
||||
raw = readFileSync(configPath, "utf8");
|
||||
} catch {
|
||||
process.stderr.write(`❌ Could not read ${configPath}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = parseNerveConfig(raw);
|
||||
if (!result.ok) {
|
||||
process.stderr.write(`❌ Config validation failed: ${result.error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = result.value;
|
||||
const workflowEntries = Object.entries(config.workflows);
|
||||
|
||||
if (workflowEntries.length === 0) {
|
||||
process.stdout.write("📭 No workflows defined in nerve.yaml.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = workflowEntries.map(([name, wf]) => ({
|
||||
name,
|
||||
concurrency: wf.concurrency,
|
||||
overflow: wf.overflow,
|
||||
...(wf.overflow === "queue" ? { maxQueue: wf.maxQueue } : {}),
|
||||
}));
|
||||
|
||||
process.stdout.write(`📋 Workflow definitions (${String(rows.length)}):\n\n`);
|
||||
process.stdout.write(formatRowsAsAlignedTable(rows));
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// nerve workflow inspect <runId>
|
||||
// nerve workflow status (daemon — registered workflows + queue depth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const workflowInspectCommand = defineCommand({
|
||||
const workflowStatusCommand = defineCommand({
|
||||
meta: {
|
||||
name: "inspect",
|
||||
description: "Show details and thread events for a workflow run",
|
||||
name: "status",
|
||||
description: "Show live workflow status from the running daemon (concurrency, active, queued)",
|
||||
},
|
||||
args: {
|
||||
runId: {
|
||||
type: "positional",
|
||||
description: "The run ID to inspect",
|
||||
},
|
||||
limit: {
|
||||
type: "string",
|
||||
description: `Max log entries to show (default: ${DEFAULT_PAGE_SIZE})`,
|
||||
default: String(DEFAULT_PAGE_SIZE),
|
||||
},
|
||||
offset: {
|
||||
type: "string",
|
||||
description: "Skip first N log entries (for pagination)",
|
||||
default: "0",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const store = await openStore();
|
||||
|
||||
try {
|
||||
const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE));
|
||||
const offset = Math.max(0, parseIntArg(args.offset, 0));
|
||||
|
||||
const run = store.getWorkflowRun(args.runId);
|
||||
if (run === null) {
|
||||
process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allLogs = store.query({ source: "workflow", refId: args.runId });
|
||||
const { header, eventLines, paginationHint } = buildInspectOutput(
|
||||
run,
|
||||
allLogs,
|
||||
offset,
|
||||
limit,
|
||||
);
|
||||
|
||||
for (const line of [...header, ...eventLines]) {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
if (paginationHint !== null) {
|
||||
process.stdout.write(paginationHint);
|
||||
}
|
||||
} finally {
|
||||
store.close();
|
||||
async run() {
|
||||
if (!isRemoteDaemonCli() && !isRunning()) {
|
||||
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve daemon start`.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const transport = resolveDaemonTransport();
|
||||
let workflows: Awaited<ReturnType<typeof transport.listWorkflows>>;
|
||||
try {
|
||||
workflows = await transport.listWorkflows();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rows = workflows.map((w) => ({
|
||||
name: w.name,
|
||||
active: w.activeThreads,
|
||||
runIds: w.activeRunIds.length > 0 ? w.activeRunIds.join(", ") : "—",
|
||||
queued: w.queuedThreads,
|
||||
concurrency: w.config.concurrency,
|
||||
overflow: w.config.overflow,
|
||||
}));
|
||||
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("📭 No workflows in nerve.yaml (or empty registry).\n");
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`📋 Workflows (${String(rows.length)}):\n\n`);
|
||||
process.stdout.write(formatRowsAsAlignedTable(rows));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -297,6 +428,21 @@ const workflowInspectCommand = defineCommand({
|
||||
// nerve workflow trigger <name>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readWorkspaceDefaultMaxRounds(): number {
|
||||
const configPath = join(getNerveRoot(), "nerve.yaml");
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, "utf8");
|
||||
} catch {
|
||||
return DEFAULT_ENGINE_MAX_ROUNDS;
|
||||
}
|
||||
const result = parseNerveConfig(raw);
|
||||
if (!result.ok) {
|
||||
return DEFAULT_ENGINE_MAX_ROUNDS;
|
||||
}
|
||||
return result.value.maxRounds;
|
||||
}
|
||||
|
||||
const workflowTriggerCommand = defineCommand({
|
||||
meta: {
|
||||
name: "trigger",
|
||||
@@ -307,30 +453,38 @@ const workflowTriggerCommand = defineCommand({
|
||||
type: "positional",
|
||||
description: "The workflow name to trigger",
|
||||
},
|
||||
payload: {
|
||||
maxRounds: {
|
||||
type: "string",
|
||||
description: "JSON payload to pass as trigger payload (default: {})",
|
||||
default: "{}",
|
||||
description: "Max moderator rounds (default: nerve.yaml maxRounds)",
|
||||
default: "",
|
||||
},
|
||||
prompt: {
|
||||
type: "string",
|
||||
description: "Initial prompt for the workflow run",
|
||||
default: "",
|
||||
},
|
||||
dryRun: {
|
||||
type: "boolean",
|
||||
description: "Run the workflow in dry-run mode",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
let triggerPayload: unknown = {};
|
||||
try {
|
||||
triggerPayload = JSON.parse(args.payload) as unknown;
|
||||
} catch {
|
||||
process.stderr.write(`❌ --payload must be valid JSON. Got: ${args.payload}\n`);
|
||||
const prompt = args.prompt;
|
||||
const defaultMax = readWorkspaceDefaultMaxRounds();
|
||||
const maxRounds =
|
||||
args.maxRounds.length > 0 ? parseIntArg(args.maxRounds, defaultMax) : defaultMax;
|
||||
const dryRun = args.dryRun;
|
||||
|
||||
if (!isRemoteDaemonCli() && !isRunning()) {
|
||||
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve daemon start`.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!isRunning()) {
|
||||
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const socketPath = getSocketPath();
|
||||
const transport = resolveDaemonTransport();
|
||||
let response: { ok: true } | { ok: false; error: string };
|
||||
try {
|
||||
response = await triggerWorkflowViaDaemon(socketPath, args.name, triggerPayload);
|
||||
response = await transport.triggerWorkflow(args.name, { prompt, maxRounds, dryRun });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
|
||||
@@ -343,7 +497,7 @@ const workflowTriggerCommand = defineCommand({
|
||||
}
|
||||
|
||||
process.stdout.write(`✅ Triggered workflow "${args.name}" via daemon.\n`);
|
||||
process.stdout.write("\n💡 Inspect active runs with: nerve workflow list\n");
|
||||
process.stdout.write("\n💡 Inspect active runs with: nerve thread list\n");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -354,11 +508,11 @@ const workflowTriggerCommand = defineCommand({
|
||||
export const workflowCommand = defineCommand({
|
||||
meta: {
|
||||
name: "workflow",
|
||||
description: "Manage and inspect workflow runs",
|
||||
description: "Manage workflow definitions and trigger workflows",
|
||||
},
|
||||
subCommands: {
|
||||
list: workflowListCommand,
|
||||
inspect: workflowInspectCommand,
|
||||
status: workflowStatusCommand,
|
||||
trigger: workflowTriggerCommand,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "@uncaged/nerve-daemon/experimental-warning-suppression.js";
|
||||
|
||||
import { runForegroundKernelSession } from "./run-foreground-kernel.js";
|
||||
import { loadDaemonModule } from "./workspace-daemon.js";
|
||||
|
||||
|
||||
@@ -8,22 +8,39 @@
|
||||
import { connect } from "node:net";
|
||||
import type { Socket } from "node:net";
|
||||
|
||||
import type { SenseInfo } from "@uncaged/nerve-core";
|
||||
import type {
|
||||
DaemonIpcListSensesResponse,
|
||||
DaemonIpcListWorkflowsResponse,
|
||||
DaemonIpcRequest,
|
||||
DaemonIpcTriggerResponse,
|
||||
DaemonTransport,
|
||||
DaemonTransportTriggerResult,
|
||||
DaemonTransportWorkflowLaunch,
|
||||
HealthInfo,
|
||||
SenseInfo,
|
||||
WorkflowStatus,
|
||||
} from "@uncaged/nerve-core";
|
||||
import {
|
||||
DEFAULT_ENGINE_MAX_ROUNDS,
|
||||
isPlainRecord,
|
||||
isSenseInfo,
|
||||
isWorkflowStatus,
|
||||
} from "@uncaged/nerve-core";
|
||||
|
||||
import { getCliDaemonApiToken, getCliDaemonHost } from "./cli-global.js";
|
||||
import { HttpTransport } from "./http-transport.js";
|
||||
import { getSocketPath } from "./workspace.js";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 3_000;
|
||||
const RESPONSE_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type { SenseInfo };
|
||||
|
||||
type TriggerResponse = { ok: true } | { ok: false; error: string };
|
||||
|
||||
type ListSensesResponse = { ok: true; senses: SenseInfo[] } | { ok: false; error: string };
|
||||
|
||||
function parseDaemonResponse(line: string): TriggerResponse {
|
||||
function parseDaemonResponse(line: string): DaemonIpcTriggerResponse {
|
||||
try {
|
||||
const obj = JSON.parse(line) as unknown;
|
||||
if (obj !== null && typeof obj === "object") {
|
||||
const r = obj as Record<string, unknown>;
|
||||
const obj: unknown = JSON.parse(line);
|
||||
if (isPlainRecord(obj)) {
|
||||
const r = obj;
|
||||
if (r.ok === true) return { ok: true };
|
||||
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
|
||||
}
|
||||
@@ -33,14 +50,15 @@ function parseDaemonResponse(line: string): TriggerResponse {
|
||||
return { ok: false, error: `Unexpected daemon response: ${line}` };
|
||||
}
|
||||
|
||||
function parseListSensesResponse(line: string): ListSensesResponse {
|
||||
function parseListSensesResponse(line: string): DaemonIpcListSensesResponse {
|
||||
try {
|
||||
const obj = JSON.parse(line) as unknown;
|
||||
if (obj !== null && typeof obj === "object") {
|
||||
const r = obj as Record<string, unknown>;
|
||||
const obj: unknown = JSON.parse(line);
|
||||
if (isPlainRecord(obj)) {
|
||||
const r = obj;
|
||||
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
|
||||
if (r.ok === true && Array.isArray(r.senses))
|
||||
return { ok: true, senses: r.senses as SenseInfo[] };
|
||||
if (r.ok === true && Array.isArray(r.senses) && r.senses.every(isSenseInfo)) {
|
||||
return { ok: true, senses: r.senses };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
@@ -48,13 +66,58 @@ function parseListSensesResponse(line: string): ListSensesResponse {
|
||||
return { ok: false, error: `Unexpected daemon response: ${line}` };
|
||||
}
|
||||
|
||||
function parseListWorkflowsResponse(line: string): DaemonIpcListWorkflowsResponse {
|
||||
try {
|
||||
const obj: unknown = JSON.parse(line);
|
||||
if (isPlainRecord(obj)) {
|
||||
const r = obj;
|
||||
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
|
||||
if (r.ok === true && Array.isArray(r.workflows) && r.workflows.every(isWorkflowStatus)) {
|
||||
return { ok: true, workflows: r.workflows };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return { ok: false, error: `Unexpected daemon response: ${line}` };
|
||||
}
|
||||
|
||||
function parseHealthResponse(line: string): HealthInfo | null {
|
||||
try {
|
||||
const obj: unknown = JSON.parse(line);
|
||||
if (!isPlainRecord(obj)) return null;
|
||||
const r = obj;
|
||||
if (r.ok === true && isPlainRecord(r.health)) {
|
||||
const h = r.health;
|
||||
if (
|
||||
typeof h.ok === "boolean" &&
|
||||
typeof h.version === "string" &&
|
||||
typeof h.uptime === "number" &&
|
||||
typeof h.startedAt === "string" &&
|
||||
typeof h.hostname === "string"
|
||||
) {
|
||||
return {
|
||||
ok: h.ok,
|
||||
version: h.version,
|
||||
uptime: h.uptime,
|
||||
startedAt: h.startedAt,
|
||||
hostname: h.hostname,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the daemon socket, send one JSON request (newline-terminated),
|
||||
* and resolve with the first non-empty line parsed by `parseFirstLine`.
|
||||
*/
|
||||
function sendAndReceive<T>(
|
||||
socketPath: string,
|
||||
message: object,
|
||||
message: DaemonIpcRequest,
|
||||
parseFirstLine: (trimmed: string) => T,
|
||||
responseTimeoutMs: number = RESPONSE_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
@@ -112,6 +175,78 @@ function sendAndReceive<T>(
|
||||
});
|
||||
}
|
||||
|
||||
/** Unix-socket implementation of {@link DaemonTransport} (local daemon). */
|
||||
export class UnixTransport implements DaemonTransport {
|
||||
readonly socketPath: string;
|
||||
constructor(socketPath: string) {
|
||||
this.socketPath = socketPath;
|
||||
}
|
||||
|
||||
async health(): Promise<HealthInfo> {
|
||||
const parsed = await sendAndReceive(this.socketPath, { type: "health" }, (line) =>
|
||||
parseHealthResponse(line),
|
||||
);
|
||||
if (parsed === null) {
|
||||
throw new Error("Unexpected daemon response for health");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async listSenses(): Promise<SenseInfo[]> {
|
||||
const r = await sendAndReceive(
|
||||
this.socketPath,
|
||||
{ type: "list-senses" },
|
||||
parseListSensesResponse,
|
||||
);
|
||||
if (!r.ok) {
|
||||
throw new Error(r.error);
|
||||
}
|
||||
return r.senses;
|
||||
}
|
||||
|
||||
async listWorkflows(): Promise<WorkflowStatus[]> {
|
||||
const r = await sendAndReceive(
|
||||
this.socketPath,
|
||||
{ type: "list-workflows" },
|
||||
parseListWorkflowsResponse,
|
||||
);
|
||||
if (!r.ok) {
|
||||
throw new Error(r.error);
|
||||
}
|
||||
return r.workflows;
|
||||
}
|
||||
|
||||
async triggerSense(name: string): Promise<DaemonTransportTriggerResult> {
|
||||
return sendAndReceive(
|
||||
this.socketPath,
|
||||
{ type: "trigger-sense", sense: name },
|
||||
parseDaemonResponse,
|
||||
);
|
||||
}
|
||||
|
||||
async triggerWorkflow(
|
||||
name: string,
|
||||
launch: DaemonTransportWorkflowLaunch | null,
|
||||
): Promise<DaemonTransportTriggerResult> {
|
||||
const prompt = launch !== null ? launch.prompt : "";
|
||||
const maxRounds = launch !== null ? launch.maxRounds : DEFAULT_ENGINE_MAX_ROUNDS;
|
||||
const dryRun = launch !== null ? launch.dryRun : false;
|
||||
const message: DaemonIpcRequest = {
|
||||
type: "trigger-workflow",
|
||||
workflow: name,
|
||||
prompt,
|
||||
maxRounds,
|
||||
dryRun,
|
||||
};
|
||||
return sendAndReceive(this.socketPath, message, parseDaemonResponse);
|
||||
}
|
||||
|
||||
async killWorkflow(runId: string): Promise<DaemonTransportTriggerResult> {
|
||||
const message: DaemonIpcRequest = { type: "kill-workflow", runId };
|
||||
return sendAndReceive(this.socketPath, message, parseDaemonResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a trigger-workflow message to the running daemon via its Unix socket.
|
||||
* Resolves with the daemon's response or rejects on connection/timeout errors.
|
||||
@@ -119,27 +254,73 @@ function sendAndReceive<T>(
|
||||
export function triggerWorkflowViaDaemon(
|
||||
socketPath: string,
|
||||
workflow: string,
|
||||
payload: unknown,
|
||||
): Promise<TriggerResponse> {
|
||||
return sendAndReceive(
|
||||
socketPath,
|
||||
{ type: "trigger-workflow", workflow, payload },
|
||||
parseDaemonResponse,
|
||||
);
|
||||
prompt: string,
|
||||
maxRounds: number,
|
||||
dryRun = false,
|
||||
): Promise<DaemonIpcTriggerResponse> {
|
||||
const message: DaemonIpcRequest = {
|
||||
type: "trigger-workflow",
|
||||
workflow,
|
||||
prompt,
|
||||
maxRounds,
|
||||
dryRun,
|
||||
};
|
||||
return sendAndReceive(socketPath, message, parseDaemonResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a trigger-sense message to the running daemon via its Unix socket.
|
||||
* Resolves with the daemon's response or rejects on connection/timeout errors.
|
||||
*/
|
||||
export function triggerSenseViaDaemon(socketPath: string, sense: string): Promise<TriggerResponse> {
|
||||
return sendAndReceive(socketPath, { type: "trigger-sense", sense }, parseDaemonResponse);
|
||||
export function triggerSenseViaDaemon(
|
||||
socketPath: string,
|
||||
sense: string,
|
||||
): Promise<DaemonIpcTriggerResponse> {
|
||||
const message: DaemonIpcRequest = { type: "trigger-sense", sense };
|
||||
return sendAndReceive(socketPath, message, parseDaemonResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a list-senses message to the running daemon via its Unix socket.
|
||||
* Resolves with the list of registered senses or rejects on connection/timeout errors.
|
||||
*/
|
||||
export function listSensesViaDaemon(socketPath: string): Promise<ListSensesResponse> {
|
||||
return sendAndReceive(socketPath, { type: "list-senses" }, parseListSensesResponse);
|
||||
export function listSensesViaDaemon(socketPath: string): Promise<DaemonIpcListSensesResponse> {
|
||||
const message: DaemonIpcRequest = { type: "list-senses" };
|
||||
return sendAndReceive(socketPath, message, parseListSensesResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a list-workflows message to the running daemon via its Unix socket.
|
||||
*/
|
||||
export function listWorkflowsViaDaemon(
|
||||
socketPath: string,
|
||||
): Promise<DaemonIpcListWorkflowsResponse> {
|
||||
const message: DaemonIpcRequest = { type: "list-workflows" };
|
||||
return sendAndReceive(socketPath, message, parseListWorkflowsResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a kill-workflow message to the running daemon via its Unix socket.
|
||||
* Resolves with the daemon's response or rejects on connection/timeout errors.
|
||||
*/
|
||||
export function killWorkflowViaDaemon(
|
||||
socketPath: string,
|
||||
runId: string,
|
||||
): Promise<DaemonIpcTriggerResponse> {
|
||||
const message: DaemonIpcRequest = { type: "kill-workflow", runId };
|
||||
return sendAndReceive(socketPath, message, parseDaemonResponse);
|
||||
}
|
||||
|
||||
/** Unix socket when no `--host`; otherwise {@link HttpTransport} for remote HTTP API. */
|
||||
export function resolveDaemonTransport(): DaemonTransport {
|
||||
const host = getCliDaemonHost();
|
||||
if (host !== null && host.length > 0) {
|
||||
const tok = getCliDaemonApiToken();
|
||||
return tok !== null && tok.length > 0
|
||||
? new HttpTransport({ host, token: tok })
|
||||
: new HttpTransport({ host });
|
||||
}
|
||||
return new UnixTransport(getSocketPath());
|
||||
}
|
||||
|
||||
export { HttpTransport } from "./http-transport.js";
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Structural types for workflow CLI — mirrors @uncaged/nerve-daemon log-store
|
||||
* public API so the CLI runtime does not statically depend on the daemon package.
|
||||
*
|
||||
* ⚠️ Keep in sync with @uncaged/nerve-daemon exports.
|
||||
* Run `pnpm --filter @uncaged/nerve-cli test` to catch drift via satisfies assertions.
|
||||
*/
|
||||
|
||||
export type WorkflowRunStatus =
|
||||
| "queued"
|
||||
| "started"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "crashed"
|
||||
| "dropped"
|
||||
| "interrupted";
|
||||
|
||||
export type WorkflowRun = {
|
||||
runId: string;
|
||||
workflow: string;
|
||||
status: WorkflowRunStatus;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type LogEntry = {
|
||||
id?: number;
|
||||
source: string;
|
||||
type: string;
|
||||
refId: string | null;
|
||||
payload: string | null;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type LogQuery = {
|
||||
source?: string;
|
||||
type?: string;
|
||||
refId?: string;
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ArchiveLogsOptions = {
|
||||
now?: number;
|
||||
vacuum?: boolean;
|
||||
maxDays?: number;
|
||||
retentionMs?: number;
|
||||
};
|
||||
|
||||
export type ArchiveLogsDayResult = {
|
||||
day: string;
|
||||
rowCount: number;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
export type ArchiveLogsResult = {
|
||||
days: ArchiveLogsDayResult[];
|
||||
vacuumed: boolean;
|
||||
};
|
||||
|
||||
/** Subset of daemon LogStore used by the CLI workflow commands. */
|
||||
export type LogStore = {
|
||||
query: (filter?: LogQuery) => LogEntry[];
|
||||
getWorkflowRun: (runId: string) => WorkflowRun | null;
|
||||
getActiveWorkflowRuns: (workflowName?: string) => WorkflowRun[];
|
||||
getAllWorkflowRuns: (workflowName: string | null) => WorkflowRun[];
|
||||
upsertWorkflowRun: (entry: Omit<LogEntry, "id">, run: WorkflowRun) => LogEntry;
|
||||
archiveLogs: (options?: ArchiveLogsOptions) => ArchiveLogsResult;
|
||||
close: () => void;
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
DaemonTransport,
|
||||
DaemonTransportTriggerResult,
|
||||
DaemonTransportWorkflowLaunch,
|
||||
HealthInfo,
|
||||
SenseInfo,
|
||||
WorkflowStatus,
|
||||
} from "@uncaged/nerve-core";
|
||||
import {
|
||||
DEFAULT_ENGINE_MAX_ROUNDS,
|
||||
isPlainRecord,
|
||||
isSenseInfo,
|
||||
isWorkflowStatus,
|
||||
} from "@uncaged/nerve-core";
|
||||
|
||||
function normalizeBaseUrl(host: string): string {
|
||||
const t = host.trim();
|
||||
const withScheme = t.startsWith("http://") || t.startsWith("https://") ? t : `http://${t}`;
|
||||
return withScheme.endsWith("/") ? withScheme.slice(0, -1) : withScheme;
|
||||
}
|
||||
|
||||
function isHealthInfo(value: unknown): value is HealthInfo {
|
||||
if (!isPlainRecord(value)) return false;
|
||||
return (
|
||||
typeof value.ok === "boolean" &&
|
||||
typeof value.version === "string" &&
|
||||
typeof value.uptime === "number" &&
|
||||
typeof value.startedAt === "string" &&
|
||||
typeof value.hostname === "string"
|
||||
);
|
||||
}
|
||||
|
||||
async function readJsonBody(res: Response): Promise<unknown> {
|
||||
const text = await res.text();
|
||||
if (text.trim().length === 0) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function httpErrorMessage(status: number, body: unknown): string {
|
||||
if (isPlainRecord(body) && body.ok === false && typeof body.error === "string") {
|
||||
return body.error;
|
||||
}
|
||||
return `HTTP ${String(status)}`;
|
||||
}
|
||||
|
||||
/** Remote daemon control plane via JSON HTTP API (Phase 2). */
|
||||
export class HttpTransport implements DaemonTransport {
|
||||
private readonly baseUrl: string;
|
||||
private readonly token: string | null;
|
||||
|
||||
constructor(opts: { host: string; token?: string | null }) {
|
||||
this.baseUrl = normalizeBaseUrl(opts.host);
|
||||
this.token =
|
||||
opts.token !== undefined && opts.token !== null && opts.token.length > 0 ? opts.token : null;
|
||||
}
|
||||
|
||||
private baseHeaders(): Record<string, string> {
|
||||
const h: Record<string, string> = { Accept: "application/json" };
|
||||
if (this.token !== null) {
|
||||
h.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
async health(): Promise<HealthInfo> {
|
||||
const res = await fetch(`${this.baseUrl}/api/health`, {
|
||||
headers: this.baseHeaders(),
|
||||
});
|
||||
const body = await readJsonBody(res);
|
||||
if (res.status === 401) {
|
||||
throw new Error(httpErrorMessage(res.status, body));
|
||||
}
|
||||
if (!res.ok || !isHealthInfo(body)) {
|
||||
throw new Error(httpErrorMessage(res.status, body));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async listSenses(): Promise<SenseInfo[]> {
|
||||
const res = await fetch(`${this.baseUrl}/api/senses`, {
|
||||
headers: this.baseHeaders(),
|
||||
});
|
||||
const body = await readJsonBody(res);
|
||||
if (res.status === 401) {
|
||||
throw new Error(httpErrorMessage(res.status, body));
|
||||
}
|
||||
if (!res.ok || !isPlainRecord(body) || !Array.isArray(body.senses)) {
|
||||
throw new Error(httpErrorMessage(res.status, body));
|
||||
}
|
||||
if (!body.senses.every(isSenseInfo)) {
|
||||
throw new Error("Unexpected senses payload from daemon HTTP API");
|
||||
}
|
||||
return body.senses;
|
||||
}
|
||||
|
||||
async listWorkflows(): Promise<WorkflowStatus[]> {
|
||||
const res = await fetch(`${this.baseUrl}/api/workflows`, {
|
||||
headers: this.baseHeaders(),
|
||||
});
|
||||
const body = await readJsonBody(res);
|
||||
if (res.status === 401) {
|
||||
throw new Error(httpErrorMessage(res.status, body));
|
||||
}
|
||||
if (!res.ok || !isPlainRecord(body) || !Array.isArray(body.workflows)) {
|
||||
throw new Error(httpErrorMessage(res.status, body));
|
||||
}
|
||||
if (!body.workflows.every(isWorkflowStatus)) {
|
||||
throw new Error("Unexpected workflows payload from daemon HTTP API");
|
||||
}
|
||||
return body.workflows;
|
||||
}
|
||||
|
||||
async triggerSense(name: string): Promise<DaemonTransportTriggerResult> {
|
||||
const res = await fetch(`${this.baseUrl}/api/trigger-sense`, {
|
||||
method: "POST",
|
||||
headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const body = await readJsonBody(res);
|
||||
if (res.status === 401) {
|
||||
return { ok: false, error: httpErrorMessage(res.status, body) };
|
||||
}
|
||||
if (!isPlainRecord(body)) {
|
||||
return { ok: false, error: httpErrorMessage(res.status, body) };
|
||||
}
|
||||
if (body.ok === true) return { ok: true };
|
||||
if (body.ok === false && typeof body.error === "string")
|
||||
return { ok: false, error: body.error };
|
||||
return { ok: false, error: httpErrorMessage(res.status, body) };
|
||||
}
|
||||
|
||||
async triggerWorkflow(
|
||||
name: string,
|
||||
launch: DaemonTransportWorkflowLaunch | null,
|
||||
): Promise<DaemonTransportTriggerResult> {
|
||||
const L =
|
||||
launch !== null
|
||||
? launch
|
||||
: { prompt: "", maxRounds: DEFAULT_ENGINE_MAX_ROUNDS, dryRun: false };
|
||||
const res = await fetch(`${this.baseUrl}/api/trigger-workflow`, {
|
||||
method: "POST",
|
||||
headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
prompt: L.prompt,
|
||||
maxRounds: L.maxRounds,
|
||||
dryRun: L.dryRun,
|
||||
}),
|
||||
});
|
||||
const body = await readJsonBody(res);
|
||||
if (res.status === 401) {
|
||||
return { ok: false, error: httpErrorMessage(res.status, body) };
|
||||
}
|
||||
if (!isPlainRecord(body)) {
|
||||
return { ok: false, error: httpErrorMessage(res.status, body) };
|
||||
}
|
||||
if (body.ok === true) return { ok: true };
|
||||
if (body.ok === false && typeof body.error === "string")
|
||||
return { ok: false, error: body.error };
|
||||
return { ok: false, error: httpErrorMessage(res.status, body) };
|
||||
}
|
||||
|
||||
async killWorkflow(runId: string): Promise<DaemonTransportTriggerResult> {
|
||||
const res = await fetch(`${this.baseUrl}/api/kill-workflow`, {
|
||||
method: "POST",
|
||||
headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ runId }),
|
||||
});
|
||||
const body = await readJsonBody(res);
|
||||
if (res.status === 401) {
|
||||
return { ok: false, error: httpErrorMessage(res.status, body) };
|
||||
}
|
||||
if (!isPlainRecord(body)) {
|
||||
return { ok: false, error: httpErrorMessage(res.status, body) };
|
||||
}
|
||||
if (body.ok === true) return { ok: true };
|
||||
if (body.ok === false && typeof body.error === "string")
|
||||
return { ok: false, error: body.error };
|
||||
return { ok: false, error: httpErrorMessage(res.status, body) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/;
|
||||
|
||||
export type MarkdownChunk = {
|
||||
slug: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function slugPart(title: string): string {
|
||||
const t = title.trim().toLowerCase().replace(/\s+/g, "-");
|
||||
const safe = t.replace(/[^a-z0-9_-]+/g, "");
|
||||
return safe.length > 0 ? safe : "section";
|
||||
}
|
||||
|
||||
function splitLargeMarkdownChunk(slugBase: string, text: string): MarkdownChunk[] {
|
||||
const maxParagraphs = 24;
|
||||
const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
|
||||
if (paragraphs.length <= maxParagraphs) {
|
||||
return [{ slug: slugBase, text }];
|
||||
}
|
||||
const chunks: MarkdownChunk[] = [];
|
||||
let part = 0;
|
||||
for (let i = 0; i < paragraphs.length; i += maxParagraphs) {
|
||||
const slice = paragraphs.slice(i, i + maxParagraphs).join("\n\n");
|
||||
chunks.push({ slug: `${slugBase}-part${String(part)}`, text: slice });
|
||||
part += 1;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function headingLineIndices(lines: string[]): number[] {
|
||||
const headingIdx: number[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line !== undefined && HEADING_RE.test(line)) {
|
||||
headingIdx.push(i);
|
||||
}
|
||||
}
|
||||
return headingIdx;
|
||||
}
|
||||
|
||||
function chunksFromHeadings(
|
||||
lines: string[],
|
||||
headingIdx: number[],
|
||||
baseSlug: string,
|
||||
): MarkdownChunk[] {
|
||||
const chunks: MarkdownChunk[] = [];
|
||||
const firstHead = headingIdx[0] ?? 0;
|
||||
if (firstHead > 0) {
|
||||
const preamble = lines.slice(0, firstHead).join("\n").trim();
|
||||
if (preamble.length > 0) {
|
||||
chunks.push(...splitLargeMarkdownChunk(`${baseSlug}#preamble`, preamble));
|
||||
}
|
||||
}
|
||||
|
||||
for (let h = 0; h < headingIdx.length; h++) {
|
||||
const start = headingIdx[h] ?? 0;
|
||||
const end = h + 1 < headingIdx.length ? (headingIdx[h + 1] ?? lines.length) : lines.length;
|
||||
const block = lines.slice(start, end).join("\n").trim();
|
||||
if (block.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const titleLine = lines[start] ?? "";
|
||||
const ht = HEADING_RE.exec(titleLine);
|
||||
const suffix = ht !== null ? slugPart(ht[2] ?? "h") : `h${String(h)}`;
|
||||
chunks.push(...splitLargeMarkdownChunk(`${baseSlug}#${suffix}-${String(h)}`, block));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split Markdown by headings; long sections are split further by blank-line paragraphs.
|
||||
*/
|
||||
export function chunkMarkdown(relativePath: string, source: string): MarkdownChunk[] {
|
||||
const lines = source.split(/\r?\n/);
|
||||
const headingIdx = headingLineIndices(lines);
|
||||
const baseSlug = relativePath.replace(/\//g, "-");
|
||||
|
||||
if (headingIdx.length === 0) {
|
||||
const text = source.trim();
|
||||
if (text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return splitLargeMarkdownChunk(`${baseSlug}#doc`, text);
|
||||
}
|
||||
|
||||
const chunks = chunksFromHeadings(lines, headingIdx, baseSlug);
|
||||
return chunks;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export type TsJsChunk = {
|
||||
slug: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Line starts a function-like declaration (heuristic, no full TS parse).
|
||||
*/
|
||||
function isFunctionStartLine(line: string): boolean {
|
||||
const t = line.trimStart();
|
||||
if (/^(export\s+)?declare\s+/.test(t)) {
|
||||
return false;
|
||||
}
|
||||
if (/^(export\s+)?(async\s+)?function\s+[A-Za-z_$][\w$]*\s*\(/.test(t)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(export\s+)?const\s+[A-Za-z_$][\w$]*\s*=\s*(async\s*)?\(/.test(t)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(export\s+)?const\s+[A-Za-z_$][\w$]*\s*=\s*async\s+function/.test(t)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function slugPart(name: string): string {
|
||||
const safe = name.replace(/[^\w$-]+/g, "-").toLowerCase();
|
||||
return safe.length > 0 ? safe : "block";
|
||||
}
|
||||
|
||||
function extractRoughName(firstLine: string): string {
|
||||
const m =
|
||||
/function\s+([A-Za-z_$][\w$]*)/.exec(firstLine) ?? /const\s+([A-Za-z_$][\w$]*)/.exec(firstLine);
|
||||
return m !== null && m[1] !== undefined ? m[1] : "fn";
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `.ts` / `.js` by top-level function-like lines; falls back to paragraph chunks.
|
||||
*/
|
||||
export function chunkTypeScriptOrJavaScript(relativePath: string, source: string): TsJsChunk[] {
|
||||
const baseSlug = relativePath.replace(/\./g, "-").replace(/\//g, "-");
|
||||
const lines = source.split(/\r?\n/);
|
||||
const starts: number[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line !== undefined && isFunctionStartLine(line)) {
|
||||
starts.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (starts.length === 0) {
|
||||
return paragraphFallbackChunks(baseSlug, source);
|
||||
}
|
||||
|
||||
const chunks: TsJsChunk[] = [];
|
||||
for (let s = 0; s < starts.length; s++) {
|
||||
const start = starts[s] ?? 0;
|
||||
const end = s + 1 < starts.length ? (starts[s + 1] ?? lines.length) : lines.length;
|
||||
const block = lines.slice(start, end).join("\n").trim();
|
||||
if (block.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const first = lines[start] ?? "";
|
||||
const name = extractRoughName(first);
|
||||
chunks.push({
|
||||
slug: `${baseSlug}#${slugPart(name)}-${String(s)}`,
|
||||
text: block,
|
||||
});
|
||||
}
|
||||
|
||||
return chunks.length > 0 ? chunks : paragraphFallbackChunks(baseSlug, source);
|
||||
}
|
||||
|
||||
function paragraphFallbackChunks(baseSlug: string, source: string): TsJsChunk[] {
|
||||
const text = source.trim();
|
||||
if (text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const parts = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
|
||||
if (parts.length === 0) {
|
||||
return [{ slug: `${baseSlug}#0`, text }];
|
||||
}
|
||||
return parts.map((p, i) => ({
|
||||
slug: `${baseSlug}#para-${String(i)}`,
|
||||
text: p.trim(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { chunkMarkdown } from "./chunk-markdown.js";
|
||||
import { chunkTypeScriptOrJavaScript } from "./chunk-typescript.js";
|
||||
|
||||
export type KnowledgeChunk = {
|
||||
slug: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function chunkKnowledgeFile(relativePath: string, source: string): KnowledgeChunk[] {
|
||||
const lower = relativePath.toLowerCase();
|
||||
if (lower.endsWith(".md")) {
|
||||
return chunkMarkdown(relativePath, source);
|
||||
}
|
||||
if (
|
||||
lower.endsWith(".ts") ||
|
||||
lower.endsWith(".tsx") ||
|
||||
lower.endsWith(".js") ||
|
||||
lower.endsWith(".jsx")
|
||||
) {
|
||||
return chunkTypeScriptOrJavaScript(relativePath, source);
|
||||
}
|
||||
return [{ slug: `${relativePath.replace(/\//g, "-")}#0`, text: source.trim() }];
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Remote embedding service client — calls embed.shazhou.workers.dev
|
||||
* for real vector embeddings. Falls back to fake hash-based embeddings
|
||||
* if credentials are not configured.
|
||||
*/
|
||||
|
||||
type EmbedResponse = {
|
||||
embeddings: number[][];
|
||||
model: string;
|
||||
dimensions: number;
|
||||
cached: boolean[];
|
||||
};
|
||||
|
||||
export type EmbedServiceConfig = {
|
||||
url: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve embed service config from environment or cfg.
|
||||
* Returns null if not configured (will fall back to placeholder).
|
||||
*/
|
||||
export function resolveEmbedConfig(): EmbedServiceConfig | null {
|
||||
const url = process.env.EMBED_SERVICE_URL ?? null;
|
||||
const token = process.env.EMBED_AUTH_TOKEN ?? null;
|
||||
if (url === null || token === null) {
|
||||
return null;
|
||||
}
|
||||
return { url, token };
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Call remote embedding service. Batches texts in groups of 100.
|
||||
* Returns Float32Array per text (stored as Buffer for SQLite BLOB).
|
||||
*/
|
||||
export async function embedTexts(config: EmbedServiceConfig, texts: string[]): Promise<Buffer[]> {
|
||||
const results: Buffer[] = [];
|
||||
|
||||
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
|
||||
const batch = texts.slice(i, i + BATCH_SIZE);
|
||||
const resp = await fetch(`${config.url}/embed`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
},
|
||||
body: JSON.stringify({ texts: batch }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`Embed service error ${String(resp.status)}: ${body}`);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as EmbedResponse;
|
||||
|
||||
for (const vec of data.embeddings) {
|
||||
const buf = Buffer.alloc(vec.length * 4);
|
||||
for (let j = 0; j < vec.length; j++) {
|
||||
buf.writeFloatLE(vec[j] as number, j * 4);
|
||||
}
|
||||
results.push(buf);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed a single text (for query). Returns Float32Array as Buffer.
|
||||
*/
|
||||
export async function embedQuery(config: EmbedServiceConfig, text: string): Promise<Buffer> {
|
||||
const results = await embedTexts(config, [text]);
|
||||
const first = results[0];
|
||||
if (first === undefined) {
|
||||
throw new Error("Embed service returned empty result");
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosine similarity between two embedding buffers (Float32LE encoded).
|
||||
*/
|
||||
export function cosineSimilarity(a: Buffer, b: Buffer): number {
|
||||
const len = Math.min(a.length, b.length) / 4;
|
||||
let dot = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const va = a.readFloatLE(i * 4);
|
||||
const vb = b.readFloatLE(i * 4);
|
||||
dot += va * vb;
|
||||
normA += va * va;
|
||||
normB += vb * vb;
|
||||
}
|
||||
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
if (denom === 0) return 0;
|
||||
return dot / denom;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import picomatch from "picomatch";
|
||||
|
||||
const PICOMATCH_OPTS = { dot: true } as const;
|
||||
|
||||
/**
|
||||
* True if `relativePosixPath` matches any exclude glob (POSIX slashes).
|
||||
*/
|
||||
export function matchesKnowledgeExclude(
|
||||
relativePosixPath: string,
|
||||
excludePatterns: ReadonlyArray<string>,
|
||||
): boolean {
|
||||
for (const pattern of excludePatterns) {
|
||||
const isMatch = picomatch(pattern, PICOMATCH_OPTS);
|
||||
if (isMatch(relativePosixPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** Deterministic placeholder embedding bytes until a remote embedding service exists (RFC-003). */
|
||||
export function fakeEmbeddingBytes(text: string): Buffer {
|
||||
const hash = createHash("sha256").update(text, "utf8").digest();
|
||||
return Buffer.concat([hash, hash, hash, hash]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user