Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f8e61afda | |||
| 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 |
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
pnpm check
|
||||||
|
pnpm -r test
|
||||||
@@ -7,28 +7,31 @@ Nerve is a lightweight daemon that continuously observes external state through
|
|||||||
## Core Concepts
|
## Core Concepts
|
||||||
|
|
||||||
```
|
```
|
||||||
External World → Sense → Signal → Reflex → Workflow → Log
|
External World → Sense ─┬→ Signal → Reflex → Sense (scheduled compute)
|
||||||
↑ ↑
|
│
|
||||||
"what to observe" "what to do"
|
└→ Workflow (Sense return with workflow directive) → Log
|
||||||
```
|
```
|
||||||
|
|
||||||
| Concept | Metaphor | Role |
|
| Concept | Metaphor | Role |
|
||||||
|---------|----------|------|
|
|---------|----------|------|
|
||||||
| **Sense** | 👁️ Perception | A `compute()` function that samples or derives data. Each sense has its own SQLite database. |
|
| **Sense** | 👁️ Perception | A `compute()` function that samples or derives data. Each sense has its own SQLite database. |
|
||||||
| **Reflex** | ⚡ Reaction | Declarative trigger — interval-based, event-driven, or both. Connects senses to actions. |
|
| **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 non-null. Other reflexes can listen for signals. |
|
| **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 (actors) and a Moderator (coordinator). |
|
| **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). |
|
| **Log** | 📝 Record | Immutable audit trail. Queryable by senses, but **cannot** trigger reflexes (prevents feedback loops). |
|
||||||
|
|
||||||
Three extension points, fully orthogonal — a Sense doesn't know when it runs, a Reflex doesn't know what it computes, a Workflow doesn't know why it was triggered.
|
**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
|
## Packages
|
||||||
|
|
||||||
| Package | Description |
|
| Package | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| [`@uncaged/nerve-core`](./packages/core) | Shared types and config parser |
|
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, Sense→workflow routing, daemon IPC protocol |
|
||||||
| [`@uncaged/nerve-daemon`](./packages/daemon) | The observation engine — kernel, sense runtime, reflex scheduler, workflow manager |
|
| [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows |
|
||||||
| [`@uncaged/nerve-cli`](./packages/cli) | CLI tool (`nerve`) — init, start, stop, logs, query |
|
| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, workers, signal bus, reflex scheduler, workflow manager, file watcher, IPC |
|
||||||
|
| [`@uncaged/nerve-cli`](./packages/cli) | CLI (`nerve`) — init, validate, daemon, dev, logs, sense, store, workflow |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -70,15 +73,17 @@ nerve logs # view logs
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
`nerve.yaml` declares senses, reflexes, and workflows:
|
`nerve.yaml` declares senses, reflexes (sense-only), optional workflows (concurrency), and optional engine `max_rounds`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
max_rounds: 100 # default moderator cap (e.g. CLI workflow trigger)
|
||||||
|
|
||||||
senses:
|
senses:
|
||||||
cpu-usage:
|
cpu-usage:
|
||||||
group: system # senses in the same group share a worker process
|
group: system # senses in the same group share a worker process
|
||||||
throttle: 10s # min interval between computes
|
throttle: 10s # min interval between computes
|
||||||
timeout: 30s # max compute duration
|
timeout: 30s # max compute duration
|
||||||
gracePeriod: 5s # wait before first compute after startup
|
grace_period: 5s # wait before first compute after startup
|
||||||
|
|
||||||
reflexes:
|
reflexes:
|
||||||
- kind: sense
|
- kind: sense
|
||||||
@@ -86,10 +91,6 @@ reflexes:
|
|||||||
interval: 30s # periodic trigger
|
interval: 30s # periodic trigger
|
||||||
on: [disk-pressure] # also trigger on signals from other senses
|
on: [disk-pressure] # also trigger on signals from other senses
|
||||||
|
|
||||||
- kind: workflow
|
|
||||||
workflow: cleanup
|
|
||||||
on: [disk-pressure] # start a workflow when signal fires
|
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
cleanup:
|
cleanup:
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
@@ -97,43 +98,66 @@ workflows:
|
|||||||
code-review:
|
code-review:
|
||||||
concurrency: 3
|
concurrency: 3
|
||||||
overflow: queue
|
overflow: queue
|
||||||
maxQueue: 20
|
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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Kernel │
|
│ Kernel │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
│ ┌──────────────┐ watches nerve.yaml / senses / workflows │
|
||||||
│ │ Worker │ │ Worker │ │ Worker │ (1 per │
|
│ │ File Watcher ├──────────────────────────────────────────┐ │
|
||||||
│ │ (group A)│ │ (group B)│ │ (group C)│ group) │
|
│ └──────────────┘ │ │
|
||||||
│ │ sense-1 │ │ sense-3 │ │ sense-5 │ │
|
│ ┌──────────────┐ CLI ↔ newline JSON (trigger-workflow, │ │
|
||||||
│ │ sense-2 │ │ sense-4 │ │ │ │
|
│ │ Daemon IPC │ trigger-sense, list-senses) │ │
|
||||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
│ └──────┬───────┘ ▼ │
|
||||||
│ │ │ │ │
|
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
│ └──────────────┼──────────────┘ │
|
│ │ │ Worker │ │ Worker │ │ Worker │ (1 per│
|
||||||
│ ▼ │
|
│ │ │ (group A)│ │ (group B)│ │ (group C)│ group) │
|
||||||
│ ┌──────────────┐ │
|
│ │ │ sense-1 │ │ sense-3 │ │ sense-5 │ │
|
||||||
│ │ Signal Bus │ │
|
│ │ │ sense-2 │ │ sense-4 │ │ │ │
|
||||||
│ └──────┬───────┘ │
|
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
│ ▼ │
|
│ │ │ │ │ │
|
||||||
│ ┌──────────────────┐ │
|
│ │ └──────────────┼──────────────┘ │
|
||||||
│ │ Reflex Scheduler │ │
|
│ │ ▼ │
|
||||||
│ └────────┬─────────┘ │
|
│ │ ┌──────────────┐ │
|
||||||
│ ▼ │
|
│ │ │ Signal Bus │ │
|
||||||
│ ┌───────────────────┐ │
|
│ │ └──────┬───────┘ │
|
||||||
│ │ Workflow Manager │──→ Log Store (SQLite) │
|
│ │ ▼ │
|
||||||
│ └───────────────────┘ │
|
│ │ ┌──────────────────┐ │
|
||||||
└─────────────────────────────────────────────────────────┘
|
│ │ │ Reflex Scheduler│ │
|
||||||
|
│ │ └────────┬─────────┘ │
|
||||||
|
│ │ ▼ │
|
||||||
|
│ │ ┌───────────────────┐ │
|
||||||
|
│ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │
|
||||||
|
│ └───────────────────┘ (logs.db, …) │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Worker processes** — one per sense group, forked by the kernel. Isolated compute execution.
|
- **Worker pool** — one child process per sense group; isolation between groups.
|
||||||
- **Signal Bus** — in-memory pub/sub for signal distribution.
|
- **Signal Bus** — in-memory pub/sub for signal distribution.
|
||||||
- **Reflex Scheduler** — interval timers + signal subscriptions, with throttle/coalesce.
|
- **Reflex Scheduler** — interval timers + signal subscriptions, with throttle/coalesce.
|
||||||
- **Workflow Manager** — concurrency control (drop/queue), thread lifecycle tracking.
|
- **Workflow Manager** — concurrency (drop/queue), per-workflow workers, crash recovery.
|
||||||
- **Log Store** — WAL-mode SQLite via `node:sqlite`, with archival and retention policies.
|
- **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
|
## Tech Stack
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -19,7 +19,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"include": ["tsup.config.ts"],
|
"include": ["tsup.config.ts", "*/rslib.config.ts", "packages/khala/src/index.ts"],
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"style": {
|
"style": {
|
||||||
@@ -27,6 +27,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"include": ["**/__tests__/**"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"linter": {
|
"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).
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"node": ">=22.5.0"
|
"node": ">=22.5.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prepare": "husky",
|
||||||
"build": "pnpm -r run build",
|
"build": "pnpm -r run build",
|
||||||
"check": "biome check .",
|
"check": "biome check .",
|
||||||
"format": "biome format --write ."
|
"format": "biome format --write ."
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.0",
|
"@biomejs/biome": "^1.9.0",
|
||||||
"@rslib/core": "^0.21.3",
|
"@rslib/core": "^0.21.3",
|
||||||
|
"husky": "^9.1.7",
|
||||||
"typescript": "^5.5.0"
|
"typescript": "^5.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-14
@@ -21,41 +21,59 @@ nerve init # Initialize a nerve workspace (installs deps, scaff
|
|||||||
nerve validate # Validate nerve.yaml configuration
|
nerve validate # Validate nerve.yaml configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
### Daemon Management
|
### Daemon management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nerve daemon start # Start the daemon (background)
|
nerve daemon start # Start the daemon (background)
|
||||||
nerve daemon stop # Stop the daemon
|
nerve daemon stop # Stop the daemon
|
||||||
nerve daemon status # Check daemon health
|
nerve daemon status # Show pid, uptime, sense names from nerve.yaml (process must exist)
|
||||||
nerve daemon restart # Restart the daemon
|
nerve daemon restart # Stop then start
|
||||||
nerve daemon logs # Tail daemon logs
|
nerve daemon logs # Tail daemon process logs (file under workspace logs/)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nerve dev # Run in foreground mode (no daemon, Ctrl+C to stop)
|
nerve dev # Foreground kernel with file watcher + IPC (Ctrl+C stops)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Querying
|
### Querying & status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nerve logs # View structured logs
|
nerve logs # Tail or page the daemon text log file (path in footer; default ~/.uncaged-nerve/logs/nerve.log)
|
||||||
nerve sense query <name> # Query a sense's SQLite database
|
nerve status # Short daemon health summary (aliases daemon status)
|
||||||
nerve sense schema <name> # Show a sense's database schema
|
```
|
||||||
nerve status # Daemon health summary
|
|
||||||
|
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
|
### Workflows
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nerve workflow list # List workflow runs
|
nerve workflow list # Queued/started runs (add --all for terminal states; --workflow, --limit, --offset)
|
||||||
nerve workflow show <runId> # Show workflow run details
|
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}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Top-level Aliases
|
`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.
|
||||||
|
|
||||||
For convenience, these aliases are available:
|
### Top-level aliases
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nerve start → nerve daemon start
|
nerve start → nerve daemon start
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.5.0"
|
"node": ">=22.5.0"
|
||||||
},
|
},
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"nerve": "dist/cli.js"
|
"nerve": "dist/cli.js"
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ describe("logsCommand negative offset", () => {
|
|||||||
|
|
||||||
it("exits with code 1 and writes to stderr when offset is negative", async () => {
|
it("exits with code 1 and writes to stderr when offset is negative", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
logsCommand.run!({
|
logsCommand.run?.({
|
||||||
args: { n: "50", offset: "-5", follow: false },
|
args: { n: "50", offset: "-5", follow: false },
|
||||||
rawArgs: [],
|
rawArgs: [],
|
||||||
cmd: logsCommand as never,
|
cmd: logsCommand as never,
|
||||||
@@ -247,7 +247,7 @@ describe("logsCommand negative offset", () => {
|
|||||||
|
|
||||||
it("exits with code 1 for offset=-1", async () => {
|
it("exits with code 1 for offset=-1", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
logsCommand.run!({
|
logsCommand.run?.({
|
||||||
args: { n: "10", offset: "-1", follow: false },
|
args: { n: "10", offset: "-1", follow: false },
|
||||||
rawArgs: [],
|
rawArgs: [],
|
||||||
cmd: logsCommand as never,
|
cmd: logsCommand as never,
|
||||||
|
|||||||
@@ -29,10 +29,22 @@ const SAMPLE_SENSES: SenseInfo[] = [
|
|||||||
group: "system",
|
group: "system",
|
||||||
throttle: 5000,
|
throttle: 5000,
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
lastSignalTs: 1_700_000_000_000,
|
lastSignalTimestamp: 1_700_000_000_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disk-usage",
|
||||||
|
group: "system",
|
||||||
|
throttle: 30000,
|
||||||
|
timeout: null,
|
||||||
|
lastSignalTimestamp: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "active-tasks",
|
||||||
|
group: "tasks",
|
||||||
|
throttle: 10000,
|
||||||
|
timeout: 30000,
|
||||||
|
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 +112,14 @@ describe("formatSenseList", () => {
|
|||||||
expect(output).toContain("—");
|
expect(output).toContain("—");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows '(never)' when lastSignalTs is null", () => {
|
it("shows '(never)' when lastSignalTimestamp is null", () => {
|
||||||
const output = formatSenseList(SAMPLE_SENSES);
|
const output = formatSenseList(SAMPLE_SENSES);
|
||||||
expect(output).toContain("(never)");
|
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);
|
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());
|
expect(output).toContain(new Date(1_700_000_000_000).toISOString());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -157,11 +169,19 @@ reflexes: []
|
|||||||
);
|
);
|
||||||
const result = sensesFromConfig(path);
|
const result = sensesFromConfig(path);
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(result[0]).toMatchObject({ name: "cpu-usage", group: "system", lastSignalTs: null });
|
expect(result[0]).toMatchObject({
|
||||||
expect(result[1]).toMatchObject({ name: "disk-usage", group: "system", lastSignalTs: null });
|
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");
|
const path = join(tmpDir, "nerve.yaml");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
path,
|
path,
|
||||||
@@ -173,7 +193,7 @@ reflexes: []
|
|||||||
`.trim(),
|
`.trim(),
|
||||||
);
|
);
|
||||||
const result = sensesFromConfig(path);
|
const result = sensesFromConfig(path);
|
||||||
expect(result[0].lastSignalTs).toBeNull();
|
expect(result[0].lastSignalTimestamp).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("populates throttle and timeout from config", () => {
|
it("populates throttle and timeout from config", () => {
|
||||||
@@ -238,7 +258,13 @@ describe("listSensesViaDaemon", () => {
|
|||||||
|
|
||||||
it("resolves with populated senses array", async () => {
|
it("resolves with populated senses array", async () => {
|
||||||
const senses: SenseInfo[] = [
|
const senses: SenseInfo[] = [
|
||||||
{ name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, lastSignalTs: 12345 },
|
{
|
||||||
|
name: "cpu-usage",
|
||||||
|
group: "system",
|
||||||
|
throttle: 5000,
|
||||||
|
timeout: 3000,
|
||||||
|
lastSignalTimestamp: 12345,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const server = createServer((s) => {
|
const server = createServer((s) => {
|
||||||
s.on("data", () => {
|
s.on("data", () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { join } from "node:path";
|
|||||||
import { createLogStore } from "@uncaged/nerve-store";
|
import { createLogStore } from "@uncaged/nerve-store";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
|
||||||
import {
|
import {
|
||||||
DEFAULT_THREAD_BUDGET_CHARS,
|
DEFAULT_THREAD_BUDGET_CHARS,
|
||||||
buildInspectOutput,
|
buildInspectOutput,
|
||||||
@@ -28,7 +29,6 @@ import {
|
|||||||
statusIcon,
|
statusIcon,
|
||||||
} from "../commands/workflow.js";
|
} from "../commands/workflow.js";
|
||||||
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
|
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
|
||||||
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Test helpers
|
// Test helpers
|
||||||
@@ -41,11 +41,11 @@ function upsertRun(
|
|||||||
runId: string,
|
runId: string,
|
||||||
workflow: string,
|
workflow: string,
|
||||||
status: WorkflowRun["status"],
|
status: WorkflowRun["status"],
|
||||||
ts: number,
|
timestampMs: number,
|
||||||
): void {
|
): void {
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: status, refId: runId, payload: null, ts },
|
{ source: "workflow", type: status, refId: runId, payload: null, timestamp: timestampMs },
|
||||||
{ runId, workflow, status, ts },
|
{ runId, workflow, status, timestamp: timestampMs, exitCode: null },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +65,8 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("formatTs", () => {
|
describe("formatTs", () => {
|
||||||
it("returns ISO 8601 string", () => {
|
it("returns ISO 8601 string", () => {
|
||||||
const ts = new Date("2026-01-01T00:00:00.000Z").getTime();
|
const timestampMs = new Date("2026-01-01T00:00:00.000Z").getTime();
|
||||||
expect(formatTs(ts)).toBe("2026-01-01T00:00:00.000Z");
|
expect(formatTs(timestampMs)).toBe("2026-01-01T00:00:00.000Z");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,6 +83,7 @@ describe("statusIcon", () => {
|
|||||||
["crashed", "💥"],
|
["crashed", "💥"],
|
||||||
["dropped", "🗑"],
|
["dropped", "🗑"],
|
||||||
["interrupted", "⚠️"],
|
["interrupted", "⚠️"],
|
||||||
|
["killed", "🛑"],
|
||||||
] as const)("maps status=%s to icon=%s", (status, icon) => {
|
] as const)("maps status=%s to icon=%s", (status, icon) => {
|
||||||
expect(statusIcon(status)).toBe(icon);
|
expect(statusIcon(status)).toBe(icon);
|
||||||
});
|
});
|
||||||
@@ -127,14 +128,14 @@ describe("getAllWorkflowRuns", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sorts by ts descending (newest first)", () => {
|
it("sorts by timestamp descending (newest first)", () => {
|
||||||
upsertRun("r1", "cleanup", "completed", 1000);
|
upsertRun("r1", "cleanup", "completed", 1000);
|
||||||
upsertRun("r2", "cleanup", "started", 3000);
|
upsertRun("r2", "cleanup", "started", 3000);
|
||||||
upsertRun("r3", "cleanup", "failed", 2000);
|
upsertRun("r3", "cleanup", "failed", 2000);
|
||||||
|
|
||||||
const runs = getAllWorkflowRuns(store, null);
|
const runs = getAllWorkflowRuns(store, null);
|
||||||
expect(runs[0].ts).toBeGreaterThan(runs[1].ts);
|
expect(runs[0].timestamp).toBeGreaterThan(runs[1].timestamp);
|
||||||
expect(runs[1].ts).toBeGreaterThan(runs[2].ts);
|
expect(runs[1].timestamp).toBeGreaterThan(runs[2].timestamp);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,9 +148,9 @@ describe("buildListOutput", () => {
|
|||||||
runId: string,
|
runId: string,
|
||||||
workflow: string,
|
workflow: string,
|
||||||
status: WorkflowRun["status"],
|
status: WorkflowRun["status"],
|
||||||
ts: number,
|
timestampMs: number,
|
||||||
): WorkflowRun {
|
): WorkflowRun {
|
||||||
return { runId, workflow, status, ts };
|
return { runId, workflow, status, timestamp: timestampMs };
|
||||||
}
|
}
|
||||||
|
|
||||||
it("returns empty message when no runs and --all=false", () => {
|
it("returns empty message when no runs and --all=false", () => {
|
||||||
@@ -235,7 +236,7 @@ describe("buildInspectOutput", () => {
|
|||||||
runId: "run-xyz",
|
runId: "run-xyz",
|
||||||
workflow: "cleanup",
|
workflow: "cleanup",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
ts: 1_700_000_000_000,
|
timestamp: 1_700_000_000_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("shows header with run details", () => {
|
it("shows header with run details", () => {
|
||||||
@@ -251,8 +252,8 @@ describe("buildInspectOutput", () => {
|
|||||||
expect(eventLines.join("")).toContain("no events recorded");
|
expect(eventLines.join("")).toContain("no events recorded");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows event lines with type and ts", () => {
|
it("shows event lines with type and timestamp", () => {
|
||||||
const logs = [{ ts: 1_700_000_001_000, type: "started", payload: null }];
|
const logs = [{ timestamp: 1_700_000_001_000, type: "started", payload: null }];
|
||||||
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
|
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
|
||||||
const text = eventLines.join("");
|
const text = eventLines.join("");
|
||||||
expect(text).toContain("type=started");
|
expect(text).toContain("type=started");
|
||||||
@@ -260,7 +261,7 @@ describe("buildInspectOutput", () => {
|
|||||||
|
|
||||||
it("truncates long payloads to 200 chars with ellipsis", () => {
|
it("truncates long payloads to 200 chars with ellipsis", () => {
|
||||||
const longPayload = "x".repeat(250);
|
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 { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
|
||||||
const text = eventLines.join("");
|
const text = eventLines.join("");
|
||||||
expect(text).toContain("…");
|
expect(text).toContain("…");
|
||||||
@@ -268,14 +269,14 @@ describe("buildInspectOutput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows short payloads in full", () => {
|
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);
|
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
|
||||||
expect(eventLines.join("")).toContain('{"count":5}');
|
expect(eventLines.join("")).toContain('{"count":5}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("paginates events with a hint", () => {
|
it("paginates events with a hint", () => {
|
||||||
const logs = Array.from({ length: 5 }, (_, i) => ({
|
const logs = Array.from({ length: 5 }, (_, i) => ({
|
||||||
ts: 1000 + i,
|
timestamp: 1000 + i,
|
||||||
type: "step_complete",
|
type: "step_complete",
|
||||||
payload: null,
|
payload: null,
|
||||||
}));
|
}));
|
||||||
@@ -287,7 +288,7 @@ describe("buildInspectOutput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("no pagination hint when all events fit on one page", () => {
|
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);
|
const { paginationHint } = buildInspectOutput(baseRun, logs, 0, 20);
|
||||||
expect(paginationHint).toBeNull();
|
expect(paginationHint).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -342,9 +343,14 @@ describe("partitionWorkflowMessage", () => {
|
|||||||
expect(p.meta).toEqual({ items: [1, 2] });
|
expect(p.meta).toEqual({ items: [1, 2] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses fallback role and stringifies non-string content", () => {
|
it("passes through role and content as-is", () => {
|
||||||
const p = partitionWorkflowMessage({ content: { n: 1 } });
|
const p = partitionWorkflowMessage({
|
||||||
expect(p.roleStr).toBe("?");
|
role: "unknown",
|
||||||
|
content: '{"n":1}',
|
||||||
|
meta: null,
|
||||||
|
timestamp: 0,
|
||||||
|
});
|
||||||
|
expect(p.roleStr).toBe("unknown");
|
||||||
expect(p.contentBody).toBe('{"n":1}');
|
expect(p.contentBody).toBe('{"n":1}');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -353,7 +359,7 @@ describe("formatThreadRoundBlock", () => {
|
|||||||
const row: ThreadRoundRow = {
|
const row: ThreadRoundRow = {
|
||||||
round: 2,
|
round: 2,
|
||||||
logId: 99,
|
logId: 99,
|
||||||
ts: new Date("2026-01-02T03:04:05.006Z").getTime(),
|
timestamp: new Date("2026-01-02T03:04:05.006Z").getTime(),
|
||||||
message: { role: "bot", content: "hi", meta: { score: 0.5 }, timestamp: 1735783445006 },
|
message: { role: "bot", content: "hi", meta: { score: 0.5 }, timestamp: 1735783445006 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -371,7 +377,7 @@ describe("buildThreadCommandOutput", () => {
|
|||||||
return {
|
return {
|
||||||
round: n,
|
round: n,
|
||||||
logId: 10 + n,
|
logId: 10 + n,
|
||||||
ts: 1000 + n,
|
timestamp: 1000 + n,
|
||||||
message: { role: "r", content, meta: { extra: n }, timestamp: 1000 + n },
|
message: { role: "r", content, meta: { extra: n }, timestamp: 1000 + n },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -457,15 +463,15 @@ describe("getAllWorkflowRuns — uses store.getAllWorkflowRuns SQL path", () =>
|
|||||||
expect(runs).toHaveLength(7);
|
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("r1", "deploy", "completed", 1000);
|
||||||
upsertRun("r2", "deploy", "completed", 3000);
|
upsertRun("r2", "deploy", "completed", 3000);
|
||||||
upsertRun("r3", "deploy", "completed", 2000);
|
upsertRun("r3", "deploy", "completed", 2000);
|
||||||
|
|
||||||
const runs = getAllWorkflowRuns(store, null);
|
const runs = getAllWorkflowRuns(store, null);
|
||||||
expect(runs[0].ts).toBe(3000);
|
expect(runs[0].timestamp).toBe(3000);
|
||||||
expect(runs[1].ts).toBe(2000);
|
expect(runs[1].timestamp).toBe(2000);
|
||||||
expect(runs[2].ts).toBe(1000);
|
expect(runs[2].timestamp).toBe(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters by workflow name", () => {
|
it("filters by workflow name", () => {
|
||||||
@@ -509,7 +515,7 @@ describe("triggerWorkflowViaDaemon", () => {
|
|||||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await triggerWorkflowViaDaemon(sockPath, "my-workflow", {});
|
const result = await triggerWorkflowViaDaemon(sockPath, "my-workflow", "", 100);
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((r) => server.close(() => r()));
|
await new Promise<void>((r) => server.close(() => r()));
|
||||||
@@ -525,7 +531,7 @@ describe("triggerWorkflowViaDaemon", () => {
|
|||||||
await new Promise<void>((r) => server.listen(sockPath, r));
|
await new Promise<void>((r) => server.listen(sockPath, r));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await triggerWorkflowViaDaemon(sockPath, "missing", {});
|
const result = await triggerWorkflowViaDaemon(sockPath, "missing", "", 100);
|
||||||
expect(result).toEqual({ ok: false, error: "unknown workflow" });
|
expect(result).toEqual({ ok: false, error: "unknown workflow" });
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((r) => server.close(() => r()));
|
await new Promise<void>((r) => server.close(() => r()));
|
||||||
@@ -533,7 +539,7 @@ describe("triggerWorkflowViaDaemon", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when no daemon is listening on the socket", async () => {
|
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/,
|
/Cannot connect to daemon/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,31 @@ reflexes:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const BIOME_JSON = `{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"semicolons": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"suspicious": {
|
||||||
|
"noConsole": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const PACKAGE_JSON = `{
|
const PACKAGE_JSON = `{
|
||||||
"name": "my-nerve-workspace",
|
"name": "my-nerve-workspace",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -32,6 +57,7 @@ const PACKAGE_JSON = `{
|
|||||||
"drizzle-orm": "latest"
|
"drizzle-orm": "latest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "latest",
|
||||||
"drizzle-kit": "latest"
|
"drizzle-kit": "latest"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
@@ -320,6 +346,7 @@ async function runInitWorkspace(force: boolean): Promise<void> {
|
|||||||
|
|
||||||
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
|
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
|
||||||
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
|
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
|
||||||
|
writeFile(join(nerveRoot, "biome.json"), BIOME_JSON);
|
||||||
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
|
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
|
||||||
writeFile(join(nerveRoot, "senses", "cpu-usage", "schema.ts"), CPU_SCHEMA_TS);
|
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", "index.js"), CPU_INDEX_JS);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function buildLogFooter(slice: LogSlice, nArg: number, logPath: string):
|
|||||||
let footer = `\n📄 ${rangeStr} | ${logPath}\n`;
|
let footer = `\n📄 ${rangeStr} | ${logPath}\n`;
|
||||||
|
|
||||||
if (slice.nextOffset !== null) {
|
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`;
|
footer += ` nerve logs --offset ${slice.nextOffset} -n ${nArg}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
import { type SenseInfo, parseNerveConfig } from "@uncaged/nerve-core";
|
import { type SenseInfo, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
|
||||||
import { defineCommand } from "citty";
|
import { defineCommand } from "citty";
|
||||||
|
|
||||||
import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js";
|
import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js";
|
||||||
import {
|
import {
|
||||||
assertSenseDbExists,
|
|
||||||
defaultPreviewSql,
|
defaultPreviewSql,
|
||||||
formatRowsAsAlignedTable,
|
formatRowsAsAlignedTable,
|
||||||
listTableSqlStatements,
|
listTableSqlStatements,
|
||||||
@@ -44,7 +43,8 @@ export function formatSenseList(senses: SenseInfo[]): string {
|
|||||||
lines.push(` group: ${s.group}\n`);
|
lines.push(` group: ${s.group}\n`);
|
||||||
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
|
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
|
||||||
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
|
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
|
||||||
const lastSignal = s.lastSignalTs !== null ? new Date(s.lastSignalTs).toISOString() : "(never)";
|
const lastSignal =
|
||||||
|
s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)";
|
||||||
lines.push(` last signal: ${lastSignal}\n`);
|
lines.push(` last signal: ${lastSignal}\n`);
|
||||||
}
|
}
|
||||||
return lines.join("");
|
return lines.join("");
|
||||||
@@ -65,7 +65,7 @@ export function sensesFromConfig(configPath: string): SenseInfo[] {
|
|||||||
group: cfg.group,
|
group: cfg.group,
|
||||||
throttle: cfg.throttle,
|
throttle: cfg.throttle,
|
||||||
timeout: cfg.timeout,
|
timeout: cfg.timeout,
|
||||||
lastSignalTs: null,
|
lastSignalTimestamp: null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,8 @@ const senseQueryCommand = defineCommand({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = db.prepare(sql).all() as Record<string, unknown>[];
|
const rawRows: unknown[] = db.prepare(sql).all();
|
||||||
|
const rows: Record<string, unknown>[] = rawRows.filter(isPlainRecord);
|
||||||
|
|
||||||
if (args.json) {
|
if (args.json) {
|
||||||
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
||||||
|
|||||||
@@ -74,9 +74,11 @@ async function runDaemon(nerveRoot: string): Promise<void> {
|
|||||||
|
|
||||||
const bootstrapPath = daemonBootstrapScript();
|
const bootstrapPath = daemonBootstrapScript();
|
||||||
|
|
||||||
|
// 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, [bootstrapPath], {
|
const child = spawn(process.execPath, [bootstrapPath], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ["ignore", (logStream as any).fd, (logStream as any).fd],
|
stdio: ["ignore", logFd, logFd],
|
||||||
env: { ...process.env, NERVE_ROOT: nerveRoot },
|
env: { ...process.env, NERVE_ROOT: nerveRoot },
|
||||||
cwd: nerveRoot,
|
cwd: nerveRoot,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ export const statusCommand = defineCommand({
|
|||||||
return;
|
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");
|
const configPath = join(getNerveRoot(), "nerve.yaml");
|
||||||
let senseList: string[] = [];
|
let senseList: string[] = [];
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const validateCommand = defineCommand({
|
|||||||
const config = result.value;
|
const config = result.value;
|
||||||
const senseCount = Object.keys(config.senses).length;
|
const senseCount = Object.keys(config.senses).length;
|
||||||
const reflexCount = config.reflexes.length;
|
const reflexCount = config.reflexes.length;
|
||||||
const workflowCount = config.workflows ? Object.keys(config.workflows).length : 0;
|
const workflowCount = Object.keys(config.workflows).length;
|
||||||
|
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`,
|
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { DaemonIpcTriggerResponse } from "@uncaged/nerve-core";
|
||||||
|
import { DEFAULT_ENGINE_MAX_ROUNDS, isPlainRecord } from "@uncaged/nerve-core";
|
||||||
import { defineCommand } from "citty";
|
import { defineCommand } from "citty";
|
||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
|
|
||||||
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
|
|
||||||
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
|
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
|
||||||
|
import { killWorkflowViaDaemon, triggerWorkflowViaDaemon } from "../daemon-client.js";
|
||||||
import { loadDaemonModule } from "../workspace-daemon.js";
|
import { loadDaemonModule } from "../workspace-daemon.js";
|
||||||
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
|
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
|
||||||
|
|
||||||
@@ -26,8 +28,8 @@ export function getDbPath(): string {
|
|||||||
return join(getNerveRoot(), "data", "logs.db");
|
return join(getNerveRoot(), "data", "logs.db");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTs(ts: number): string {
|
export function formatTs(timestampMs: number): string {
|
||||||
return new Date(ts).toISOString();
|
return new Date(timestampMs).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openStore(): Promise<LogStore> {
|
async function openStore(): Promise<LogStore> {
|
||||||
@@ -57,6 +59,8 @@ export function statusIcon(status: WorkflowRun["status"]): string {
|
|||||||
return "🗑";
|
return "🗑";
|
||||||
case "interrupted":
|
case "interrupted":
|
||||||
return "⚠️";
|
return "⚠️";
|
||||||
|
case "killed":
|
||||||
|
return "🛑";
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = status;
|
const _exhaustive: never = status;
|
||||||
return `?(${_exhaustive})`;
|
return `?(${_exhaustive})`;
|
||||||
@@ -65,7 +69,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.
|
* Delegates to the store's efficient SQL query on the workflow_runs table.
|
||||||
*/
|
*/
|
||||||
export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | null): WorkflowRun[] {
|
export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | null): WorkflowRun[] {
|
||||||
@@ -77,7 +81,8 @@ export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | nul
|
|||||||
*/
|
*/
|
||||||
export function formatRunLine(run: WorkflowRun): string {
|
export function formatRunLine(run: WorkflowRun): string {
|
||||||
const icon = statusIcon(run.status);
|
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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,7 +142,7 @@ export type InspectOutput = {
|
|||||||
|
|
||||||
export function buildInspectOutput(
|
export function buildInspectOutput(
|
||||||
run: WorkflowRun,
|
run: WorkflowRun,
|
||||||
allLogs: Array<{ ts: number; type: string; payload: string | null }>,
|
allLogs: Array<{ timestamp: number; type: string; payload: string | null }>,
|
||||||
offset: number,
|
offset: number,
|
||||||
limit: number,
|
limit: number,
|
||||||
): InspectOutput {
|
): InspectOutput {
|
||||||
@@ -150,7 +155,7 @@ export function buildInspectOutput(
|
|||||||
`🔍 Workflow run: ${run.runId}\n`,
|
`🔍 Workflow run: ${run.runId}\n`,
|
||||||
` workflow: ${run.workflow}\n`,
|
` workflow: ${run.workflow}\n`,
|
||||||
` status: ${run.status}\n`,
|
` status: ${run.status}\n`,
|
||||||
` ts: ${formatTs(run.ts)}\n`,
|
` timestamp: ${formatTs(run.timestamp)}\n`,
|
||||||
`\n📜 Thread events (${shown} of ${total}):\n`,
|
`\n📜 Thread events (${shown} of ${total}):\n`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -165,7 +170,7 @@ export function buildInspectOutput(
|
|||||||
: entry.payload.length <= 200
|
: entry.payload.length <= 200
|
||||||
? ` payload=${entry.payload}`
|
? ` payload=${entry.payload}`
|
||||||
: ` payload=${entry.payload.slice(0, 200)}…`;
|
: ` 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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +208,9 @@ export function partitionWorkflowMessage(msg: {
|
|||||||
const contentBody = msg.content;
|
const contentBody = msg.content;
|
||||||
const meta: Record<string, unknown> =
|
const meta: Record<string, unknown> =
|
||||||
msg.meta !== null && msg.meta !== undefined && typeof msg.meta === "object"
|
msg.meta !== null && msg.meta !== undefined && typeof msg.meta === "object"
|
||||||
? (msg.meta as Record<string, unknown>)
|
? isPlainRecord(msg.meta)
|
||||||
|
? msg.meta
|
||||||
|
: (msg.meta as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
return { roleStr, contentBody, meta };
|
return { roleStr, contentBody, meta };
|
||||||
}
|
}
|
||||||
@@ -215,13 +222,7 @@ export function formatThreadRoundBlock(row: ThreadRoundRow): string {
|
|||||||
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
|
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
|
||||||
const yamlBlock =
|
const yamlBlock =
|
||||||
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
|
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
|
||||||
return (
|
return `[#${row.round} ${roleStr}] ${formatTs(row.timestamp)}\n---\n${yamlBlock}---\n${contentBody}\n\n`;
|
||||||
`[#${row.round} ${roleStr}] ${formatTs(row.ts)}\n` +
|
|
||||||
`---\n` +
|
|
||||||
yamlBlock +
|
|
||||||
`---\n` +
|
|
||||||
`${contentBody}\n\n`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThreadCommandOutput = {
|
export type ThreadCommandOutput = {
|
||||||
@@ -229,6 +230,33 @@ export type ThreadCommandOutput = {
|
|||||||
paginationHint: string | null;
|
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 workflow thread ${runId} --before ${String(hintRound)}${budgetFlag}\n`
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build stdout lines for `nerve workflow thread`: newest-first selection from
|
* Build stdout lines for `nerve workflow thread`: newest-first selection from
|
||||||
* `descRows` until `budgetChars` (including `prefixLines`), then chronological order.
|
* `descRows` until `budgetChars` (including `prefixLines`), then chronological order.
|
||||||
@@ -254,25 +282,7 @@ export function buildThreadCommandOutput(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (picked.length === 0) {
|
if (picked.length === 0) {
|
||||||
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
|
return buildTruncatedSingleRound(row, remaining, prefixLines, runId, budgetFlag);
|
||||||
const yamlBlock =
|
|
||||||
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
|
|
||||||
const header =
|
|
||||||
`[#${row.round} ${roleStr}] ${formatTs(row.ts)}\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 workflow thread ${runId} --before ${String(hintRound)}${budgetFlag}\n`
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -281,9 +291,7 @@ export function buildThreadCommandOutput(
|
|||||||
const shownMinRound = picked.length === 0 ? null : Math.min(...picked.map((r) => r.round));
|
const shownMinRound = picked.length === 0 ? null : Math.min(...picked.map((r) => r.round));
|
||||||
let paginationHint: string | null = null;
|
let paginationHint: string | null = null;
|
||||||
if (shownMinRound !== null && shownMinRound > 1) {
|
if (shownMinRound !== null && shownMinRound > 1) {
|
||||||
paginationHint =
|
paginationHint = `\n⏩ Older rounds not shown. Fetch with:\n nerve workflow thread ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`;
|
||||||
`\n⏩ Older rounds not shown. Fetch with:\n` +
|
|
||||||
` nerve workflow thread ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { lines: [...prefixLines, ...blocksAsc], paginationHint };
|
return { lines: [...prefixLines, ...blocksAsc], paginationHint };
|
||||||
@@ -452,10 +460,7 @@ const workflowThreadCommand = defineCommand({
|
|||||||
const totalRoleRounds = store.getThreadRoundCount(args.runId);
|
const totalRoleRounds = store.getThreadRoundCount(args.runId);
|
||||||
if (totalRoleRounds === 0) {
|
if (totalRoleRounds === 0) {
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`🧵 Workflow thread: ${run.runId}\n` +
|
`🧵 Workflow thread: ${run.runId}\n workflow: ${run.workflow}\n status: ${run.status}\n\n📭 No role rounds recorded for this run.\n`,
|
||||||
` workflow: ${run.workflow}\n` +
|
|
||||||
` status: ${run.status}\n\n` +
|
|
||||||
`📭 No role rounds recorded for this run.\n`,
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -466,7 +471,7 @@ const workflowThreadCommand = defineCommand({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const prefixLines = [
|
const prefixLines = [
|
||||||
`🧵 Role rounds (workflow thread)\n`,
|
"🧵 Role rounds (workflow thread)\n",
|
||||||
` runId: ${run.runId}\n`,
|
` runId: ${run.runId}\n`,
|
||||||
` workflow: ${run.workflow}\n`,
|
` workflow: ${run.workflow}\n`,
|
||||||
` status: ${run.status}\n`,
|
` status: ${run.status}\n`,
|
||||||
@@ -512,7 +517,8 @@ const workflowTriggerCommand = defineCommand({
|
|||||||
},
|
},
|
||||||
payload: {
|
payload: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "JSON payload to pass as trigger payload (default: {})",
|
description:
|
||||||
|
'JSON with optional "prompt" (string), "maxRounds" (number), and "dryRun" (boolean) for the workflow run (default: {})',
|
||||||
default: "{}",
|
default: "{}",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -525,15 +531,25 @@ const workflowTriggerCommand = defineCommand({
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let prompt = "";
|
||||||
|
let maxRounds = DEFAULT_ENGINE_MAX_ROUNDS;
|
||||||
|
let dryRun = false;
|
||||||
|
if (isPlainRecord(triggerPayload)) {
|
||||||
|
const p = triggerPayload;
|
||||||
|
if (typeof p.prompt === "string") prompt = p.prompt;
|
||||||
|
if (typeof p.maxRounds === "number") maxRounds = p.maxRounds;
|
||||||
|
if (typeof p.dryRun === "boolean") dryRun = p.dryRun;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isRunning()) {
|
if (!isRunning()) {
|
||||||
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
|
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const socketPath = getSocketPath();
|
const socketPath = getSocketPath();
|
||||||
let response: { ok: true } | { ok: false; error: string };
|
let response: DaemonIpcTriggerResponse;
|
||||||
try {
|
try {
|
||||||
response = await triggerWorkflowViaDaemon(socketPath, args.name, triggerPayload);
|
response = await triggerWorkflowViaDaemon(socketPath, args.name, prompt, maxRounds, dryRun);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
|
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
|
||||||
@@ -550,6 +566,46 @@ const workflowTriggerCommand = defineCommand({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// nerve workflow kill <runId>
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const workflowKillCommand = 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 (!isRunning()) {
|
||||||
|
process.stderr.write("❌ Nerve daemon is not running. Start it with `nerve start`.\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketPath = getSocketPath();
|
||||||
|
let response: DaemonIpcTriggerResponse;
|
||||||
|
try {
|
||||||
|
response = await killWorkflowViaDaemon(socketPath, 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 workflow (parent command)
|
// nerve workflow (parent command)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -564,5 +620,6 @@ export const workflowCommand = defineCommand({
|
|||||||
inspect: workflowInspectCommand,
|
inspect: workflowInspectCommand,
|
||||||
thread: workflowThreadCommand,
|
thread: workflowThreadCommand,
|
||||||
trigger: workflowTriggerCommand,
|
trigger: workflowTriggerCommand,
|
||||||
|
kill: workflowKillCommand,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,22 +8,35 @@
|
|||||||
import { connect } from "node:net";
|
import { connect } from "node:net";
|
||||||
import type { Socket } from "node:net";
|
import type { Socket } from "node:net";
|
||||||
|
|
||||||
import type { SenseInfo } from "@uncaged/nerve-core";
|
import type {
|
||||||
|
DaemonIpcListSensesResponse,
|
||||||
|
DaemonIpcRequest,
|
||||||
|
DaemonIpcTriggerResponse,
|
||||||
|
SenseInfo,
|
||||||
|
} from "@uncaged/nerve-core";
|
||||||
|
import { isPlainRecord } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
const CONNECT_TIMEOUT_MS = 3_000;
|
const CONNECT_TIMEOUT_MS = 3_000;
|
||||||
const RESPONSE_TIMEOUT_MS = 5_000;
|
const RESPONSE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
export type { SenseInfo };
|
export type { SenseInfo };
|
||||||
|
|
||||||
type TriggerResponse = { ok: true } | { ok: false; error: string };
|
function isSenseInfo(value: unknown): value is SenseInfo {
|
||||||
|
if (!isPlainRecord(value)) return false;
|
||||||
|
return (
|
||||||
|
typeof value.name === "string" &&
|
||||||
|
typeof value.group === "string" &&
|
||||||
|
(value.throttle === null || typeof value.throttle === "number") &&
|
||||||
|
(value.timeout === null || typeof value.timeout === "number") &&
|
||||||
|
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ListSensesResponse = { ok: true; senses: SenseInfo[] } | { ok: false; error: string };
|
function parseDaemonResponse(line: string): DaemonIpcTriggerResponse {
|
||||||
|
|
||||||
function parseDaemonResponse(line: string): TriggerResponse {
|
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(line) as unknown;
|
const obj: unknown = JSON.parse(line);
|
||||||
if (obj !== null && typeof obj === "object") {
|
if (isPlainRecord(obj)) {
|
||||||
const r = obj as Record<string, unknown>;
|
const r = obj;
|
||||||
if (r.ok === true) return { ok: true };
|
if (r.ok === true) return { ok: true };
|
||||||
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
|
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
|
||||||
}
|
}
|
||||||
@@ -33,14 +46,15 @@ function parseDaemonResponse(line: string): TriggerResponse {
|
|||||||
return { ok: false, error: `Unexpected daemon response: ${line}` };
|
return { ok: false, error: `Unexpected daemon response: ${line}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseListSensesResponse(line: string): ListSensesResponse {
|
function parseListSensesResponse(line: string): DaemonIpcListSensesResponse {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(line) as unknown;
|
const obj: unknown = JSON.parse(line);
|
||||||
if (obj !== null && typeof obj === "object") {
|
if (isPlainRecord(obj)) {
|
||||||
const r = obj as Record<string, unknown>;
|
const r = obj;
|
||||||
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
|
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
|
||||||
if (r.ok === true && Array.isArray(r.senses))
|
if (r.ok === true && Array.isArray(r.senses) && r.senses.every(isSenseInfo)) {
|
||||||
return { ok: true, senses: r.senses as SenseInfo[] };
|
return { ok: true, senses: r.senses };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
@@ -54,7 +68,7 @@ function parseListSensesResponse(line: string): ListSensesResponse {
|
|||||||
*/
|
*/
|
||||||
function sendAndReceive<T>(
|
function sendAndReceive<T>(
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
message: object,
|
message: DaemonIpcRequest,
|
||||||
parseFirstLine: (trimmed: string) => T,
|
parseFirstLine: (trimmed: string) => T,
|
||||||
responseTimeoutMs: number = RESPONSE_TIMEOUT_MS,
|
responseTimeoutMs: number = RESPONSE_TIMEOUT_MS,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
@@ -119,27 +133,49 @@ function sendAndReceive<T>(
|
|||||||
export function triggerWorkflowViaDaemon(
|
export function triggerWorkflowViaDaemon(
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
workflow: string,
|
workflow: string,
|
||||||
payload: unknown,
|
prompt: string,
|
||||||
): Promise<TriggerResponse> {
|
maxRounds: number,
|
||||||
return sendAndReceive(
|
dryRun = false,
|
||||||
socketPath,
|
): Promise<DaemonIpcTriggerResponse> {
|
||||||
{ type: "trigger-workflow", workflow, payload },
|
const message: DaemonIpcRequest = {
|
||||||
parseDaemonResponse,
|
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.
|
* 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.
|
* Resolves with the daemon's response or rejects on connection/timeout errors.
|
||||||
*/
|
*/
|
||||||
export function triggerSenseViaDaemon(socketPath: string, sense: string): Promise<TriggerResponse> {
|
export function triggerSenseViaDaemon(
|
||||||
return sendAndReceive(socketPath, { type: "trigger-sense", sense }, parseDaemonResponse);
|
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.
|
* 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.
|
* Resolves with the list of registered senses or rejects on connection/timeout errors.
|
||||||
*/
|
*/
|
||||||
export function listSensesViaDaemon(socketPath: string): Promise<ListSensesResponse> {
|
export function listSensesViaDaemon(socketPath: string): Promise<DaemonIpcListSensesResponse> {
|
||||||
return sendAndReceive(socketPath, { type: "list-senses" }, parseListSensesResponse);
|
const message: DaemonIpcRequest = { type: "list-senses" };
|
||||||
|
return sendAndReceive(socketPath, message, parseListSensesResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,5 +46,6 @@ export type DaemonModule = {
|
|||||||
export async function loadDaemonModule(nerveRoot: string): Promise<DaemonModule> {
|
export async function loadDaemonModule(nerveRoot: string): Promise<DaemonModule> {
|
||||||
const entry = assertWorkspaceDaemonInstalled(nerveRoot);
|
const entry = assertWorkspaceDaemonInstalled(nerveRoot);
|
||||||
const url = pathToFileURL(entry).href;
|
const url = pathToFileURL(entry).href;
|
||||||
|
// Dynamic import return type is module-specific; narrow at this workspace boundary.
|
||||||
return import(url) as Promise<DaemonModule>;
|
return import(url) as Promise<DaemonModule>;
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-3
@@ -4,9 +4,12 @@ Shared types and configuration parser for the [nerve](../../README.md) observati
|
|||||||
|
|
||||||
## What's Inside
|
## What's Inside
|
||||||
|
|
||||||
- **Type definitions** — `Signal`, `SenseConfig`, `ReflexConfig`, `WorkflowConfig`, `NerveConfig`, and all related types
|
- **Type definitions** — `Signal`, `SenseConfig`, `SenseInfo`, `SenseReflexConfig`, `ReflexConfig` (sense-only), `WorkflowConfig`, `NerveConfig`, and related types
|
||||||
- **Config parser** — `parseNerveConfig(yaml)` validates and parses `nerve.yaml` into a typed `NerveConfig`
|
- **Config parser** — `parseNerveConfig(yaml)` validates and parses `nerve.yaml` into `NerveConfig` (rejects reflex entries that declare a `workflow` key; reflexes only schedule senses)
|
||||||
- **Result type** — `Result<T>` with `ok()` / `err()` helpers for explicit error handling (no thrown exceptions)
|
- **Sense → workflow routing** — `parseSenseWorkflowDirective`, `routeSenseComputeOutput`, and types `ParsedSenseWorkflowDirective`, `SenseComputeRoute`
|
||||||
|
- **Daemon IPC protocol** — request/response types (`DaemonIpcRequest`, `DaemonIpcResponse`, …) and `parseDaemonIpcRequest` for newline-delimited JSON on the CLI ↔ daemon socket
|
||||||
|
- **Workflow automaton types** — `START` / `END` sentinel constants, `WorkflowMessage`, `StartStep`, `RoleStep`, `ModeratorContext` (`start` + `steps`; empty `steps` on first moderator call), `Moderator` (single `context` argument), `WorkflowDefinition`, `Role`, `SenseResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS`
|
||||||
|
- **Result type** — `Result<T>` with `ok()` / `err()` helpers for explicit error handling (no thrown exceptions for parse paths)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -20,6 +23,29 @@ if (result.ok) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Sense return → signal vs workflow
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { parseSenseWorkflowDirective, routeSenseComputeOutput } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
|
const directive = parseSenseWorkflowDirective("my-workflow|8|Hello from sense");
|
||||||
|
if (directive.ok) {
|
||||||
|
console.log(directive.value.workflowName, directive.value.maxRounds, directive.value.prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = routeSenseComputeOutput({
|
||||||
|
metric: 42,
|
||||||
|
workflow: "my-workflow|8|Run now",
|
||||||
|
});
|
||||||
|
if (route.kind === "launch") {
|
||||||
|
// engine starts workflow; no Signal to the bus for this return
|
||||||
|
console.log(route.launch);
|
||||||
|
} else {
|
||||||
|
// normal signal with payload
|
||||||
|
console.log(route.payload);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Duration Format
|
## Duration Format
|
||||||
|
|
||||||
Config fields like `throttle`, `timeout`, and `interval` accept human-readable durations:
|
Config fields like `throttle`, `timeout`, and `interval` accept human-readable durations:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/nerve-core",
|
"name": "@uncaged/nerve-core",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"files": ["dist"],
|
"files": ["dist"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { parseNerveConfig } from "../config.js";
|
import { parseNerveConfig } from "../parse-nerve-config.js";
|
||||||
|
|
||||||
const VALID_CONFIG = `
|
const VALID_CONFIG = `
|
||||||
senses:
|
senses:
|
||||||
@@ -50,7 +50,7 @@ describe("parseNerveConfig", () => {
|
|||||||
kind: "sense",
|
kind: "sense",
|
||||||
sense: "cpu",
|
sense: "cpu",
|
||||||
interval: 30_000,
|
interval: 30_000,
|
||||||
on: null,
|
on: [],
|
||||||
});
|
});
|
||||||
expect(result.value.reflexes[1]).toEqual({
|
expect(result.value.reflexes[1]).toEqual({
|
||||||
kind: "sense",
|
kind: "sense",
|
||||||
@@ -58,7 +58,7 @@ describe("parseNerveConfig", () => {
|
|||||||
interval: null,
|
interval: null,
|
||||||
on: ["high_usage"],
|
on: ["high_usage"],
|
||||||
});
|
});
|
||||||
expect(result.value.workflows?.alert).toEqual({
|
expect(result.value.workflows.alert).toEqual({
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
overflow: "queue",
|
overflow: "queue",
|
||||||
maxQueue: 10,
|
maxQueue: 10,
|
||||||
@@ -85,11 +85,12 @@ senses:
|
|||||||
group: system
|
group: system
|
||||||
reflexes:
|
reflexes:
|
||||||
- sense: cpu
|
- sense: cpu
|
||||||
|
interval: 1s
|
||||||
`;
|
`;
|
||||||
const result = parseNerveConfig(yaml);
|
const result = parseNerveConfig(yaml);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
expect(result.value.workflows).toBeNull();
|
expect(result.value.workflows).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sense config has null for omitted throttle/timeout/gracePeriod", () => {
|
it("sense config has null for omitted throttle/timeout/gracePeriod", () => {
|
||||||
@@ -142,11 +143,11 @@ workflows:
|
|||||||
const result = parseNerveConfig(yaml);
|
const result = parseNerveConfig(yaml);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
expect(result.value.workflows?.alert).toEqual({
|
expect(result.value.workflows.alert).toEqual({
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
overflow: "drop",
|
overflow: "drop",
|
||||||
});
|
});
|
||||||
expect("maxQueue" in (result.value.workflows?.alert ?? {})).toBe(false);
|
expect("maxQueue" in result.value.workflows.alert).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("overflow: queue defaults maxQueue to 100", () => {
|
it("overflow: queue defaults maxQueue to 100", () => {
|
||||||
@@ -163,7 +164,7 @@ workflows:
|
|||||||
const result = parseNerveConfig(yaml);
|
const result = parseNerveConfig(yaml);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
expect(result.value.workflows?.alert).toEqual({
|
expect(result.value.workflows.alert).toEqual({
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
overflow: "queue",
|
overflow: "queue",
|
||||||
maxQueue: 100,
|
maxQueue: 100,
|
||||||
@@ -193,7 +194,7 @@ reflexes:
|
|||||||
expect(result.error.message).toMatch(/disk.*not found in senses/);
|
expect(result.error.message).toMatch(/disk.*not found in senses/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when workflow reflex references a non-existent workflow", () => {
|
it("returns error when reflex uses unsupported workflow field", () => {
|
||||||
const yaml = `
|
const yaml = `
|
||||||
senses:
|
senses:
|
||||||
cpu:
|
cpu:
|
||||||
@@ -206,10 +207,10 @@ reflexes:
|
|||||||
const result = parseNerveConfig(yaml);
|
const result = parseNerveConfig(yaml);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
expect(result.error.message).toMatch(/missing_wf.*not found in workflows/);
|
expect(result.error.message).toMatch(/workflow.*not supported/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when workflow reflex references non-existent workflow (with workflows defined)", () => {
|
it("returns error when reflex uses unsupported workflow field (with workflows defined)", () => {
|
||||||
const yaml = `
|
const yaml = `
|
||||||
senses:
|
senses:
|
||||||
cpu:
|
cpu:
|
||||||
@@ -226,7 +227,7 @@ workflows:
|
|||||||
const result = parseNerveConfig(yaml);
|
const result = parseNerveConfig(yaml);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
expect(result.error.message).toMatch(/unknown.*not found in workflows/);
|
expect(result.error.message).toMatch(/workflow.*not supported/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for invalid throttle format", () => {
|
it("returns error for invalid throttle format", () => {
|
||||||
@@ -354,7 +355,7 @@ reflexes:
|
|||||||
const result = parseNerveConfig(yaml);
|
const result = parseNerveConfig(yaml);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
expect(result.error.message).toMatch(/cannot have both/);
|
expect(result.error.message).toMatch(/workflow.*not supported/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when reflex has neither sense nor workflow", () => {
|
it("returns error when reflex has neither sense nor workflow", () => {
|
||||||
@@ -368,7 +369,7 @@ reflexes:
|
|||||||
const result = parseNerveConfig(yaml);
|
const result = parseNerveConfig(yaml);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
expect(result.error.message).toMatch(/must have either/);
|
expect(result.error.message).toMatch(/must include "sense"/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parseDaemonIpcRequest } from "../daemon-ipc-protocol.js";
|
||||||
|
|
||||||
|
describe("parseDaemonIpcRequest", () => {
|
||||||
|
it("parses trigger-workflow", () => {
|
||||||
|
expect(
|
||||||
|
parseDaemonIpcRequest(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "trigger-workflow",
|
||||||
|
workflow: "wf",
|
||||||
|
prompt: "go",
|
||||||
|
maxRounds: 3,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: "trigger-workflow",
|
||||||
|
workflow: "wf",
|
||||||
|
prompt: "go",
|
||||||
|
maxRounds: 3,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses trigger-workflow with dryRun true", () => {
|
||||||
|
expect(
|
||||||
|
parseDaemonIpcRequest(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "trigger-workflow",
|
||||||
|
workflow: "wf",
|
||||||
|
prompt: "go",
|
||||||
|
maxRounds: 3,
|
||||||
|
dryRun: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: "trigger-workflow",
|
||||||
|
workflow: "wf",
|
||||||
|
prompt: "go",
|
||||||
|
maxRounds: 3,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects trigger-workflow with empty workflow", () => {
|
||||||
|
expect(
|
||||||
|
parseDaemonIpcRequest(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "trigger-workflow",
|
||||||
|
workflow: "",
|
||||||
|
prompt: "",
|
||||||
|
maxRounds: 1,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses trigger-sense and list-senses", () => {
|
||||||
|
expect(parseDaemonIpcRequest(JSON.stringify({ type: "trigger-sense", sense: "x" }))).toEqual({
|
||||||
|
type: "trigger-sense",
|
||||||
|
sense: "x",
|
||||||
|
});
|
||||||
|
expect(parseDaemonIpcRequest(JSON.stringify({ type: "list-senses" }))).toEqual({
|
||||||
|
type: "list-senses",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid JSON or unknown type", () => {
|
||||||
|
expect(parseDaemonIpcRequest("not json")).toBeNull();
|
||||||
|
expect(parseDaemonIpcRequest(JSON.stringify({ type: "nope" }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+30
-296
@@ -1,303 +1,37 @@
|
|||||||
import { parse } from "yaml";
|
export type SenseConfig = {
|
||||||
|
group: string;
|
||||||
import type { Result } from "./result.js";
|
throttle: number | null;
|
||||||
import { err, ok } from "./result.js";
|
timeout: number | null;
|
||||||
import type { NerveConfig, ReflexConfig, SenseConfig, WorkflowConfig } from "./types.js";
|
gracePeriod: number | null;
|
||||||
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js";
|
|
||||||
|
|
||||||
const DURATION_RE = /^(\d+)([smh])$/;
|
|
||||||
|
|
||||||
const DURATION_MULTIPLIERS: Record<string, number> = {
|
|
||||||
s: 1_000,
|
|
||||||
m: 60_000,
|
|
||||||
h: 3_600_000,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseDurationToMs(value: string): number | null {
|
export type SenseReflexConfig = {
|
||||||
const match = DURATION_RE.exec(value);
|
kind: "sense";
|
||||||
if (!match) return null;
|
sense: string;
|
||||||
return Number(match[1]) * DURATION_MULTIPLIERS[match[2]];
|
interval: number | null;
|
||||||
}
|
on: string[];
|
||||||
|
};
|
||||||
|
|
||||||
function isValidGroupName(value: string): boolean {
|
/** Reflexes only schedule Senses; workflow launches come from Sense return values. */
|
||||||
return /^[a-zA-Z0-9_-]+$/.test(value);
|
export type ReflexConfig = SenseReflexConfig;
|
||||||
}
|
|
||||||
|
|
||||||
function parseDurationField(field: unknown, label: string): Result<number | null> {
|
export type DropOverflowConfig = {
|
||||||
if (field === undefined || field === null) return ok(null);
|
concurrency: number;
|
||||||
if (typeof field !== "string") {
|
overflow: "drop";
|
||||||
return err(
|
};
|
||||||
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const ms = parseDurationToMs(field);
|
|
||||||
if (ms === null) {
|
|
||||||
return err(
|
|
||||||
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ok(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
|
export type QueueOverflowConfig = {
|
||||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
concurrency: number;
|
||||||
return err(new Error(`senses.${name}: must be an object`));
|
overflow: "queue";
|
||||||
}
|
maxQueue: number;
|
||||||
|
};
|
||||||
|
|
||||||
const obj = raw as Record<string, unknown>;
|
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
|
||||||
|
|
||||||
if (typeof obj.group !== "string" || obj.group.trim() === "") {
|
export type NerveConfig = {
|
||||||
return err(new Error(`senses.${name}.group: required string`));
|
/** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */
|
||||||
}
|
maxRounds: number;
|
||||||
|
senses: Record<string, SenseConfig>;
|
||||||
if (!isValidGroupName(obj.group)) {
|
reflexes: ReflexConfig[];
|
||||||
return err(
|
workflows: Record<string, WorkflowConfig>;
|
||||||
new Error(
|
};
|
||||||
`senses.${name}.group: invalid name "${obj.group}" (only alphanumeric, underscore, hyphen allowed)`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const throttleResult = parseDurationField(obj.throttle, `senses.${name}.throttle`);
|
|
||||||
if (!throttleResult.ok) return throttleResult;
|
|
||||||
|
|
||||||
const timeoutResult = parseDurationField(obj.timeout, `senses.${name}.timeout`);
|
|
||||||
if (!timeoutResult.ok) return timeoutResult;
|
|
||||||
|
|
||||||
const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`);
|
|
||||||
if (!graceResult.ok) return graceResult;
|
|
||||||
|
|
||||||
return ok({
|
|
||||||
group: obj.group,
|
|
||||||
throttle: throttleResult.value,
|
|
||||||
timeout: timeoutResult.value,
|
|
||||||
gracePeriod: graceResult.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOnField(index: number, obj: Record<string, unknown>): Result<string[] | null> {
|
|
||||||
if (obj.on === undefined || obj.on === null) return ok(null);
|
|
||||||
if (!Array.isArray(obj.on) || !obj.on.every((item) => typeof item === "string")) {
|
|
||||||
return err(new Error(`reflexes[${index}].on: must be an array of strings`));
|
|
||||||
}
|
|
||||||
return ok(obj.on as string[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSenseReflex(
|
|
||||||
index: number,
|
|
||||||
obj: Record<string, unknown>,
|
|
||||||
senseNames: Set<string>,
|
|
||||||
on: string[] | null,
|
|
||||||
): Result<ReflexConfig> {
|
|
||||||
if (typeof obj.sense !== "string") {
|
|
||||||
return err(new Error(`reflexes[${index}].sense: must be a string`));
|
|
||||||
}
|
|
||||||
if (!senseNames.has(obj.sense)) {
|
|
||||||
return err(new Error(`reflexes[${index}].sense: "${obj.sense}" not found in senses`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const intervalResult = parseDurationField(obj.interval, `reflexes[${index}].interval`);
|
|
||||||
if (!intervalResult.ok) return intervalResult;
|
|
||||||
|
|
||||||
if (intervalResult.value === null && on !== null && on.length === 0) {
|
|
||||||
return err(
|
|
||||||
new Error(`reflexes[${index}]: sense reflex must have at least one of "interval" or "on"`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok({
|
|
||||||
kind: "sense" as const,
|
|
||||||
sense: obj.sense,
|
|
||||||
interval: intervalResult.value,
|
|
||||||
on,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateReflexConfig(
|
|
||||||
index: number,
|
|
||||||
raw: unknown,
|
|
||||||
senseNames: Set<string>,
|
|
||||||
): Result<ReflexConfig> {
|
|
||||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
||||||
return err(new Error(`reflexes[${index}]: must be an object`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = raw as Record<string, unknown>;
|
|
||||||
const hasSense = obj.sense !== undefined;
|
|
||||||
const hasWorkflowKey = Object.hasOwn(obj, "workflow");
|
|
||||||
|
|
||||||
if (hasWorkflowKey) {
|
|
||||||
return err(
|
|
||||||
new Error(
|
|
||||||
`reflexes[${index}]: YAML "workflow" entries are not supported — start workflows from a Sense compute return value using a "workflow" string field (format: name|maxRounds|prompt)`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!hasSense) {
|
|
||||||
return err(new Error(`reflexes[${index}]: must include "sense"`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const onResult = parseOnField(index, obj);
|
|
||||||
if (!onResult.ok) return onResult;
|
|
||||||
|
|
||||||
return parseSenseReflex(index, obj, senseNames, onResult.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEngineMaxRounds(obj: Record<string, unknown>): Result<number> {
|
|
||||||
if (obj.max_rounds === undefined || obj.max_rounds === null) {
|
|
||||||
return ok(DEFAULT_ENGINE_MAX_ROUNDS);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof obj.max_rounds !== "number" ||
|
|
||||||
!Number.isInteger(obj.max_rounds) ||
|
|
||||||
obj.max_rounds < 1
|
|
||||||
) {
|
|
||||||
return err(new Error("max_rounds: must be a positive integer"));
|
|
||||||
}
|
|
||||||
return ok(obj.max_rounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateWorkflowConfig(name: string, raw: unknown): Result<WorkflowConfig> {
|
|
||||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
||||||
return err(new Error(`workflows.${name}: must be an object`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = raw as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof obj.concurrency !== "number" ||
|
|
||||||
!Number.isInteger(obj.concurrency) ||
|
|
||||||
obj.concurrency < 1
|
|
||||||
) {
|
|
||||||
return err(new Error(`workflows.${name}.concurrency: must be a positive integer`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (obj.overflow !== "drop" && obj.overflow !== "queue") {
|
|
||||||
return err(new Error(`workflows.${name}.overflow: must be "drop" or "queue"`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (obj.overflow === "drop") {
|
|
||||||
if (obj.max_queue !== undefined && obj.max_queue !== null) {
|
|
||||||
return err(new Error(`workflows.${name}: max_queue is not allowed with overflow "drop"`));
|
|
||||||
}
|
|
||||||
return ok({
|
|
||||||
concurrency: obj.concurrency,
|
|
||||||
overflow: "drop" as const,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// overflow: "queue"
|
|
||||||
let maxQueue = 100; // default
|
|
||||||
if (obj.max_queue !== undefined && obj.max_queue !== null) {
|
|
||||||
if (
|
|
||||||
typeof obj.max_queue !== "number" ||
|
|
||||||
!Number.isInteger(obj.max_queue) ||
|
|
||||||
obj.max_queue < 1
|
|
||||||
) {
|
|
||||||
return err(new Error(`workflows.${name}.max_queue: must be a positive integer`));
|
|
||||||
}
|
|
||||||
maxQueue = obj.max_queue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok({
|
|
||||||
concurrency: obj.concurrency,
|
|
||||||
overflow: "queue" as const,
|
|
||||||
maxQueue,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSenses(
|
|
||||||
obj: Record<string, unknown>,
|
|
||||||
): Result<{ senses: Record<string, SenseConfig>; senseNames: Set<string> }> {
|
|
||||||
if (obj.senses === null || typeof obj.senses !== "object" || Array.isArray(obj.senses)) {
|
|
||||||
return err(new Error("senses: required object"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const sensesRaw = obj.senses as Record<string, unknown>;
|
|
||||||
const senses: Record<string, SenseConfig> = {};
|
|
||||||
const senseNames = new Set(Object.keys(sensesRaw));
|
|
||||||
|
|
||||||
for (const [name, senseRaw] of Object.entries(sensesRaw)) {
|
|
||||||
const result = validateSenseConfig(name, senseRaw);
|
|
||||||
if (!result.ok) return result;
|
|
||||||
senses[name] = result.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok({ senses, senseNames });
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReflexes(
|
|
||||||
obj: Record<string, unknown>,
|
|
||||||
senseNames: Set<string>,
|
|
||||||
): Result<ReflexConfig[]> {
|
|
||||||
if (!Array.isArray(obj.reflexes)) {
|
|
||||||
return err(new Error("reflexes: required array"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const reflexes: ReflexConfig[] = [];
|
|
||||||
for (let i = 0; i < obj.reflexes.length; i++) {
|
|
||||||
const result = validateReflexConfig(i, obj.reflexes[i], senseNames);
|
|
||||||
if (!result.ok) return result;
|
|
||||||
reflexes.push(result.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok(reflexes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWorkflows(
|
|
||||||
obj: Record<string, unknown>,
|
|
||||||
): Result<Record<string, WorkflowConfig> | null> {
|
|
||||||
if (obj.workflows === undefined || obj.workflows === null) return ok(null);
|
|
||||||
|
|
||||||
if (typeof obj.workflows !== "object" || Array.isArray(obj.workflows)) {
|
|
||||||
return err(new Error("workflows: must be an object if provided"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const workflowsRaw = obj.workflows as Record<string, unknown>;
|
|
||||||
const workflows: Record<string, WorkflowConfig> = {};
|
|
||||||
|
|
||||||
for (const [name, wfRaw] of Object.entries(workflowsRaw)) {
|
|
||||||
const result = validateWorkflowConfig(name, wfRaw);
|
|
||||||
if (!result.ok) return result;
|
|
||||||
workflows[name] = result.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok(workflows);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseNerveConfig(raw: string): Result<NerveConfig> {
|
|
||||||
let parsed: unknown;
|
|
||||||
|
|
||||||
try {
|
|
||||||
parsed = parse(raw);
|
|
||||||
} catch (e) {
|
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
|
||||||
return err(new Error(`YAML parse error: ${message}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
||||||
return err(new Error("Config must be a YAML object"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = parsed as Record<string, unknown>;
|
|
||||||
|
|
||||||
const sensesResult = parseSenses(obj);
|
|
||||||
if (!sensesResult.ok) return sensesResult;
|
|
||||||
const { senses, senseNames } = sensesResult.value;
|
|
||||||
|
|
||||||
const reflexesResult = parseReflexes(obj, senseNames);
|
|
||||||
if (!reflexesResult.ok) return reflexesResult;
|
|
||||||
|
|
||||||
const workflowsResult = parseWorkflows(obj);
|
|
||||||
if (!workflowsResult.ok) return workflowsResult;
|
|
||||||
|
|
||||||
const maxRoundsResult = parseEngineMaxRounds(obj);
|
|
||||||
if (!maxRoundsResult.ok) return maxRoundsResult;
|
|
||||||
|
|
||||||
return ok({
|
|
||||||
maxRounds: maxRoundsResult.value,
|
|
||||||
senses,
|
|
||||||
reflexes: reflexesResult.value,
|
|
||||||
workflows: workflowsResult.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Daemon Unix-socket IPC protocol (CLI → daemon).
|
||||||
|
* Newline-delimited JSON: one request object per line from the client,
|
||||||
|
* one response object per line from the daemon.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isPlainRecord } from "./is-plain-record.js";
|
||||||
|
import type { SenseInfo } from "./sense.js";
|
||||||
|
|
||||||
|
/** Client → daemon: start a workflow run. */
|
||||||
|
export type DaemonIpcTriggerWorkflowRequest = {
|
||||||
|
type: "trigger-workflow";
|
||||||
|
workflow: string;
|
||||||
|
prompt: string;
|
||||||
|
maxRounds: number;
|
||||||
|
dryRun: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Client → daemon: run a sense compute on demand. */
|
||||||
|
export type DaemonIpcTriggerSenseRequest = {
|
||||||
|
type: "trigger-sense";
|
||||||
|
sense: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Client → daemon: list registered senses. */
|
||||||
|
export type DaemonIpcListSensesRequest = {
|
||||||
|
type: "list-senses";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Client → daemon: kill a running or queued workflow thread by runId. */
|
||||||
|
export type DaemonIpcKillWorkflowRequest = {
|
||||||
|
type: "kill-workflow";
|
||||||
|
runId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Union of all JSON requests the daemon IPC server accepts. */
|
||||||
|
export type DaemonIpcRequest =
|
||||||
|
| DaemonIpcTriggerWorkflowRequest
|
||||||
|
| DaemonIpcTriggerSenseRequest
|
||||||
|
| DaemonIpcListSensesRequest
|
||||||
|
| DaemonIpcKillWorkflowRequest;
|
||||||
|
|
||||||
|
/** Successful trigger / trigger-sense reply (no body). */
|
||||||
|
export type DaemonIpcTriggerOkResponse = { ok: true };
|
||||||
|
|
||||||
|
export type DaemonIpcErrorResponse = { ok: false; error: string };
|
||||||
|
|
||||||
|
/** Replies for trigger-workflow and trigger-sense. */
|
||||||
|
export type DaemonIpcTriggerResponse = DaemonIpcTriggerOkResponse | DaemonIpcErrorResponse;
|
||||||
|
|
||||||
|
/** Reply for list-senses. */
|
||||||
|
export type DaemonIpcListSensesResponse =
|
||||||
|
| { ok: true; senses: SenseInfo[] }
|
||||||
|
| DaemonIpcErrorResponse;
|
||||||
|
|
||||||
|
/** Any JSON response the daemon may write on the IPC socket. */
|
||||||
|
export type DaemonIpcResponse =
|
||||||
|
| DaemonIpcTriggerOkResponse
|
||||||
|
| DaemonIpcErrorResponse
|
||||||
|
| { ok: true; senses: SenseInfo[] };
|
||||||
|
|
||||||
|
function parseTriggerWorkflowFields(
|
||||||
|
req: Record<string, unknown>,
|
||||||
|
): DaemonIpcTriggerWorkflowRequest | null {
|
||||||
|
if (typeof req.workflow !== "string" || req.workflow.length === 0) return null;
|
||||||
|
if (typeof req.prompt !== "string") return null;
|
||||||
|
if (typeof req.maxRounds !== "number") return null;
|
||||||
|
const dryRun = typeof req.dryRun === "boolean" ? req.dryRun : false;
|
||||||
|
return {
|
||||||
|
type: "trigger-workflow",
|
||||||
|
workflow: req.workflow,
|
||||||
|
prompt: req.prompt,
|
||||||
|
maxRounds: req.maxRounds,
|
||||||
|
dryRun,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single line of JSON into a {@link DaemonIpcRequest}, or null if invalid.
|
||||||
|
* Kept in core with the request types so CLI and daemon stay aligned at compile time.
|
||||||
|
*/
|
||||||
|
export function parseDaemonIpcRequest(line: string): DaemonIpcRequest | null {
|
||||||
|
try {
|
||||||
|
const obj: unknown = JSON.parse(line);
|
||||||
|
if (!isPlainRecord(obj)) return null;
|
||||||
|
const req = obj;
|
||||||
|
if (req.type === "trigger-workflow") {
|
||||||
|
return parseTriggerWorkflowFields(req);
|
||||||
|
}
|
||||||
|
if (req.type === "trigger-sense") {
|
||||||
|
if (typeof req.sense !== "string" || req.sense.length === 0) return null;
|
||||||
|
return { type: "trigger-sense", sense: req.sense };
|
||||||
|
}
|
||||||
|
if (req.type === "list-senses") {
|
||||||
|
return { type: "list-senses" };
|
||||||
|
}
|
||||||
|
if (req.type === "kill-workflow") {
|
||||||
|
if (typeof req.runId !== "string" || req.runId.length === 0) return null;
|
||||||
|
return { type: "kill-workflow", runId: req.runId };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-10
@@ -1,27 +1,49 @@
|
|||||||
export type {
|
export type {
|
||||||
Signal,
|
|
||||||
SenseConfig,
|
SenseConfig,
|
||||||
SenseInfo,
|
|
||||||
SenseReflexConfig,
|
SenseReflexConfig,
|
||||||
ReflexConfig,
|
ReflexConfig,
|
||||||
DropOverflowConfig,
|
DropOverflowConfig,
|
||||||
QueueOverflowConfig,
|
QueueOverflowConfig,
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
NerveConfig,
|
NerveConfig,
|
||||||
|
} from "./config.js";
|
||||||
|
export type { Signal, SenseInfo, SenseResult } from "./sense.js";
|
||||||
|
export type {
|
||||||
WorkflowMessage,
|
WorkflowMessage,
|
||||||
RoleResult,
|
RoleResult,
|
||||||
Role,
|
Role,
|
||||||
RoleMeta,
|
RoleMeta,
|
||||||
StartSignal,
|
StartStep,
|
||||||
RoleSignal,
|
RoleStep,
|
||||||
|
ModeratorContext,
|
||||||
Moderator,
|
Moderator,
|
||||||
WorkflowDefinition,
|
WorkflowDefinition,
|
||||||
SenseResult,
|
} from "./workflow.js";
|
||||||
} from "./types.js";
|
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
|
||||||
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js";
|
|
||||||
export type { Result } from "./result.js";
|
export type { Result } from "./result.js";
|
||||||
export { ok, err } from "./result.js";
|
export { ok, err } from "./result.js";
|
||||||
export { parseNerveConfig } from "./config.js";
|
export { parseNerveConfig } from "./parse-nerve-config.js";
|
||||||
|
export { isPlainRecord } from "./is-plain-record.js";
|
||||||
|
|
||||||
export type { ParsedSenseWorkflowDirective, SenseComputeRoute } from "./sense-workflow-directive.js";
|
export type {
|
||||||
export { parseSenseWorkflowDirective, routeSenseComputeOutput } from "./sense-workflow-directive.js";
|
ParsedSenseWorkflowDirective,
|
||||||
|
SenseComputeRoute,
|
||||||
|
} from "./sense-workflow-directive.js";
|
||||||
|
export {
|
||||||
|
parseSenseWorkflowDirective,
|
||||||
|
routeSenseComputeOutput,
|
||||||
|
} from "./sense-workflow-directive.js";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DaemonIpcTriggerWorkflowRequest,
|
||||||
|
DaemonIpcTriggerSenseRequest,
|
||||||
|
DaemonIpcListSensesRequest,
|
||||||
|
DaemonIpcKillWorkflowRequest,
|
||||||
|
DaemonIpcRequest,
|
||||||
|
DaemonIpcTriggerOkResponse,
|
||||||
|
DaemonIpcErrorResponse,
|
||||||
|
DaemonIpcTriggerResponse,
|
||||||
|
DaemonIpcListSensesResponse,
|
||||||
|
DaemonIpcResponse,
|
||||||
|
} from "./daemon-ipc-protocol.js";
|
||||||
|
export { parseDaemonIpcRequest } from "./daemon-ipc-protocol.js";
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Narrows `unknown` to a plain JSON-style object (not null, not array).
|
||||||
|
* Use after `JSON.parse` / YAML / IPC when validating structure field-by-field.
|
||||||
|
*/
|
||||||
|
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
import type { NerveConfig, ReflexConfig, SenseConfig, WorkflowConfig } from "./config.js";
|
||||||
|
import { isPlainRecord } from "./is-plain-record.js";
|
||||||
|
import type { Result } from "./result.js";
|
||||||
|
import { err, ok } from "./result.js";
|
||||||
|
import { DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
|
||||||
|
|
||||||
|
const DURATION_RE = /^(\d+)([smh])$/;
|
||||||
|
|
||||||
|
const DURATION_MULTIPLIERS: Record<string, number> = {
|
||||||
|
s: 1_000,
|
||||||
|
m: 60_000,
|
||||||
|
h: 3_600_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDurationToMs(value: string): number | null {
|
||||||
|
const match = DURATION_RE.exec(value);
|
||||||
|
if (!match) return null;
|
||||||
|
return Number(match[1]) * DURATION_MULTIPLIERS[match[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidGroupName(value: string): boolean {
|
||||||
|
return /^[a-zA-Z0-9_-]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDurationField(field: unknown, label: string): Result<number | null> {
|
||||||
|
if (field === undefined || field === null) return ok(null);
|
||||||
|
if (typeof field !== "string") {
|
||||||
|
return err(
|
||||||
|
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ms = parseDurationToMs(field);
|
||||||
|
if (ms === null) {
|
||||||
|
return err(
|
||||||
|
new Error(`${label}: invalid duration "${field}" (expected e.g. "5s", "10m", "1h")`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ok(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
|
||||||
|
if (!isPlainRecord(raw)) {
|
||||||
|
return err(new Error(`senses.${name}: must be an object`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = raw;
|
||||||
|
|
||||||
|
if (typeof obj.group !== "string" || obj.group.trim() === "") {
|
||||||
|
return err(new Error(`senses.${name}.group: required string`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidGroupName(obj.group)) {
|
||||||
|
return err(
|
||||||
|
new Error(
|
||||||
|
`senses.${name}.group: invalid name "${obj.group}" (only alphanumeric, underscore, hyphen allowed)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const throttleResult = parseDurationField(obj.throttle, `senses.${name}.throttle`);
|
||||||
|
if (!throttleResult.ok) return throttleResult;
|
||||||
|
|
||||||
|
const timeoutResult = parseDurationField(obj.timeout, `senses.${name}.timeout`);
|
||||||
|
if (!timeoutResult.ok) return timeoutResult;
|
||||||
|
|
||||||
|
const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`);
|
||||||
|
if (!graceResult.ok) return graceResult;
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
group: obj.group,
|
||||||
|
throttle: throttleResult.value,
|
||||||
|
timeout: timeoutResult.value,
|
||||||
|
gracePeriod: graceResult.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOnField(index: number, obj: Record<string, unknown>): Result<string[]> {
|
||||||
|
if (obj.on === undefined || obj.on === null) return ok([]);
|
||||||
|
if (!Array.isArray(obj.on) || !obj.on.every((item): item is string => typeof item === "string")) {
|
||||||
|
return err(new Error(`reflexes[${index}].on: must be an array of strings`));
|
||||||
|
}
|
||||||
|
return ok(obj.on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSenseReflex(
|
||||||
|
index: number,
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
senseNames: Set<string>,
|
||||||
|
on: string[],
|
||||||
|
): Result<ReflexConfig> {
|
||||||
|
if (typeof obj.sense !== "string") {
|
||||||
|
return err(new Error(`reflexes[${index}].sense: must be a string`));
|
||||||
|
}
|
||||||
|
if (!senseNames.has(obj.sense)) {
|
||||||
|
return err(new Error(`reflexes[${index}].sense: "${obj.sense}" not found in senses`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalResult = parseDurationField(obj.interval, `reflexes[${index}].interval`);
|
||||||
|
if (!intervalResult.ok) return intervalResult;
|
||||||
|
|
||||||
|
if (intervalResult.value === null && on.length === 0) {
|
||||||
|
return err(
|
||||||
|
new Error(`reflexes[${index}]: sense reflex must have at least one of "interval" or "on"`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
kind: "sense" as const,
|
||||||
|
sense: obj.sense,
|
||||||
|
interval: intervalResult.value,
|
||||||
|
on,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReflexConfig(
|
||||||
|
index: number,
|
||||||
|
raw: unknown,
|
||||||
|
senseNames: Set<string>,
|
||||||
|
): Result<ReflexConfig> {
|
||||||
|
if (!isPlainRecord(raw)) {
|
||||||
|
return err(new Error(`reflexes[${index}]: must be an object`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = raw;
|
||||||
|
const hasSense = obj.sense !== undefined;
|
||||||
|
const hasWorkflowKey = Object.hasOwn(obj, "workflow");
|
||||||
|
|
||||||
|
if (hasWorkflowKey) {
|
||||||
|
return err(
|
||||||
|
new Error(
|
||||||
|
`reflexes[${index}]: YAML "workflow" entries are not supported — start workflows from a Sense compute return value using a "workflow" string field (format: name|maxRounds|prompt)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasSense) {
|
||||||
|
return err(new Error(`reflexes[${index}]: must include "sense"`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResult = parseOnField(index, obj);
|
||||||
|
if (!onResult.ok) return onResult;
|
||||||
|
|
||||||
|
return parseSenseReflex(index, obj, senseNames, onResult.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEngineMaxRounds(obj: Record<string, unknown>): Result<number> {
|
||||||
|
if (obj.max_rounds === undefined || obj.max_rounds === null) {
|
||||||
|
return ok(DEFAULT_ENGINE_MAX_ROUNDS);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof obj.max_rounds !== "number" ||
|
||||||
|
!Number.isInteger(obj.max_rounds) ||
|
||||||
|
obj.max_rounds < 1
|
||||||
|
) {
|
||||||
|
return err(new Error("max_rounds: must be a positive integer"));
|
||||||
|
}
|
||||||
|
return ok(obj.max_rounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWorkflowConfig(name: string, raw: unknown): Result<WorkflowConfig> {
|
||||||
|
if (!isPlainRecord(raw)) {
|
||||||
|
return err(new Error(`workflows.${name}: must be an object`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = raw;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof obj.concurrency !== "number" ||
|
||||||
|
!Number.isInteger(obj.concurrency) ||
|
||||||
|
obj.concurrency < 1
|
||||||
|
) {
|
||||||
|
return err(new Error(`workflows.${name}.concurrency: must be a positive integer`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.overflow !== "drop" && obj.overflow !== "queue") {
|
||||||
|
return err(new Error(`workflows.${name}.overflow: must be "drop" or "queue"`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.overflow === "drop") {
|
||||||
|
if (obj.max_queue !== undefined && obj.max_queue !== null) {
|
||||||
|
return err(new Error(`workflows.${name}: max_queue is not allowed with overflow "drop"`));
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
concurrency: obj.concurrency,
|
||||||
|
overflow: "drop" as const,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// overflow: "queue"
|
||||||
|
let maxQueue = 100; // default
|
||||||
|
if (obj.max_queue !== undefined && obj.max_queue !== null) {
|
||||||
|
if (
|
||||||
|
typeof obj.max_queue !== "number" ||
|
||||||
|
!Number.isInteger(obj.max_queue) ||
|
||||||
|
obj.max_queue < 1
|
||||||
|
) {
|
||||||
|
return err(new Error(`workflows.${name}.max_queue: must be a positive integer`));
|
||||||
|
}
|
||||||
|
maxQueue = obj.max_queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
concurrency: obj.concurrency,
|
||||||
|
overflow: "queue" as const,
|
||||||
|
maxQueue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSenses(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
): Result<{ senses: Record<string, SenseConfig>; senseNames: Set<string> }> {
|
||||||
|
if (!isPlainRecord(obj.senses)) {
|
||||||
|
return err(new Error("senses: required object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensesRaw = obj.senses;
|
||||||
|
const senses: Record<string, SenseConfig> = {};
|
||||||
|
const senseNames = new Set(Object.keys(sensesRaw));
|
||||||
|
|
||||||
|
for (const [name, senseRaw] of Object.entries(sensesRaw)) {
|
||||||
|
const result = validateSenseConfig(name, senseRaw);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
senses[name] = result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ senses, senseNames });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReflexes(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
senseNames: Set<string>,
|
||||||
|
): Result<ReflexConfig[]> {
|
||||||
|
if (!Array.isArray(obj.reflexes)) {
|
||||||
|
return err(new Error("reflexes: required array"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reflexes: ReflexConfig[] = [];
|
||||||
|
for (let i = 0; i < obj.reflexes.length; i++) {
|
||||||
|
const result = validateReflexConfig(i, obj.reflexes[i], senseNames);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
reflexes.push(result.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(reflexes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkflows(obj: Record<string, unknown>): Result<Record<string, WorkflowConfig>> {
|
||||||
|
if (obj.workflows === undefined || obj.workflows === null) return ok({});
|
||||||
|
|
||||||
|
if (!isPlainRecord(obj.workflows)) {
|
||||||
|
return err(new Error("workflows: must be an object if provided"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowsRaw = obj.workflows;
|
||||||
|
const workflows: Record<string, WorkflowConfig> = {};
|
||||||
|
|
||||||
|
for (const [name, wfRaw] of Object.entries(workflowsRaw)) {
|
||||||
|
const result = validateWorkflowConfig(name, wfRaw);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
workflows[name] = result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(workflows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNerveConfig(raw: string): Result<NerveConfig> {
|
||||||
|
let parsed: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return err(new Error(`YAML parse error: ${message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainRecord(parsed)) {
|
||||||
|
return err(new Error("Config must be a YAML object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = parsed;
|
||||||
|
|
||||||
|
const sensesResult = parseSenses(obj);
|
||||||
|
if (!sensesResult.ok) return sensesResult;
|
||||||
|
const { senses, senseNames } = sensesResult.value;
|
||||||
|
|
||||||
|
const reflexesResult = parseReflexes(obj, senseNames);
|
||||||
|
if (!reflexesResult.ok) return reflexesResult;
|
||||||
|
|
||||||
|
const workflowsResult = parseWorkflows(obj);
|
||||||
|
if (!workflowsResult.ok) return workflowsResult;
|
||||||
|
|
||||||
|
const maxRoundsResult = parseEngineMaxRounds(obj);
|
||||||
|
if (!maxRoundsResult.ok) return maxRoundsResult;
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
maxRounds: maxRoundsResult.value,
|
||||||
|
senses,
|
||||||
|
reflexes: reflexesResult.value,
|
||||||
|
workflows: workflowsResult.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isPlainRecord } from "./is-plain-record.js";
|
||||||
import type { Result } from "./result.js";
|
import type { Result } from "./result.js";
|
||||||
import { err, ok } from "./result.js";
|
import { err, ok } from "./result.js";
|
||||||
|
|
||||||
@@ -54,10 +55,10 @@ function stripWorkflowKey(payload: Record<string, unknown>): Record<string, unkn
|
|||||||
* - `workflow: "name|n|prompt"` → launch workflow; no Signal is emitted to the bus
|
* - `workflow: "name|n|prompt"` → launch workflow; no Signal is emitted to the bus
|
||||||
*/
|
*/
|
||||||
export function routeSenseComputeOutput(payload: unknown): SenseComputeRoute {
|
export function routeSenseComputeOutput(payload: unknown): SenseComputeRoute {
|
||||||
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
if (!isPlainRecord(payload)) {
|
||||||
return { kind: "signal", payload };
|
return { kind: "signal", payload };
|
||||||
}
|
}
|
||||||
const obj = payload as Record<string, unknown>;
|
const obj = payload;
|
||||||
if (!Object.hasOwn(obj, "workflow")) {
|
if (!Object.hasOwn(obj, "workflow")) {
|
||||||
return { kind: "signal", payload };
|
return { kind: "signal", payload };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type Signal = {
|
||||||
|
id: number;
|
||||||
|
senseId: string;
|
||||||
|
payload: unknown;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
|
||||||
|
export type SenseInfo = {
|
||||||
|
name: string;
|
||||||
|
group: string;
|
||||||
|
throttle: number | null;
|
||||||
|
timeout: number | null;
|
||||||
|
lastSignalTimestamp: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The result of a Sense compute — payload plus optional workflow directive. */
|
||||||
|
export type SenseResult<T = unknown> = {
|
||||||
|
payload: T;
|
||||||
|
workflow: string | null;
|
||||||
|
};
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
export type Signal = {
|
|
||||||
id: number;
|
|
||||||
senseId: string;
|
|
||||||
payload: unknown;
|
|
||||||
ts: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SenseConfig = {
|
|
||||||
group: string;
|
|
||||||
throttle: number | null;
|
|
||||||
timeout: number | null;
|
|
||||||
gracePeriod: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
|
|
||||||
export type SenseInfo = {
|
|
||||||
name: string;
|
|
||||||
group: string;
|
|
||||||
throttle: number | null;
|
|
||||||
timeout: number | null;
|
|
||||||
lastSignalTs: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SenseReflexConfig = {
|
|
||||||
kind: "sense";
|
|
||||||
sense: string;
|
|
||||||
interval: number | null;
|
|
||||||
on: string[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Reflexes only schedule Senses; workflow launches come from Sense return values. */
|
|
||||||
export type ReflexConfig = SenseReflexConfig;
|
|
||||||
|
|
||||||
export type DropOverflowConfig = {
|
|
||||||
concurrency: number;
|
|
||||||
overflow: "drop";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type QueueOverflowConfig = {
|
|
||||||
concurrency: number;
|
|
||||||
overflow: "queue";
|
|
||||||
maxQueue: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
|
|
||||||
|
|
||||||
export type NerveConfig = {
|
|
||||||
/** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */
|
|
||||||
maxRounds: number;
|
|
||||||
senses: Record<string, SenseConfig>;
|
|
||||||
reflexes: ReflexConfig[];
|
|
||||||
workflows: Record<string, WorkflowConfig> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Workflow Automaton types (issue #80)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const START = "__start__" as const;
|
|
||||||
export const END = "__end__" as const;
|
|
||||||
export type START = typeof START;
|
|
||||||
export type END = typeof END;
|
|
||||||
|
|
||||||
/** Engine-wide fallback for max moderator rounds when not specified in config. */
|
|
||||||
export const DEFAULT_ENGINE_MAX_ROUNDS = 100;
|
|
||||||
|
|
||||||
/** A single message in the workflow conversation chain (runtime, type-erased). */
|
|
||||||
export type WorkflowMessage = {
|
|
||||||
role: string;
|
|
||||||
content: string;
|
|
||||||
meta: unknown;
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The typed output of a Role execution. */
|
|
||||||
export type RoleResult<Meta> = { content: string; meta: Meta };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Role is a pure async function: receives the full message chain,
|
|
||||||
* returns typed content + meta. Implementation can be an agent, LLM call,
|
|
||||||
* script, HTTP request, etc.
|
|
||||||
*/
|
|
||||||
export type Role<Meta> = (messages: WorkflowMessage[]) => Promise<RoleResult<Meta>>;
|
|
||||||
|
|
||||||
/** Maps role names to their meta types — the single generic that drives all inference. */
|
|
||||||
export type RoleMeta = Record<string, Record<string, unknown>>;
|
|
||||||
|
|
||||||
/** First message in the thread chain (`messages[0]`) — passed to the moderator on start. */
|
|
||||||
export type StartSignal = {
|
|
||||||
role: START;
|
|
||||||
content: string;
|
|
||||||
meta: { maxRounds: number };
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A discriminated union of signals from each role, derived from the meta map. */
|
|
||||||
export type RoleSignal<M extends RoleMeta> = {
|
|
||||||
[K in keyof M & string]: { role: K; meta: M[K] };
|
|
||||||
}[keyof M & string];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The moderator — a pure routing function. Receives the last signal,
|
|
||||||
* current round, and maxRounds. Returns the next role name or END.
|
|
||||||
*/
|
|
||||||
export type Moderator<M extends RoleMeta> = (
|
|
||||||
signal: StartSignal | RoleSignal<M>,
|
|
||||||
round: number,
|
|
||||||
maxRounds: number,
|
|
||||||
) => (keyof M & string) | END;
|
|
||||||
|
|
||||||
/** The complete definition of a workflow, as authored by users. */
|
|
||||||
export type WorkflowDefinition<M extends RoleMeta> = {
|
|
||||||
name: string;
|
|
||||||
roles: { [K in keyof M & string]: Role<M[K]> };
|
|
||||||
moderator: Moderator<M>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The result of a Sense compute — payload plus optional workflow directive. */
|
|
||||||
export type SenseResult = {
|
|
||||||
payload: unknown;
|
|
||||||
workflow: string | null;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Workflow Automaton types (issue #80)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const START = "__start__" as const;
|
||||||
|
export const END = "__end__" as const;
|
||||||
|
export type START = typeof START;
|
||||||
|
export type END = typeof END;
|
||||||
|
|
||||||
|
/** Engine-wide fallback for max moderator rounds when not specified in config. */
|
||||||
|
export const DEFAULT_ENGINE_MAX_ROUNDS = 100;
|
||||||
|
|
||||||
|
/** A single message in the workflow conversation chain (runtime, type-erased). */
|
||||||
|
export type WorkflowMessage = {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
meta: unknown;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The typed output of a Role execution. */
|
||||||
|
export type RoleResult<Meta> = { content: string; meta: Meta };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Role is a pure async function: receives the engine start frame plus prior
|
||||||
|
* role messages only (the start frame is not included in `messages`).
|
||||||
|
* Returns typed content + meta. Implementation can be an agent, LLM call,
|
||||||
|
* script, HTTP request, etc.
|
||||||
|
*/
|
||||||
|
export type Role<Meta> = (
|
||||||
|
start: StartStep,
|
||||||
|
messages: WorkflowMessage[],
|
||||||
|
) => Promise<RoleResult<Meta>>;
|
||||||
|
|
||||||
|
/** Maps role names to their meta types — the single generic that drives all inference. */
|
||||||
|
export type RoleMeta = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
/** Engine start frame: prompt, max rounds cap, dry-run flag, and timestamps for the thread. */
|
||||||
|
export type StartStep = {
|
||||||
|
role: START;
|
||||||
|
content: string;
|
||||||
|
meta: { maxRounds: number; dryRun: boolean };
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A discriminated union of role steps after each execution, aligned with `StartStep` shape. */
|
||||||
|
export type RoleStep<M extends RoleMeta> = {
|
||||||
|
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
|
||||||
|
}[keyof M & string];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moderator input: the complete workflow history.
|
||||||
|
* Contains the start frame and all role steps so far.
|
||||||
|
* On initial call, `steps` is empty — moderator can check `steps.length === 0`.
|
||||||
|
* Round count is `steps.length`; maxRounds is in `start.meta.maxRounds`.
|
||||||
|
*/
|
||||||
|
export type ModeratorContext<M extends RoleMeta> = {
|
||||||
|
start: StartStep;
|
||||||
|
steps: RoleStep<M>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The moderator — a pure routing function. Receives the full workflow context
|
||||||
|
* (start frame + all prior steps). Returns the next role name or END.
|
||||||
|
*/
|
||||||
|
export type Moderator<M extends RoleMeta> = (
|
||||||
|
context: ModeratorContext<M>,
|
||||||
|
) => (keyof M & string) | END;
|
||||||
|
|
||||||
|
/** The complete definition of a workflow, as authored by users. */
|
||||||
|
export type WorkflowDefinition<M extends RoleMeta> = {
|
||||||
|
name: string;
|
||||||
|
roles: { [K in keyof M & string]: Role<M[K]> };
|
||||||
|
moderator: Moderator<M>;
|
||||||
|
};
|
||||||
+53
-18
@@ -4,18 +4,33 @@ The observation engine runtime for [nerve](../../README.md) — runs senses, rou
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
| Module | Responsibility |
|
| Module | Source (indicative) | Responsibility |
|
||||||
|--------|---------------|
|
|--------|---------------------|----------------|
|
||||||
| **Kernel** | Top-level orchestrator — spawns workers, wires up signal bus, scheduler, and workflow manager. Supports hot reload and graceful shutdown. |
|
| **Kernel** | `kernel.ts` | Orchestrator — worker pool, signal bus, reflex scheduler, workflow manager, optional file watcher and daemon IPC, config reload hooks |
|
||||||
| **Sense Runtime** | Per-sense SQLite database (via `node:sqlite` + Drizzle ORM), migration runner, peer DB read access. |
|
| **Worker pool** | `worker-pool.ts` | Fork and supervise one child process per sense group; restart/shutdown; crash cleanup hooks for scheduler state |
|
||||||
| **Sense Worker** | Forked child process — one per sense group. Runs compute functions in isolation. |
|
| **Kernel sense groups** | `kernel-sense-groups.ts` | Derive sense groups from config; list senses per group for scheduling |
|
||||||
| **Signal Bus** | In-memory pub/sub. Sense computes emit signals; reflexes and workflows subscribe. |
|
| **Sense runtime** | sense worker + Drizzle | Per-sense SQLite (`node:sqlite`), migrations, peer DB reads |
|
||||||
| **Reflex Scheduler** | Drives compute triggers — interval timers, signal-based events, throttle/coalesce logic. |
|
| **Sense worker** | `sense-worker.ts` (fork target) | Child process entry — runs `compute()` per sense in a group |
|
||||||
| **Workflow Manager** | Concurrency control (drop/queue), thread lifecycle, worker process management (RFC-002). |
|
| **Signal bus** | `signal-bus.ts` | In-memory pub/sub for sense signals |
|
||||||
| **Log Store** | Structured log storage in WAL-mode SQLite. Supports retention policies, archival to JSONL, and workflow run tracking. |
|
| **Reflex scheduler** | `reflex-scheduler.ts` | Interval + `on` subscriptions, throttle/coalesce |
|
||||||
| **Blob Store** | Binary artifact storage for workflow outputs. |
|
| **Workflow manager** | `workflow-manager.ts` | One worker per workflow name, concurrency (drop/queue), queue caps |
|
||||||
| **File Watcher** | Watches `nerve.yaml` and sense files for hot reload. |
|
| **Workflow worker** | `workflow-worker.ts` | Child process — runs RFC-002 threads (`start-thread`, `resume-thread` IPC) |
|
||||||
| **Daemon IPC** | Unix socket server for CLI ↔ daemon communication. |
|
| **IPC (parent ↔ workers)** | `ipc.ts` | Typed messages for sense and workflow workers (includes `resume-thread` for recovery) |
|
||||||
|
| **Log / workflow persistence** | via `@uncaged/nerve-store` | Structured logs, `workflow_runs`, thread messages (used for recovery) |
|
||||||
|
| **Blob store** | `@uncaged/nerve-store` | CAS under `data/blobs/` — sense workers construct `createBlobStore(join(nerveRoot, "data", "blobs"))` for artifact writes |
|
||||||
|
| **File watcher** | `file-watcher.ts` | Watches workspace paths for config / sense / workflow file changes |
|
||||||
|
| **Kernel file watch** | `kernel-file-watch.ts` | Maps watcher events to `reloadConfig`, sense group restart, workflow `drainAndRespawn` |
|
||||||
|
| **Daemon IPC** | `daemon-ipc.ts` | Unix socket server — parses `@uncaged/nerve-core` `DaemonIpcRequest`, dispatches trigger-workflow / trigger-sense / list-senses |
|
||||||
|
|
||||||
|
## Crash recovery (workflow workers)
|
||||||
|
|
||||||
|
If a workflow worker exits unexpectedly while threads are active:
|
||||||
|
|
||||||
|
- In-flight runs are marked **`crashed`** in the log store; the manager respawns a fresh worker.
|
||||||
|
- Runs still in **`started`** state can be **`resume-thread`**’d: the manager rebuilds the message chain from persisted workflow log rows and sends `resume-thread` to the new worker.
|
||||||
|
- **Crash-loop backoff:** repeated crashes for the same workflow name are counted in a sliding window (`60s`); after **`5`** crashes in that window, the manager **stops respawning** that worker and logs the condition (avoids tight crash loops).
|
||||||
|
|
||||||
|
Hot reload (`drainAndRespawn`) uses a controlled drain: in-flight runs may be marked **`interrupted`** when the old worker is torn down after a timeout — that path is distinct from unexpected crash recovery.
|
||||||
|
|
||||||
## Key Design Decisions
|
## Key Design Decisions
|
||||||
|
|
||||||
@@ -26,24 +41,44 @@ The observation engine runtime for [nerve](../../README.md) — runs senses, rou
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The daemon is typically started via the CLI (`nerve daemon start`), but can be used programmatically:
|
The daemon is typically started via the CLI (`nerve daemon start` / `nerve dev`), but you can embed the kernel:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { parseNerveConfig } from "@uncaged/nerve-core";
|
||||||
import { createKernel } from "@uncaged/nerve-daemon";
|
import { createKernel } from "@uncaged/nerve-daemon";
|
||||||
|
|
||||||
const kernel = await createKernel(nerveRoot);
|
const nerveRoot = "/path/to/workspace";
|
||||||
|
const yamlPath = join(nerveRoot, "nerve.yaml");
|
||||||
|
const parsed = parseNerveConfig(readFileSync(yamlPath, "utf8"));
|
||||||
|
if (!parsed.ok) {
|
||||||
|
throw parsed.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kernel = createKernel(parsed.value, nerveRoot, {
|
||||||
|
enableFileWatcher: true,
|
||||||
|
ipcSocketPath: join(nerveRoot, "nerve.sock"),
|
||||||
|
});
|
||||||
|
|
||||||
await kernel.ready;
|
await kernel.ready;
|
||||||
|
|
||||||
// Trigger a sense manually
|
|
||||||
kernel.triggerSense("cpu-usage");
|
kernel.triggerSense("cpu-usage");
|
||||||
|
|
||||||
// Check health
|
|
||||||
const health = kernel.getHealth();
|
const health = kernel.getHealth();
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
await kernel.stop();
|
await kernel.stop();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`createKernel(config, nerveRoot, options?)` — `config` is a parsed `NerveConfig`; `nerveRoot` is the workspace root (contains `nerve.yaml`, `data/`, etc.). Optional `KernelOptions`:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `workerScript` | Override path to the sense worker entry script (defaults to the package’s resolved worker) |
|
||||||
|
| `enableFileWatcher` | Watch config / senses / workflows for hot reload |
|
||||||
|
| `logStore` | Inject a `LogStore` instance (defaults to `createLogStore(join(nerveRoot, "data", "logs.db"))`) |
|
||||||
|
| `ipcSocketPath` | When non-null, listen for daemon IPC on this Unix socket path |
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/nerve-daemon",
|
"name": "@uncaged/nerve-daemon",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -73,9 +73,10 @@ function makeLogStore(
|
|||||||
runId: string;
|
runId: string;
|
||||||
workflow: string;
|
workflow: string;
|
||||||
status: "queued" | "started";
|
status: "queued" | "started";
|
||||||
ts: number;
|
timestamp: number;
|
||||||
}> = [],
|
}> = [],
|
||||||
) {
|
) {
|
||||||
|
const runsWithExitCode = activeRuns.map((r) => ({ ...r, exitCode: null }));
|
||||||
const store = {
|
const store = {
|
||||||
append: vi.fn(),
|
append: vi.fn(),
|
||||||
query: vi.fn(() => []),
|
query: vi.fn(() => []),
|
||||||
@@ -86,9 +87,9 @@ function makeLogStore(
|
|||||||
getWorkflowRun: vi.fn(() => null),
|
getWorkflowRun: vi.fn(() => null),
|
||||||
getActiveWorkflowRuns: vi.fn((_workflowName?: string) => {
|
getActiveWorkflowRuns: vi.fn((_workflowName?: string) => {
|
||||||
if (_workflowName !== undefined) {
|
if (_workflowName !== undefined) {
|
||||||
return activeRuns.filter((r) => r.workflow === _workflowName);
|
return runsWithExitCode.filter((r) => r.workflow === _workflowName);
|
||||||
}
|
}
|
||||||
return activeRuns;
|
return runsWithExitCode;
|
||||||
}),
|
}),
|
||||||
getTriggerPayload: vi.fn((): unknown => ({ value: 42 })),
|
getTriggerPayload: vi.fn((): unknown => ({ value: 42 })),
|
||||||
getThreadEvents: vi.fn(
|
getThreadEvents: vi.fn(
|
||||||
@@ -127,8 +128,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
|
||||||
expect(mgr.activeCount("my-wf")).toBe(2);
|
expect(mgr.activeCount("my-wf")).toBe(2);
|
||||||
|
|
||||||
// Simulate unexpected exit (not shutdown)
|
// Simulate unexpected exit (not shutdown)
|
||||||
@@ -154,8 +155,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
expect(mgr.activeCount("my-wf")).toBe(2);
|
expect(mgr.activeCount("my-wf")).toBe(2);
|
||||||
|
|
||||||
const child = mockChildren[0];
|
const child = mockChildren[0];
|
||||||
@@ -179,7 +180,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
expect(mockChildren).toHaveLength(1);
|
expect(mockChildren).toHaveLength(1);
|
||||||
|
|
||||||
const child = mockChildren[0];
|
const child = mockChildren[0];
|
||||||
@@ -199,7 +200,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
|
|
||||||
it("sends resume-thread for 'started' runs from DB after respawn", async () => {
|
it("sends resume-thread for 'started' runs from DB after respawn", async () => {
|
||||||
const activeRuns = [
|
const activeRuns = [
|
||||||
{ runId: "run-started-1", workflow: "my-wf", status: "started" as const, ts: 1000 },
|
{ runId: "run-started-1", workflow: "my-wf", status: "started" as const, timestamp: 1000 },
|
||||||
];
|
];
|
||||||
const logStore = makeLogStore(activeRuns);
|
const logStore = makeLogStore(activeRuns);
|
||||||
logStore.getThreadMessages.mockReturnValue([
|
logStore.getThreadMessages.mockReturnValue([
|
||||||
@@ -212,7 +213,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
const firstChild = mockChildren[0];
|
const firstChild = mockChildren[0];
|
||||||
firstChild.exitCode = 1;
|
firstChild.exitCode = 1;
|
||||||
firstChild.connected = false;
|
firstChild.connected = false;
|
||||||
@@ -235,7 +236,6 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
expect(resumeCalls[0][0]).toMatchObject({
|
expect(resumeCalls[0][0]).toMatchObject({
|
||||||
type: "resume-thread",
|
type: "resume-thread",
|
||||||
runId: "run-started-1",
|
runId: "run-started-1",
|
||||||
triggerPayload: { trigger: "initial" },
|
|
||||||
});
|
});
|
||||||
expect(Array.isArray((resumeCalls[0][0] as Record<string, unknown>).messages)).toBe(true);
|
expect(Array.isArray((resumeCalls[0][0] as Record<string, unknown>).messages)).toBe(true);
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
|
|
||||||
it("re-queues 'queued' runs from DB after respawn", async () => {
|
it("re-queues 'queued' runs from DB after respawn", async () => {
|
||||||
const activeRuns = [
|
const activeRuns = [
|
||||||
{ runId: "run-queued-1", workflow: "my-wf", status: "queued" as const, ts: 900 },
|
{ runId: "run-queued-1", workflow: "my-wf", status: "queued" as const, timestamp: 900 },
|
||||||
];
|
];
|
||||||
const logStore = makeLogStore(activeRuns);
|
const logStore = makeLogStore(activeRuns);
|
||||||
logStore.getTriggerPayload.mockReturnValue({ queued: "payload" });
|
logStore.getTriggerPayload.mockReturnValue({ queued: "payload" });
|
||||||
@@ -257,7 +257,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
// Start one thread to fill the concurrency slot (so queued run stays queued on respawn)
|
// Start one thread to fill the concurrency slot (so queued run stays queued on respawn)
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
const firstChild = mockChildren[0];
|
const firstChild = mockChildren[0];
|
||||||
firstChild.exitCode = 1;
|
firstChild.exitCode = 1;
|
||||||
firstChild.connected = false;
|
firstChild.connected = false;
|
||||||
@@ -282,7 +282,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
const child = mockChildren[0];
|
const child = mockChildren[0];
|
||||||
const startCall = (child.send as ReturnType<typeof vi.fn>).mock.calls[0];
|
const startCall = (child.send as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
@@ -318,8 +318,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
const payload = { prompt: "build-docker for myrepo", maxRounds: 10 };
|
const launch = { prompt: "build-docker for myrepo", maxRounds: 10, dryRun: false };
|
||||||
mgr.startWorkflow("my-wf", payload);
|
mgr.startWorkflow("my-wf", launch);
|
||||||
|
|
||||||
const startedCall = logStore.upsertWorkflowRun.mock.calls.find(
|
const startedCall = logStore.upsertWorkflowRun.mock.calls.find(
|
||||||
(args: any[]) => (args[0] as { type: string }).type === "started",
|
(args: any[]) => (args[0] as { type: string }).type === "started",
|
||||||
@@ -328,7 +328,11 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
const logEntry = startedCall?.[0] as { payload: string | null };
|
const logEntry = startedCall?.[0] as { payload: string | null };
|
||||||
expect(logEntry.payload).not.toBeNull();
|
expect(logEntry.payload).not.toBeNull();
|
||||||
const parsed = JSON.parse(logEntry.payload as string) as Record<string, unknown>;
|
const parsed = JSON.parse(logEntry.payload as string) as Record<string, unknown>;
|
||||||
expect(parsed.triggerPayload).toMatchObject(payload);
|
expect(parsed).toMatchObject({
|
||||||
|
prompt: "build-docker for myrepo",
|
||||||
|
maxRounds: 10,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
|
|
||||||
const stopPromise = mgr.stop();
|
const stopPromise = mgr.stop();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
@@ -339,7 +343,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
describe("runId deduplication in crash recovery", () => {
|
describe("runId deduplication in crash recovery", () => {
|
||||||
it("does not push duplicate runIds into the queue during crash recovery", async () => {
|
it("does not push duplicate runIds into the queue during crash recovery", async () => {
|
||||||
const activeRuns = [
|
const activeRuns = [
|
||||||
{ runId: "run-queued-dup", workflow: "my-wf", status: "queued" as const, ts: 900 },
|
{ runId: "run-queued-dup", workflow: "my-wf", status: "queued" as const, timestamp: 900 },
|
||||||
];
|
];
|
||||||
const logStore = makeLogStore(activeRuns);
|
const logStore = makeLogStore(activeRuns);
|
||||||
logStore.getTriggerPayload.mockReturnValue({ q: 1 });
|
logStore.getTriggerPayload.mockReturnValue({ q: 1 });
|
||||||
@@ -350,7 +354,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
// Start one thread to fill the concurrency slot
|
// Start one thread to fill the concurrency slot
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
const firstChild = mockChildren[0];
|
const firstChild = mockChildren[0];
|
||||||
|
|
||||||
// Crash once → respawn → crash again → second respawn
|
// Crash once → respawn → crash again → second respawn
|
||||||
@@ -375,7 +379,12 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
|
|
||||||
it("does not add duplicate active runIds during crash recovery", async () => {
|
it("does not add duplicate active runIds during crash recovery", async () => {
|
||||||
const activeRuns = [
|
const activeRuns = [
|
||||||
{ runId: "run-started-dup", workflow: "my-wf", status: "started" as const, ts: 1000 },
|
{
|
||||||
|
runId: "run-started-dup",
|
||||||
|
workflow: "my-wf",
|
||||||
|
status: "started" as const,
|
||||||
|
timestamp: 1000,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const logStore = makeLogStore(activeRuns);
|
const logStore = makeLogStore(activeRuns);
|
||||||
logStore.getThreadMessages.mockReturnValue([]);
|
logStore.getThreadMessages.mockReturnValue([]);
|
||||||
@@ -386,7 +395,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
const firstChild = mockChildren[0];
|
const firstChild = mockChildren[0];
|
||||||
firstChild.exitCode = 1;
|
firstChild.exitCode = 1;
|
||||||
firstChild.connected = false;
|
firstChild.connected = false;
|
||||||
@@ -416,7 +425,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("crash-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("crash-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
// Crash the worker 6 times in rapid succession (within CRASH_WINDOW_MS = 60s)
|
// Crash the worker 6 times in rapid succession (within CRASH_WINDOW_MS = 60s)
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Unit + integration tests for daemon-ipc.ts — trigger-sense request type.
|
* Unit + integration tests for daemon-ipc.ts — trigger-sense request type.
|
||||||
*
|
*
|
||||||
* Tests cover:
|
* Tests cover:
|
||||||
* - parseRequest correctly accepts/rejects trigger-sense messages
|
* - parseDaemonIpcRequest (core) / server correctly accept or reject trigger-sense messages
|
||||||
* - createDaemonIpcServer routes trigger-sense to opts.triggerSense
|
* - createDaemonIpcServer routes trigger-sense to opts.triggerSense
|
||||||
* - Error response when triggerSense throws (unknown sense)
|
* - Error response when triggerSense throws (unknown sense)
|
||||||
* - Success response on valid sense trigger
|
* - Success response on valid sense trigger
|
||||||
@@ -31,6 +31,7 @@ function makeMockWorkflowManager() {
|
|||||||
stop: vi.fn(async () => {}),
|
stop: vi.fn(async () => {}),
|
||||||
totalActiveCount: vi.fn(() => 0),
|
totalActiveCount: vi.fn(() => 0),
|
||||||
drainAndRespawn: vi.fn(async () => {}),
|
drainAndRespawn: vi.fn(async () => {}),
|
||||||
|
drainWhenIdle: vi.fn(),
|
||||||
updateConfig: vi.fn(),
|
updateConfig: vi.fn(),
|
||||||
getActiveWorkflowRuns: vi.fn(() => []),
|
getActiveWorkflowRuns: vi.fn(() => []),
|
||||||
};
|
};
|
||||||
@@ -152,12 +153,18 @@ describe("daemon-ipc — trigger-sense", () => {
|
|||||||
const resp = await sendRaw(sockPath, {
|
const resp = await sendRaw(sockPath, {
|
||||||
type: "trigger-workflow",
|
type: "trigger-workflow",
|
||||||
workflow: "my-workflow",
|
workflow: "my-workflow",
|
||||||
payload: {},
|
prompt: "test prompt",
|
||||||
|
maxRounds: 10,
|
||||||
|
dryRun: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resp).toEqual({ ok: true });
|
expect(resp).toEqual({ ok: true });
|
||||||
expect(triggerSense).not.toHaveBeenCalled();
|
expect(triggerSense).not.toHaveBeenCalled();
|
||||||
expect(wfManager.startWorkflow).toHaveBeenCalledWith("my-workflow", {});
|
expect(wfManager.startWorkflow).toHaveBeenCalledWith("my-workflow", {
|
||||||
|
prompt: "test prompt",
|
||||||
|
maxRounds: 10,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("responds ok:false for completely unknown request type", async () => {
|
it("responds ok:false for completely unknown request type", async () => {
|
||||||
@@ -194,8 +201,20 @@ describe("daemon-ipc — list-senses", () => {
|
|||||||
|
|
||||||
it("responds ok:true with senses populated from listSenses", async () => {
|
it("responds ok:true with senses populated from listSenses", async () => {
|
||||||
const sensesData = [
|
const sensesData = [
|
||||||
{ name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, lastSignalTs: 1000 },
|
{
|
||||||
{ name: "disk-usage", group: "system", throttle: 30000, timeout: null, lastSignalTs: null },
|
name: "cpu-usage",
|
||||||
|
group: "system",
|
||||||
|
throttle: 5000,
|
||||||
|
timeout: 3000,
|
||||||
|
lastSignalTimestamp: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disk-usage",
|
||||||
|
group: "system",
|
||||||
|
throttle: 30000,
|
||||||
|
timeout: null,
|
||||||
|
lastSignalTimestamp: null,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const listSenses = vi.fn(() => sensesData);
|
const listSenses = vi.fn(() => sensesData);
|
||||||
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
|
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Verifies that:
|
* Verifies that:
|
||||||
* - drainAndRespawn() sends shutdown, waits for exit, then respawns the worker
|
* - drainAndRespawn() sends shutdown, waits for exit, then respawns the worker
|
||||||
|
* - drainWhenIdle() defers drain+respawn until in-flight threads finish
|
||||||
* - Kernel dispatches handleWorkflowFileChange when file-watcher emits a workflow change
|
* - Kernel dispatches handleWorkflowFileChange when file-watcher emits a workflow change
|
||||||
* - Kernel logs a workflow_reload system event on hot reload
|
* - Kernel logs a workflow_reload system event on hot reload
|
||||||
* - drainAndRespawn on a non-existent worker is a no-op
|
* - drainAndRespawn on a non-existent worker is a no-op
|
||||||
@@ -10,6 +11,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
|
import type { NerveConfig, WorkflowConfig } from "@uncaged/nerve-core";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -102,7 +106,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
|||||||
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
expect(mockChildren).toHaveLength(1);
|
expect(mockChildren).toHaveLength(1);
|
||||||
|
|
||||||
// Remove workflow from config before drain completes
|
// Remove workflow from config before drain completes
|
||||||
@@ -121,8 +125,8 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
|||||||
const config = makeWfConfig({ "my-wf": { concurrency: 2, overflow: "drop" } });
|
const config = makeWfConfig({ "my-wf": { concurrency: 2, overflow: "drop" } });
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
expect(mgr.activeCount("my-wf")).toBe(2);
|
expect(mgr.activeCount("my-wf")).toBe(2);
|
||||||
|
|
||||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||||
@@ -153,7 +157,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
|||||||
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
expect(mockChildren).toHaveLength(1);
|
expect(mockChildren).toHaveLength(1);
|
||||||
|
|
||||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||||
@@ -169,7 +173,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
|||||||
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
expect(mockChildren).toHaveLength(1);
|
expect(mockChildren).toHaveLength(1);
|
||||||
|
|
||||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||||
@@ -186,7 +190,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
|||||||
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
@@ -211,14 +215,14 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
|||||||
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-wf", { prompt: "first", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "first", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await drainPromise;
|
await drainPromise;
|
||||||
|
|
||||||
// Start a new thread on the fresh worker
|
// Start a new thread on the fresh worker
|
||||||
mgr.startWorkflow("my-wf", { prompt: "second", maxRounds: 10 });
|
mgr.startWorkflow("my-wf", { prompt: "second", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
const newChild = mockChildren[1];
|
const newChild = mockChildren[1];
|
||||||
const startCalls = (newChild.send as ReturnType<typeof vi.fn>).mock.calls.filter(
|
const startCalls = (newChild.send as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||||
@@ -235,33 +239,230 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
|
describe("WorkflowManager — drainWhenIdle (hot reload without interrupting in-flight)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockChildren.length = 0;
|
mockChildren.length = 0;
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send shutdown while a thread is still active", () => {
|
||||||
|
const logStore = makeLogStore();
|
||||||
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
const child = mockChildren[0];
|
||||||
|
|
||||||
|
mgr.drainWhenIdle("my-wf");
|
||||||
|
|
||||||
|
const shutdownCalls = (child.send as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||||
|
(args: unknown[]) =>
|
||||||
|
args[0] !== null &&
|
||||||
|
typeof args[0] === "object" &&
|
||||||
|
(args[0] as Record<string, unknown>).type === "shutdown",
|
||||||
|
);
|
||||||
|
expect(shutdownCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends shutdown after the last active thread completes (deferred drain)", async () => {
|
||||||
|
const logStore = makeLogStore();
|
||||||
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
const child = mockChildren[0];
|
||||||
|
const runId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { runId: string };
|
||||||
|
|
||||||
|
mgr.drainWhenIdle("my-wf");
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: "thread-event",
|
||||||
|
runId: runId.runId,
|
||||||
|
eventType: "completed",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(child.send).toHaveBeenCalledWith(expect.objectContaining({ type: "shutdown" }));
|
||||||
|
expect(mockChildren).toHaveLength(2);
|
||||||
|
|
||||||
|
const stopPromise = mgr.stop();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await stopPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for all concurrent threads before draining", async () => {
|
||||||
|
const logStore = makeLogStore();
|
||||||
|
const config = makeWfConfig({ "my-wf": { concurrency: 2, overflow: "drop" } });
|
||||||
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
|
mgr.startWorkflow("my-wf", { prompt: "a", maxRounds: 10, dryRun: false });
|
||||||
|
mgr.startWorkflow("my-wf", { prompt: "b", maxRounds: 10, dryRun: false });
|
||||||
|
const child = mockChildren[0];
|
||||||
|
const sendMock = child.send as ReturnType<typeof vi.fn>;
|
||||||
|
const runIdA = (sendMock.mock.calls[0][0] as { runId: string }).runId;
|
||||||
|
const runIdB = (sendMock.mock.calls[1][0] as { runId: string }).runId;
|
||||||
|
|
||||||
|
mgr.drainWhenIdle("my-wf");
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: "thread-event",
|
||||||
|
runId: runIdA,
|
||||||
|
eventType: "completed",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const shutdownBefore = sendMock.mock.calls.filter(
|
||||||
|
(args: unknown[]) =>
|
||||||
|
args[0] !== null &&
|
||||||
|
typeof args[0] === "object" &&
|
||||||
|
(args[0] as Record<string, unknown>).type === "shutdown",
|
||||||
|
);
|
||||||
|
expect(shutdownBefore).toHaveLength(0);
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: "thread-event",
|
||||||
|
runId: runIdB,
|
||||||
|
eventType: "completed",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(child.send).toHaveBeenCalledWith(expect.objectContaining({ type: "shutdown" }));
|
||||||
|
|
||||||
|
const stopPromise = mgr.stop();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await stopPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicate drainWhenIdle while busy only schedules one deferred drain", async () => {
|
||||||
|
const logStore = makeLogStore();
|
||||||
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
const child = mockChildren[0];
|
||||||
|
const runId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { runId: string };
|
||||||
|
|
||||||
|
mgr.drainWhenIdle("my-wf");
|
||||||
|
mgr.drainWhenIdle("my-wf");
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: "thread-event",
|
||||||
|
runId: runId.runId,
|
||||||
|
eventType: "completed",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const shutdownCalls = (child.send as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||||
|
(args: unknown[]) =>
|
||||||
|
args[0] !== null &&
|
||||||
|
typeof args[0] === "object" &&
|
||||||
|
(args[0] as Record<string, unknown>).type === "shutdown",
|
||||||
|
);
|
||||||
|
expect(shutdownCalls).toHaveLength(1);
|
||||||
|
|
||||||
|
const stopPromise = mgr.stop();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await stopPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deferred drain runs after workflow-error clears the active thread", async () => {
|
||||||
|
const logStore = makeLogStore();
|
||||||
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
|
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
const child = mockChildren[0];
|
||||||
|
const runId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { runId: string };
|
||||||
|
|
||||||
|
mgr.drainWhenIdle("my-wf");
|
||||||
|
|
||||||
|
child.emit("message", {
|
||||||
|
type: "workflow-error",
|
||||||
|
runId: runId.runId,
|
||||||
|
error: "role failed",
|
||||||
|
exitCode: 1,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(child.send).toHaveBeenCalledWith(expect.objectContaining({ type: "shutdown" }));
|
||||||
|
|
||||||
|
const stopPromise = mgr.stop();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await stopPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drains immediately when there is no in-flight thread", async () => {
|
||||||
|
const logStore = makeLogStore();
|
||||||
|
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
|
||||||
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
|
mgr.startWorkflow("my-wf", { prompt: "once", maxRounds: 10, dryRun: false });
|
||||||
|
const firstChild = mockChildren[0];
|
||||||
|
const runId = (firstChild.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as {
|
||||||
|
runId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
firstChild.emit("message", {
|
||||||
|
type: "thread-event",
|
||||||
|
runId: runId.runId,
|
||||||
|
eventType: "completed",
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
mgr.drainWhenIdle("my-wf");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(firstChild.send).toHaveBeenCalledWith(expect.objectContaining({ type: "shutdown" }));
|
||||||
|
expect(mockChildren).toHaveLength(2);
|
||||||
|
|
||||||
|
const stopPromise = mgr.stop();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await stopPromise;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockChildren.length = 0;
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-hot-reload-"));
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handleWorkflowFileChange logs workflow_reload system event", async () => {
|
it("handleWorkflowFileChange logs workflow_reload system event", async () => {
|
||||||
const logStore = makeLogStore();
|
const logStore = makeLogStore();
|
||||||
const config: NerveConfig = {
|
const config: NerveConfig = {
|
||||||
senses: {},
|
senses: {},
|
||||||
reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any],
|
reflexes: [],
|
||||||
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
|
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const kernel = createKernel(config, "/tmp/nerve-hot-reload-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger a workflow thread so a worker is spawned
|
// Trigger a workflow thread so a worker is spawned
|
||||||
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
// Manually call drainAndRespawn (simulating what kernel does on workflow file change)
|
// Manually call drainAndRespawn (simulating what kernel does on workflow file change)
|
||||||
const drainPromise = kernel.workflowManager.drainAndRespawn("my-wf", 1000);
|
const drainPromise = kernel.workflowManager.drainAndRespawn("my-wf", 1000);
|
||||||
@@ -285,26 +486,30 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
|
|||||||
const logStore = makeLogStore();
|
const logStore = makeLogStore();
|
||||||
const initialConfig: NerveConfig = {
|
const initialConfig: NerveConfig = {
|
||||||
senses: {},
|
senses: {},
|
||||||
reflexes: [{ kind: "workflow", workflow: "old-wf", on: null } as any],
|
reflexes: [],
|
||||||
workflows: { "old-wf": { concurrency: 1, overflow: "drop" } },
|
workflows: { "old-wf": { concurrency: 1, overflow: "drop" } },
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const kernel = createKernel(initialConfig, "/tmp/nerve-hot-reload-test", {
|
const kernel = createKernel(initialConfig, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spawn a worker for old-wf
|
// Spawn a worker for old-wf
|
||||||
kernel.workflowManager.startWorkflow("old-wf", { prompt: "test", maxRounds: 10 });
|
kernel.workflowManager.startWorkflow("old-wf", {
|
||||||
|
prompt: "test",
|
||||||
|
maxRounds: 10,
|
||||||
|
dryRun: false,
|
||||||
|
});
|
||||||
expect(mockChildren).toHaveLength(1);
|
expect(mockChildren).toHaveLength(1);
|
||||||
|
|
||||||
// Reload config without old-wf
|
// Reload config without old-wf
|
||||||
const newConfig: NerveConfig = {
|
const newConfig: NerveConfig = {
|
||||||
senses: {},
|
senses: {},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
kernel.reloadConfig(newConfig);
|
kernel.reloadConfig(newConfig);
|
||||||
|
|
||||||
@@ -324,23 +529,23 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
|
|||||||
const logStore = makeLogStore();
|
const logStore = makeLogStore();
|
||||||
const initialConfig: NerveConfig = {
|
const initialConfig: NerveConfig = {
|
||||||
senses: {},
|
senses: {},
|
||||||
reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any],
|
reflexes: [],
|
||||||
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
|
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const kernel = createKernel(initialConfig, "/tmp/nerve-hot-reload-test", {
|
const kernel = createKernel(initialConfig, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
const workersBefore = mockChildren.length;
|
const workersBefore = mockChildren.length;
|
||||||
|
|
||||||
// Reload with updated concurrency — should NOT spawn a new workflow worker
|
// Reload with updated concurrency — should NOT spawn a new workflow worker
|
||||||
const newConfig: NerveConfig = {
|
const newConfig: NerveConfig = {
|
||||||
senses: {},
|
senses: {},
|
||||||
reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any],
|
reflexes: [],
|
||||||
workflows: { "my-wf": { concurrency: 5, overflow: "queue", maxQueue: 50 } },
|
workflows: { "my-wf": { concurrency: 5, overflow: "queue", maxQueue: 50 } },
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
@@ -354,8 +559,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
|
|||||||
expect(kernel.workflowManager.activeCount("my-wf")).toBe(1);
|
expect(kernel.workflowManager.activeCount("my-wf")).toBe(1);
|
||||||
|
|
||||||
// Can now start up to 5 concurrent threads (previously only 1)
|
// Can now start up to 5 concurrent threads (previously only 1)
|
||||||
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
|
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
expect(kernel.workflowManager.activeCount("my-wf")).toBe(3);
|
expect(kernel.workflowManager.activeCount("my-wf")).toBe(3);
|
||||||
|
|
||||||
const stopPromise = kernel.stop();
|
const stopPromise = kernel.stop();
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
* artifacts are required.
|
* artifacts are required.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import type { Signal } from "@uncaged/nerve-core";
|
import type { Signal } from "@uncaged/nerve-core";
|
||||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { createKernel } from "../kernel.js";
|
import { createKernel } from "../kernel.js";
|
||||||
import type { Kernel } from "../kernel.js";
|
import type { Kernel } from "../kernel.js";
|
||||||
@@ -26,7 +28,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -55,12 +57,18 @@ async function pollUntil(
|
|||||||
|
|
||||||
describe("kernel integration — real child processes", () => {
|
describe("kernel integration — real child processes", () => {
|
||||||
let kernel: Kernel | null = null;
|
let kernel: Kernel | null = null;
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-integration-"));
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (kernel !== null) {
|
if (kernel !== null) {
|
||||||
await kernel.stop();
|
await kernel.stop();
|
||||||
kernel = null;
|
kernel = null;
|
||||||
}
|
}
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns correct groups and senseCount", () => {
|
it("returns correct groups and senseCount", () => {
|
||||||
@@ -71,7 +79,7 @@ describe("kernel integration — real child processes", () => {
|
|||||||
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
kernel = createKernel(config, "/tmp/nerve-integration-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +91,7 @@ describe("kernel integration — real child processes", () => {
|
|||||||
|
|
||||||
it("workers start and respond to compute messages with signals", async () => {
|
it("workers start and respond to compute messages with signals", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
kernel = createKernel(config, "/tmp/nerve-integration-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +123,7 @@ describe("kernel integration — real child processes", () => {
|
|||||||
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
kernel = createKernel(config, "/tmp/nerve-integration-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,7 +139,7 @@ describe("kernel integration — real child processes", () => {
|
|||||||
|
|
||||||
it("compute round-trip: worker receives compute and sends signal back through bus", async () => {
|
it("compute round-trip: worker receives compute and sends signal back through bus", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
kernel = createKernel(config, "/tmp/nerve-integration-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,7 +166,7 @@ describe("kernel integration — real child processes", () => {
|
|||||||
|
|
||||||
it("crash recovery: kernel respawns worker after unexpected exit and new worker is functional", async () => {
|
it("crash recovery: kernel respawns worker after unexpected exit and new worker is functional", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
kernel = createKernel(config, "/tmp/nerve-integration-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -73,7 +76,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -84,13 +87,17 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("kernel — getHealth", () => {
|
describe("kernel — getHealth", () => {
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockChildren.length = 0;
|
mockChildren.length = 0;
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-p6-health-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns correct health shape", async () => {
|
it("returns correct health shape", async () => {
|
||||||
@@ -101,7 +108,7 @@ describe("kernel — getHealth", () => {
|
|||||||
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
const health = kernel.getHealth();
|
const health = kernel.getHealth();
|
||||||
expect(health.activeSenses).toBe(3);
|
expect(health.activeSenses).toBe(3);
|
||||||
@@ -115,18 +122,22 @@ describe("kernel — getHealth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("kernel — restartGroup", () => {
|
describe("kernel — restartGroup", () => {
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockChildren.length = 0;
|
mockChildren.length = 0;
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-p6-restart-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends shutdown to old worker and spawns new one", async () => {
|
it("sends shutdown to old worker and spawns new one", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
expect(mockChildren.length).toBe(1);
|
expect(mockChildren.length).toBe(1);
|
||||||
const oldChild = mockChildren[0];
|
const oldChild = mockChildren[0];
|
||||||
@@ -146,7 +157,7 @@ describe("kernel — restartGroup", () => {
|
|||||||
|
|
||||||
it("restartGroup on unknown group does nothing", async () => {
|
it("restartGroup on unknown group does nothing", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
expect(mockChildren.length).toBe(1);
|
expect(mockChildren.length).toBe(1);
|
||||||
await kernel.restartGroup("nonexistent");
|
await kernel.restartGroup("nonexistent");
|
||||||
@@ -158,18 +169,22 @@ describe("kernel — restartGroup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("kernel — reloadConfig", () => {
|
describe("kernel — reloadConfig", () => {
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockChildren.length = 0;
|
mockChildren.length = 0;
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-p6-reload-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds new group worker when new sense group appears", async () => {
|
it("adds new group worker when new sense group appears", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
expect(mockChildren.length).toBe(1); // only system group
|
expect(mockChildren.length).toBe(1); // only system group
|
||||||
expect(kernel.groups.has("network")).toBe(false);
|
expect(kernel.groups.has("network")).toBe(false);
|
||||||
@@ -180,8 +195,8 @@ describe("kernel — reloadConfig", () => {
|
|||||||
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(kernel.groups.has("network")).toBe(true);
|
expect(kernel.groups.has("network")).toBe(true);
|
||||||
@@ -197,10 +212,10 @@ describe("kernel — reloadConfig", () => {
|
|||||||
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
expect(mockChildren.length).toBe(2);
|
expect(mockChildren.length).toBe(2);
|
||||||
expect(kernel.groups.has("network")).toBe(true);
|
expect(kernel.groups.has("network")).toBe(true);
|
||||||
@@ -212,8 +227,8 @@ describe("kernel — reloadConfig", () => {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(kernel.groups.has("network")).toBe(false);
|
expect(kernel.groups.has("network")).toBe(false);
|
||||||
@@ -225,7 +240,7 @@ describe("kernel — reloadConfig", () => {
|
|||||||
|
|
||||||
it("health reflects updated sense count after reloadConfig", async () => {
|
it("health reflects updated sense count after reloadConfig", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
expect(kernel.getHealth().activeSenses).toBe(1);
|
expect(kernel.getHealth().activeSenses).toBe(1);
|
||||||
|
|
||||||
@@ -235,8 +250,8 @@ describe("kernel — reloadConfig", () => {
|
|||||||
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(kernel.getHealth().activeSenses).toBe(2);
|
expect(kernel.getHealth().activeSenses).toBe(2);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -92,7 +95,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -103,18 +106,22 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("kernel.triggerSense()", () => {
|
describe("kernel.triggerSense()", () => {
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockChildren.length = 0;
|
mockChildren.length = 0;
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-trigger-sense-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws for an unknown sense name", async () => {
|
it("throws for an unknown sense name", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: null,
|
workerScript: null,
|
||||||
logStore: makeMockLogStore() as never,
|
logStore: makeMockLogStore() as never,
|
||||||
});
|
});
|
||||||
@@ -132,7 +139,7 @@ describe("kernel.triggerSense()", () => {
|
|||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
});
|
});
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: null,
|
workerScript: null,
|
||||||
logStore: makeMockLogStore() as never,
|
logStore: makeMockLogStore() as never,
|
||||||
});
|
});
|
||||||
@@ -162,7 +169,7 @@ describe("kernel.triggerSense()", () => {
|
|||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
});
|
});
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: null,
|
workerScript: null,
|
||||||
logStore: makeMockLogStore() as never,
|
logStore: makeMockLogStore() as never,
|
||||||
});
|
});
|
||||||
@@ -185,7 +192,7 @@ describe("kernel.triggerSense()", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: null,
|
workerScript: null,
|
||||||
logStore: makeMockLogStore() as never,
|
logStore: makeMockLogStore() as never,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -95,7 +98,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -106,40 +109,50 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("kernel + workflowManager integration", () => {
|
describe("kernel + workflowManager integration", () => {
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockChildren.length = 0;
|
mockChildren.length = 0;
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-wf-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sense signal triggers workflow via reflex", () => {
|
describe("sense compute triggers workflow via return value", () => {
|
||||||
it("calls workflowManager.startWorkflow when a sense signal fires on a workflow reflex", async () => {
|
it("calls workflowManager.startWorkflow when a sense compute returns a workflow launch", async () => {
|
||||||
const logStore = makeLogStore();
|
const logStore = makeLogStore();
|
||||||
const config = makeConfig({
|
const config = makeConfig({
|
||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["cpu-usage"] } as any],
|
reflexes: [],
|
||||||
workflows: { "my-workflow": { concurrency: 2, overflow: "drop" } },
|
workflows: { "my-workflow": { concurrency: 2, overflow: "drop" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit a signal from "cpu-usage" on the bus
|
// Simulate a sense worker sending a signal with workflow launch payload
|
||||||
const { createSignalBus } = await import("../signal-bus.js");
|
// The kernel's handleWorkerMessage processes "signal" type messages
|
||||||
void createSignalBus; // ensure import resolves
|
// and uses routeSenseComputeOutput to detect workflow launches
|
||||||
kernel.bus.emit({ id: 1, senseId: "cpu-usage", payload: { value: 80 }, ts: Date.now() });
|
const workerPool = mockChildren[0];
|
||||||
|
if (workerPool) {
|
||||||
|
// Simulate the worker sending a signal message with workflow field
|
||||||
|
workerPool.emit("message", {
|
||||||
|
type: "signal",
|
||||||
|
sense: "cpu-usage",
|
||||||
|
payload: { workflow: "my-workflow|10|run this workflow" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// The workflow worker should be spawned (one for the sense group, one for workflow)
|
// A workflow worker should be spawned and a start-thread message sent
|
||||||
// The sense group worker is mockChildren[0]; the workflow worker is mockChildren[1]
|
|
||||||
// We need to check that a start-thread message was sent to the workflow worker
|
|
||||||
const workflowWorker = mockChildren.find((c) =>
|
const workflowWorker = mockChildren.find((c) =>
|
||||||
(c.send as ReturnType<typeof vi.fn>).mock.calls.some(
|
(c.send as ReturnType<typeof vi.fn>).mock.calls.some(
|
||||||
(args: unknown[]) =>
|
(args: unknown[]) =>
|
||||||
@@ -155,23 +168,30 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
await stopPromise;
|
await stopPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the signal payload as triggerPayload to the workflow", async () => {
|
it("passes prompt and maxRounds from the workflow field to the workflow", async () => {
|
||||||
const logStore = makeLogStore();
|
const logStore = makeLogStore();
|
||||||
const config = makeConfig({
|
const config = makeConfig({
|
||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "workflow", workflow: "alert-workflow", on: ["cpu-usage"] } as any],
|
reflexes: [],
|
||||||
workflows: { "alert-workflow": { concurrency: 1, overflow: "drop" } },
|
workflows: { "alert-workflow": { concurrency: 1, overflow: "drop" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = { level: "critical", value: 99 };
|
// Simulate sense worker returning a workflow launch
|
||||||
kernel.bus.emit({ id: 1, senseId: "cpu-usage", payload, ts: Date.now() });
|
const workerPool = mockChildren[0];
|
||||||
|
if (workerPool) {
|
||||||
|
workerPool.emit("message", {
|
||||||
|
type: "signal",
|
||||||
|
sense: "cpu-usage",
|
||||||
|
payload: { workflow: "alert-workflow|5|handle critical alert" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Find the start-thread call and verify triggerPayload
|
// Find the start-thread call and verify triggerPayload
|
||||||
const startThreadCall = mockChildren
|
const startThreadCall = mockChildren
|
||||||
@@ -187,7 +207,9 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
expect(startThreadCall?.[0]).toMatchObject({
|
expect(startThreadCall?.[0]).toMatchObject({
|
||||||
type: "start-thread",
|
type: "start-thread",
|
||||||
workflow: "alert-workflow",
|
workflow: "alert-workflow",
|
||||||
triggerPayload: payload,
|
prompt: "handle critical alert",
|
||||||
|
maxRounds: 5,
|
||||||
|
dryRun: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopPromise = kernel.stop();
|
const stopPromise = kernel.stop();
|
||||||
@@ -202,19 +224,26 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
"disk-io": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"disk-io": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "workflow", workflow: "my-workflow", on: ["disk-io"] } as any],
|
reflexes: [],
|
||||||
workflows: { "my-workflow": { concurrency: 1, overflow: "drop" } },
|
workflows: { "my-workflow": { concurrency: 1, overflow: "drop" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit signal from cpu-usage — NOT in the workflow's "on" list
|
// Emit a regular signal (no workflow field) — should NOT trigger any workflow
|
||||||
kernel.bus.emit({ id: 1, senseId: "cpu-usage", payload: 50, ts: Date.now() });
|
const workerPool = mockChildren[0];
|
||||||
|
if (workerPool) {
|
||||||
|
workerPool.emit("message", {
|
||||||
|
type: "signal",
|
||||||
|
sense: "cpu-usage",
|
||||||
|
payload: 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// No workflow worker should have been spawned (only the sense group worker)
|
// No workflow should have been started
|
||||||
const workflowWorkerSpawned = mockChildren.some((c) =>
|
const workflowWorkerSpawned = mockChildren.some((c) =>
|
||||||
(c.send as ReturnType<typeof vi.fn>).mock.calls.some(
|
(c.send as ReturnType<typeof vi.fn>).mock.calls.some(
|
||||||
(args: unknown[]) =>
|
(args: unknown[]) =>
|
||||||
@@ -232,22 +261,30 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("workflow events are logged", () => {
|
describe("workflow events are logged", () => {
|
||||||
it("logs a 'started' event when workflow thread is triggered", async () => {
|
it("logs a 'started' event when workflow thread is triggered via sense compute", async () => {
|
||||||
const logStore = makeLogStore();
|
const logStore = makeLogStore();
|
||||||
const config = makeConfig({
|
const config = makeConfig({
|
||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "workflow", workflow: "log-test-workflow", on: ["cpu-usage"] } as any],
|
reflexes: [],
|
||||||
workflows: { "log-test-workflow": { concurrency: 2, overflow: "drop" } },
|
workflows: { "log-test-workflow": { concurrency: 2, overflow: "drop" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
kernel.bus.emit({ id: 1, senseId: "cpu-usage", payload: null, ts: Date.now() });
|
// Simulate sense compute returning a workflow launch
|
||||||
|
const workerPool = mockChildren[0];
|
||||||
|
if (workerPool) {
|
||||||
|
workerPool.emit("message", {
|
||||||
|
type: "signal",
|
||||||
|
sense: "cpu-usage",
|
||||||
|
payload: { workflow: "log-test-workflow|10|test prompt" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
|
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ source: "workflow", type: "started" }),
|
expect.objectContaining({ source: "workflow", type: "started" }),
|
||||||
@@ -261,35 +298,42 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("reloadConfig handles workflow changes", () => {
|
describe("reloadConfig handles workflow changes", () => {
|
||||||
it("new workflow reflexes are active after reloadConfig", async () => {
|
it("new workflows are available after reloadConfig", async () => {
|
||||||
const logStore = makeLogStore();
|
const logStore = makeLogStore();
|
||||||
const initialConfig = makeConfig({
|
const initialConfig = makeConfig({
|
||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const kernel = createKernel(initialConfig, "/tmp/nerve-test", {
|
const kernel = createKernel(initialConfig, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload with a workflow reflex added
|
// Reload with a workflow added
|
||||||
const newConfig: NerveConfig = {
|
const newConfig: NerveConfig = {
|
||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "workflow", workflow: "new-workflow", on: ["cpu-usage"] } as any],
|
reflexes: [],
|
||||||
workflows: { "new-workflow": { concurrency: 1, overflow: "drop" } },
|
workflows: { "new-workflow": { concurrency: 1, overflow: "drop" } },
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
kernel.reloadConfig(newConfig);
|
kernel.reloadConfig(newConfig);
|
||||||
|
|
||||||
// Now emit a signal — should trigger the new workflow
|
// Simulate sense compute returning a workflow launch for the new workflow
|
||||||
kernel.bus.emit({ id: 2, senseId: "cpu-usage", payload: "reload-test", ts: Date.now() });
|
const workerPool = mockChildren[0];
|
||||||
|
if (workerPool) {
|
||||||
|
workerPool.emit("message", {
|
||||||
|
type: "signal",
|
||||||
|
sense: "cpu-usage",
|
||||||
|
payload: { workflow: "new-workflow|10|reload test" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const startThreadCall = mockChildren
|
const startThreadCall = mockChildren
|
||||||
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
|
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
|
||||||
@@ -308,29 +352,29 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
await stopPromise;
|
await stopPromise;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("old workflow reflexes are removed after reloadConfig", async () => {
|
it("old workflows are removed after reloadConfig", async () => {
|
||||||
const logStore = makeLogStore();
|
const logStore = makeLogStore();
|
||||||
const initialConfig = makeConfig({
|
const initialConfig = makeConfig({
|
||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "workflow", workflow: "old-workflow", on: ["cpu-usage"] } as any],
|
reflexes: [],
|
||||||
workflows: { "old-workflow": { concurrency: 1, overflow: "drop" } },
|
workflows: { "old-workflow": { concurrency: 1, overflow: "drop" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const kernel = createKernel(initialConfig, "/tmp/nerve-test", {
|
const kernel = createKernel(initialConfig, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload with the workflow reflex removed
|
// Reload with the workflow removed
|
||||||
const newConfig: NerveConfig = {
|
const newConfig: NerveConfig = {
|
||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
kernel.reloadConfig(newConfig);
|
kernel.reloadConfig(newConfig);
|
||||||
|
|
||||||
@@ -339,8 +383,15 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
(c.send as ReturnType<typeof vi.fn>).mockClear();
|
(c.send as ReturnType<typeof vi.fn>).mockClear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit a signal — old-workflow should NOT be triggered
|
// Simulate sense compute trying to launch the old workflow — it should still not start
|
||||||
kernel.bus.emit({ id: 3, senseId: "cpu-usage", payload: "after-reload", ts: Date.now() });
|
const workerPool = mockChildren[0];
|
||||||
|
if (workerPool) {
|
||||||
|
workerPool.emit("message", {
|
||||||
|
type: "signal",
|
||||||
|
sense: "cpu-usage",
|
||||||
|
payload: { workflow: "old-workflow|10|should not work" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const startThreadCall = mockChildren
|
const startThreadCall = mockChildren
|
||||||
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
|
.flatMap((c) => (c.send as ReturnType<typeof vi.fn>).mock.calls as [unknown][])
|
||||||
@@ -366,17 +417,24 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "workflow", workflow: "shutdown-test", on: ["cpu-usage"] } as any],
|
reflexes: [],
|
||||||
workflows: { "shutdown-test": { concurrency: 1, overflow: "drop" } },
|
workflows: { "shutdown-test": { concurrency: 1, overflow: "drop" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger a workflow so a worker is spawned
|
// Trigger a workflow via sense compute return value
|
||||||
kernel.bus.emit({ id: 1, senseId: "cpu-usage", payload: null, ts: Date.now() });
|
const workerPool = mockChildren[0];
|
||||||
|
if (workerPool) {
|
||||||
|
workerPool.emit("message", {
|
||||||
|
type: "signal",
|
||||||
|
sense: "cpu-usage",
|
||||||
|
payload: { workflow: "shutdown-test|10|test" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const stopPromise = kernel.stop();
|
const stopPromise = kernel.stop();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
@@ -389,7 +447,7 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
|
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
@@ -408,11 +466,11 @@ describe("kernel + workflowManager integration", () => {
|
|||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "workflow", workflow: "health-wf", on: ["cpu-usage"] } as any],
|
reflexes: [],
|
||||||
workflows: { "health-wf": { concurrency: 2, overflow: "drop" } },
|
workflows: { "health-wf": { concurrency: 2, overflow: "drop" } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test", {
|
const kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: "fake-worker.js",
|
workerScript: "fake-worker.js",
|
||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -70,13 +70,17 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("kernel — message routing", () => {
|
describe("kernel — message routing", () => {
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockChildren.length = 0;
|
mockChildren.length = 0;
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-msg-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes signal message to bus without throwing", async () => {
|
it("routes signal message to bus without throwing", async () => {
|
||||||
@@ -86,7 +90,7 @@ describe("kernel — message routing", () => {
|
|||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
});
|
});
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
expect(mockChildren.length).toBe(1);
|
expect(mockChildren.length).toBe(1);
|
||||||
const child = mockChildren[0];
|
const child = mockChildren[0];
|
||||||
@@ -129,7 +133,7 @@ describe("kernel — message routing", () => {
|
|||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
});
|
});
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
const child = mockChildren[0];
|
const child = mockChildren[0];
|
||||||
child.emit("message", { type: "error", sense: "cpu-usage", error: "compute failed" });
|
child.emit("message", { type: "error", sense: "cpu-usage", error: "compute failed" });
|
||||||
@@ -150,7 +154,7 @@ describe("kernel — message routing", () => {
|
|||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
});
|
});
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
const child = mockChildren[0];
|
const child = mockChildren[0];
|
||||||
const callsBefore = stderrSpy.mock.calls.length;
|
const callsBefore = stderrSpy.mock.calls.length;
|
||||||
@@ -170,7 +174,7 @@ describe("kernel — message routing", () => {
|
|||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
});
|
});
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
const child = mockChildren[0];
|
const child = mockChildren[0];
|
||||||
expect(() => child.emit("message", { type: "unknown-type" })).not.toThrow();
|
expect(() => child.emit("message", { type: "unknown-type" })).not.toThrow();
|
||||||
@@ -183,13 +187,17 @@ describe("kernel — message routing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("kernel — groupForSense mapping", () => {
|
describe("kernel — groupForSense mapping", () => {
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockChildren.length = 0;
|
mockChildren.length = 0;
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-groups-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spawns one worker per unique group", async () => {
|
it("spawns one worker per unique group", async () => {
|
||||||
@@ -200,10 +208,10 @@ describe("kernel — groupForSense mapping", () => {
|
|||||||
"net-usage": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-usage": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
const kernel = createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
// system and network = 2 unique groups
|
// system and network = 2 unique groups
|
||||||
expect(mockChildren.length).toBe(2);
|
expect(mockChildren.length).toBe(2);
|
||||||
@@ -215,9 +223,9 @@ describe("kernel — groupForSense mapping", () => {
|
|||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: null }],
|
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: [] }],
|
||||||
});
|
});
|
||||||
createKernel(config, "/tmp/nerve-test");
|
const kernel = createKernel(config, nerveRoot);
|
||||||
|
|
||||||
const child = mockChildren[0];
|
const child = mockChildren[0];
|
||||||
vi.advanceTimersByTime(500);
|
vi.advanceTimersByTime(500);
|
||||||
@@ -225,5 +233,7 @@ describe("kernel — groupForSense mapping", () => {
|
|||||||
expect(child.send).toHaveBeenCalledWith(
|
expect(child.send).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ type: "compute", sense: "cpu-usage" }),
|
expect.objectContaining({ type: "compute", sense: "cpu-usage" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await kernel.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ describe("LogStore + ReflexScheduler integration", () => {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
|
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
const bus = createSignalBus();
|
const bus = createSignalBus();
|
||||||
const triggered: string[] = [];
|
const triggered: string[] = [];
|
||||||
@@ -38,7 +38,7 @@ describe("LogStore + ReflexScheduler integration", () => {
|
|||||||
logStore,
|
logStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
const signal: Signal = { id: 1, senseId: "cpu-usage", payload: 42, ts: Date.now() };
|
const signal: Signal = { id: 1, senseId: "cpu-usage", payload: 42, timestamp: Date.now() };
|
||||||
bus.emit(signal);
|
bus.emit(signal);
|
||||||
|
|
||||||
const logs = logStore.query({ source: "reflex", type: "run_start" });
|
const logs = logStore.query({ source: "reflex", type: "run_start" });
|
||||||
@@ -56,9 +56,9 @@ describe("LogStore + ReflexScheduler integration", () => {
|
|||||||
senses: {
|
senses: {
|
||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }],
|
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
const bus = createSignalBus();
|
const bus = createSignalBus();
|
||||||
const ref: { scheduler: ReturnType<typeof createReflexScheduler> | null } = { scheduler: null };
|
const ref: { scheduler: ReturnType<typeof createReflexScheduler> | null } = { scheduler: null };
|
||||||
@@ -88,8 +88,8 @@ describe("LogStore + ReflexScheduler integration", () => {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
|
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
const bus = createSignalBus();
|
const bus = createSignalBus();
|
||||||
const triggered: string[] = [];
|
const triggered: string[] = [];
|
||||||
@@ -108,7 +108,7 @@ describe("LogStore + ReflexScheduler integration", () => {
|
|||||||
type: "run_complete",
|
type: "run_complete",
|
||||||
refId: "cpu-usage",
|
refId: "cpu-usage",
|
||||||
payload: '{"v":99}',
|
payload: '{"v":99}',
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Writing to the log store should NOT trigger any reflex.
|
// Writing to the log store should NOT trigger any reflex.
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
* Phase 6 integration tests — hot reload, error isolation, grace period, health.
|
* Phase 6 integration tests — hot reload, error isolation, grace period, health.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import type { Signal } from "@uncaged/nerve-core";
|
import type { Signal } from "@uncaged/nerve-core";
|
||||||
import type { NerveConfig } from "@uncaged/nerve-core";
|
import type { NerveConfig } from "@uncaged/nerve-core";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { createKernel } from "../kernel.js";
|
import { createKernel } from "../kernel.js";
|
||||||
import type { Kernel } from "../kernel.js";
|
import type { Kernel } from "../kernel.js";
|
||||||
@@ -23,7 +25,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -55,17 +57,23 @@ async function pollUntil(
|
|||||||
|
|
||||||
describe("phase6 — restartGroup", () => {
|
describe("phase6 — restartGroup", () => {
|
||||||
let kernel: Kernel | null = null;
|
let kernel: Kernel | null = null;
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-restart-"));
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (kernel !== null) {
|
if (kernel !== null) {
|
||||||
await kernel.stop();
|
await kernel.stop();
|
||||||
kernel = null;
|
kernel = null;
|
||||||
}
|
}
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restartGroup stops old worker and spawns a new one", async () => {
|
it("restartGroup stops old worker and spawns a new one", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +105,7 @@ describe("phase6 — restartGroup", () => {
|
|||||||
|
|
||||||
it("restartGroup on nonexistent group does nothing", async () => {
|
it("restartGroup on nonexistent group does nothing", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
await kernel.ready;
|
await kernel.ready;
|
||||||
@@ -113,17 +121,23 @@ describe("phase6 — restartGroup", () => {
|
|||||||
|
|
||||||
describe("phase6 — reloadConfig", () => {
|
describe("phase6 — reloadConfig", () => {
|
||||||
let kernel: Kernel | null = null;
|
let kernel: Kernel | null = null;
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-reload-"));
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (kernel !== null) {
|
if (kernel !== null) {
|
||||||
await kernel.stop();
|
await kernel.stop();
|
||||||
kernel = null;
|
kernel = null;
|
||||||
}
|
}
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds new group when new sense group is introduced", async () => {
|
it("adds new group when new sense group is introduced", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
await kernel.ready;
|
await kernel.ready;
|
||||||
@@ -136,8 +150,8 @@ describe("phase6 — reloadConfig", () => {
|
|||||||
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
kernel.reloadConfig(newConfig);
|
kernel.reloadConfig(newConfig);
|
||||||
@@ -156,10 +170,10 @@ describe("phase6 — reloadConfig", () => {
|
|||||||
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
await kernel.ready;
|
await kernel.ready;
|
||||||
@@ -171,8 +185,8 @@ describe("phase6 — reloadConfig", () => {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
kernel.reloadConfig(newConfig);
|
kernel.reloadConfig(newConfig);
|
||||||
@@ -187,12 +201,18 @@ describe("phase6 — reloadConfig", () => {
|
|||||||
|
|
||||||
describe("phase6 — error isolation", () => {
|
describe("phase6 — error isolation", () => {
|
||||||
let kernel: Kernel | null = null;
|
let kernel: Kernel | null = null;
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-err-"));
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (kernel !== null) {
|
if (kernel !== null) {
|
||||||
await kernel.stop();
|
await kernel.stop();
|
||||||
kernel = null;
|
kernel = null;
|
||||||
}
|
}
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("error from one sense does not crash the worker — other senses still work", async () => {
|
it("error from one sense does not crash the worker — other senses still work", async () => {
|
||||||
@@ -202,11 +222,11 @@ describe("phase6 — error isolation", () => {
|
|||||||
"bad-sense": { group: "mixed", throttle: null, timeout: null, gracePeriod: null },
|
"bad-sense": { group: "mixed", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
await kernel.ready;
|
await kernel.ready;
|
||||||
@@ -238,7 +258,7 @@ describe("phase6 — error isolation", () => {
|
|||||||
process.stderr.write = stderrSpy as typeof process.stderr.write;
|
process.stderr.write = stderrSpy as typeof process.stderr.write;
|
||||||
|
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: ERROR_WORKER,
|
workerScript: ERROR_WORKER,
|
||||||
});
|
});
|
||||||
await kernel.ready;
|
await kernel.ready;
|
||||||
@@ -261,12 +281,18 @@ describe("phase6 — error isolation", () => {
|
|||||||
|
|
||||||
describe("phase6 — getHealth", () => {
|
describe("phase6 — getHealth", () => {
|
||||||
let kernel: Kernel | null = null;
|
let kernel: Kernel | null = null;
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-health-"));
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (kernel !== null) {
|
if (kernel !== null) {
|
||||||
await kernel.stop();
|
await kernel.stop();
|
||||||
kernel = null;
|
kernel = null;
|
||||||
}
|
}
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns health snapshot with correct shape", async () => {
|
it("returns health snapshot with correct shape", async () => {
|
||||||
@@ -277,7 +303,7 @@ describe("phase6 — getHealth", () => {
|
|||||||
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
await kernel.ready;
|
await kernel.ready;
|
||||||
@@ -293,7 +319,7 @@ describe("phase6 — getHealth", () => {
|
|||||||
|
|
||||||
it("health reflects config changes after reloadConfig", async () => {
|
it("health reflects config changes after reloadConfig", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
await kernel.ready;
|
await kernel.ready;
|
||||||
@@ -306,8 +332,8 @@ describe("phase6 — getHealth", () => {
|
|||||||
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
};
|
};
|
||||||
kernel.reloadConfig(newConfig);
|
kernel.reloadConfig(newConfig);
|
||||||
|
|
||||||
@@ -322,17 +348,23 @@ describe("phase6 — getHealth", () => {
|
|||||||
|
|
||||||
describe("phase6 — auto-respawn on worker crash", () => {
|
describe("phase6 — auto-respawn on worker crash", () => {
|
||||||
let kernel: Kernel | null = null;
|
let kernel: Kernel | null = null;
|
||||||
|
let nerveRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-crash-"));
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (kernel !== null) {
|
if (kernel !== null) {
|
||||||
await kernel.stop();
|
await kernel.stop();
|
||||||
kernel = null;
|
kernel = null;
|
||||||
}
|
}
|
||||||
|
rmSync(nerveRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("kernel auto-respawns worker and new worker is functional", async () => {
|
it("kernel auto-respawns worker and new worker is functional", async () => {
|
||||||
const config = makeConfig();
|
const config = makeConfig();
|
||||||
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
|
kernel = createKernel(config, nerveRoot, {
|
||||||
workerScript: MOCK_WORKER,
|
workerScript: MOCK_WORKER,
|
||||||
});
|
});
|
||||||
await kernel.ready;
|
await kernel.ready;
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSignal(senseId: string, payload: unknown = 1): Signal {
|
function makeSignal(senseId: string, payload: unknown = 1): Signal {
|
||||||
return { id: 1, senseId, payload, ts: Date.now() };
|
return { id: 1, senseId, payload, timestamp: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ReflexScheduler — throttle + pending deferred trigger", () => {
|
describe("ReflexScheduler — throttle + pending deferred trigger", () => {
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
|
|||||||
"system-health": { group: "derived", throttle: null, timeout: null, gracePeriod: null },
|
"system-health": { group: "derived", throttle: null, timeout: null, gracePeriod: null },
|
||||||
},
|
},
|
||||||
reflexes: [],
|
reflexes: [],
|
||||||
workflows: null,
|
workflows: {},
|
||||||
maxRounds: 10,
|
maxRounds: 10,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSignal(senseId: string, payload: unknown = 1): Signal {
|
function makeSignal(senseId: string, payload: unknown = 1): Signal {
|
||||||
return { id: 1, senseId, payload, ts: Date.now() };
|
return { id: 1, senseId, payload, timestamp: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -41,7 +41,7 @@ describe("ReflexScheduler — interval reflex", () => {
|
|||||||
it("fires triggerFn on schedule", () => {
|
it("fires triggerFn on schedule", () => {
|
||||||
const triggered: string[] = [];
|
const triggered: string[] = [];
|
||||||
const config = makeConfig({
|
const config = makeConfig({
|
||||||
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }],
|
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
|
||||||
});
|
});
|
||||||
const bus = createSignalBus();
|
const bus = createSignalBus();
|
||||||
// Use a ref so the triggerFn can call back into the scheduler
|
// Use a ref so the triggerFn can call back into the scheduler
|
||||||
@@ -66,7 +66,7 @@ describe("ReflexScheduler — interval reflex", () => {
|
|||||||
it("stops firing after stop() is called", () => {
|
it("stops firing after stop() is called", () => {
|
||||||
const triggered: string[] = [];
|
const triggered: string[] = [];
|
||||||
const config = makeConfig({
|
const config = makeConfig({
|
||||||
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: null }],
|
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 500, on: [] }],
|
||||||
});
|
});
|
||||||
const bus = createSignalBus();
|
const bus = createSignalBus();
|
||||||
const ref: { scheduler: ReturnType<typeof createReflexScheduler> | null } = {
|
const ref: { scheduler: ReturnType<typeof createReflexScheduler> | null } = {
|
||||||
@@ -89,7 +89,7 @@ describe("ReflexScheduler — interval reflex", () => {
|
|||||||
it("starts from current time — does not compensate for past intervals", () => {
|
it("starts from current time — does not compensate for past intervals", () => {
|
||||||
const triggered: string[] = [];
|
const triggered: string[] = [];
|
||||||
const config = makeConfig({
|
const config = makeConfig({
|
||||||
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }],
|
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
|
||||||
});
|
});
|
||||||
const bus = createSignalBus();
|
const bus = createSignalBus();
|
||||||
const scheduler = createReflexScheduler(config, bus, (name) => triggered.push(name));
|
const scheduler = createReflexScheduler(config, bus, (name) => triggered.push(name));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Signal } from "@uncaged/nerve-core";
|
|||||||
import { createSignalBus } from "../signal-bus.js";
|
import { createSignalBus } from "../signal-bus.js";
|
||||||
|
|
||||||
function makeSignal(senseId: string, payload: unknown = 1): Signal {
|
function makeSignal(senseId: string, payload: unknown = 1): Signal {
|
||||||
return { id: 1, senseId, payload, ts: Date.now() };
|
return { id: 1, senseId, payload, timestamp: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("createSignalBus", () => {
|
describe("createSignalBus", () => {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
expect(mockChildren).toHaveLength(1);
|
expect(mockChildren).toHaveLength(1);
|
||||||
expect(mockChildren[0].send).toHaveBeenCalledWith(
|
expect(mockChildren[0].send).toHaveBeenCalledWith(
|
||||||
@@ -131,8 +131,8 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10 });
|
mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10 });
|
mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
// Only one forked child — worker is reused
|
// Only one forked child — worker is reused
|
||||||
expect(mockChildren).toHaveLength(1);
|
expect(mockChildren).toHaveLength(1);
|
||||||
@@ -147,7 +147,7 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("my-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
|
expect(logStore.upsertWorkflowRun).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ source: "workflow", type: "started" }),
|
expect.objectContaining({ source: "workflow", type: "started" }),
|
||||||
@@ -164,9 +164,9 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("drop-wf", { prompt: "first", maxRounds: 10 });
|
mgr.startWorkflow("drop-wf", { prompt: "first", maxRounds: 10, dryRun: false });
|
||||||
// now at limit — second call should be dropped
|
// now at limit — second call should be dropped
|
||||||
mgr.startWorkflow("drop-wf", { prompt: "second", maxRounds: 10 });
|
mgr.startWorkflow("drop-wf", { prompt: "second", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
expect(mgr.activeCount("drop-wf")).toBe(1);
|
expect(mgr.activeCount("drop-wf")).toBe(1);
|
||||||
expect(mgr.queueLength("drop-wf")).toBe(0);
|
expect(mgr.queueLength("drop-wf")).toBe(0);
|
||||||
@@ -181,8 +181,8 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
const droppedCall = logStore.upsertWorkflowRun.mock.calls.find(
|
const droppedCall = logStore.upsertWorkflowRun.mock.calls.find(
|
||||||
([entry]) => entry.type === "dropped",
|
([entry]) => entry.type === "dropped",
|
||||||
@@ -199,8 +199,8 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
expect(mgr.activeCount("queue-wf")).toBe(1);
|
expect(mgr.activeCount("queue-wf")).toBe(1);
|
||||||
expect(mgr.queueLength("queue-wf")).toBe(1);
|
expect(mgr.queueLength("queue-wf")).toBe(1);
|
||||||
@@ -213,8 +213,8 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
const queuedCall = logStore.upsertWorkflowRun.mock.calls.find(
|
const queuedCall = logStore.upsertWorkflowRun.mock.calls.find(
|
||||||
([entry]) => entry.type === "queued",
|
([entry]) => entry.type === "queued",
|
||||||
@@ -233,12 +233,12 @@ describe("WorkflowManager", () => {
|
|||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
// Fill the concurrency slot
|
// Fill the concurrency slot
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "test 0", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "test 0", maxRounds: 10, dryRun: false });
|
||||||
// Fill the queue to maxQueue
|
// Fill the queue to maxQueue
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "test 1", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "test 2", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
|
||||||
// This one should push out the oldest
|
// This one should push out the oldest
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "test 3", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "test 3", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
// Queue should still be at maxQueue (2)
|
// Queue should still be at maxQueue (2)
|
||||||
expect(mgr.queueLength("queue-wf")).toBe(2);
|
expect(mgr.queueLength("queue-wf")).toBe(2);
|
||||||
@@ -259,8 +259,8 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
expect(mgr.activeCount("queue-wf")).toBe(1);
|
expect(mgr.activeCount("queue-wf")).toBe(1);
|
||||||
expect(mgr.queueLength("queue-wf")).toBe(1);
|
expect(mgr.queueLength("queue-wf")).toBe(1);
|
||||||
@@ -294,8 +294,8 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 });
|
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
const child = mockChildren[0];
|
const child = mockChildren[0];
|
||||||
const firstRunId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0].runId as string;
|
const firstRunId = (child.send as ReturnType<typeof vi.fn>).mock.calls[0][0].runId as string;
|
||||||
@@ -321,8 +321,8 @@ describe("WorkflowManager", () => {
|
|||||||
});
|
});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
// Two distinct workers should have been forked
|
// Two distinct workers should have been forked
|
||||||
expect(mockChildren).toHaveLength(2);
|
expect(mockChildren).toHaveLength(2);
|
||||||
@@ -348,7 +348,7 @@ describe("WorkflowManager", () => {
|
|||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
await stopPromise;
|
await stopPromise;
|
||||||
|
|
||||||
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
// No worker should have been spawned
|
// No worker should have been spawned
|
||||||
expect(mockChildren).toHaveLength(0);
|
expect(mockChildren).toHaveLength(0);
|
||||||
@@ -361,7 +361,7 @@ describe("WorkflowManager", () => {
|
|||||||
const config = makeConfig({});
|
const config = makeConfig({});
|
||||||
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
const mgr = createWorkflowManager("/nerve-root", config, logStore);
|
||||||
|
|
||||||
mgr.startWorkflow("no-such-workflow", { prompt: "test", maxRounds: 10 });
|
mgr.startWorkflow("no-such-workflow", { prompt: "test", maxRounds: 10, dryRun: false });
|
||||||
|
|
||||||
expect(mockChildren).toHaveLength(0);
|
expect(mockChildren).toHaveLength(0);
|
||||||
expect(logStore.upsertWorkflowRun).not.toHaveBeenCalled();
|
expect(logStore.upsertWorkflowRun).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -2,77 +2,24 @@
|
|||||||
* Daemon IPC server — listens on a Unix domain socket so that the CLI
|
* Daemon IPC server — listens on a Unix domain socket so that the CLI
|
||||||
* can send commands (e.g. trigger-workflow, trigger-sense) to the running daemon process.
|
* can send commands (e.g. trigger-workflow, trigger-sense) to the running daemon process.
|
||||||
*
|
*
|
||||||
* Protocol: newline-delimited JSON messages.
|
* Protocol: newline-delimited JSON — request/response types and
|
||||||
* Each request: { type: "trigger-workflow"; workflow: string; payload: unknown }
|
* `parseDaemonIpcRequest` live in `@uncaged/nerve-core`.
|
||||||
* | { type: "trigger-sense"; sense: string }
|
|
||||||
* | { type: "list-senses" }
|
|
||||||
* Each response: { ok: true } | { ok: false; error: string }
|
|
||||||
* | { ok: true; senses: SenseInfo[] } (for list-senses)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { rmSync } from "node:fs";
|
import { rmSync } from "node:fs";
|
||||||
import { type Server, type Socket, createServer } from "node:net";
|
import { type Server, type Socket, createServer } from "node:net";
|
||||||
|
|
||||||
import type { SenseInfo } from "@uncaged/nerve-core";
|
import type { DaemonIpcResponse, SenseInfo } from "@uncaged/nerve-core";
|
||||||
|
import { parseDaemonIpcRequest } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
import type { WorkflowManager } from "./workflow-manager.js";
|
import type { WorkflowManager } from "./workflow-manager.js";
|
||||||
|
|
||||||
export type { SenseInfo };
|
export type { SenseInfo };
|
||||||
|
|
||||||
/** JSON message sent by the CLI to trigger a workflow. */
|
|
||||||
export type TriggerWorkflowRequest = {
|
|
||||||
type: "trigger-workflow";
|
|
||||||
workflow: string;
|
|
||||||
prompt: string;
|
|
||||||
maxRounds: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** JSON message sent by the CLI to trigger a sense compute on-demand. */
|
|
||||||
export type TriggerSenseRequest = {
|
|
||||||
type: "trigger-sense";
|
|
||||||
sense: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** JSON message sent by the CLI to list registered senses. */
|
|
||||||
export type ListSensesRequest = {
|
|
||||||
type: "list-senses";
|
|
||||||
};
|
|
||||||
|
|
||||||
type DaemonRequest = TriggerWorkflowRequest | TriggerSenseRequest | ListSensesRequest;
|
|
||||||
|
|
||||||
type DaemonResponse =
|
|
||||||
| { ok: true }
|
|
||||||
| { ok: false; error: string }
|
|
||||||
| { ok: true; senses: SenseInfo[] };
|
|
||||||
|
|
||||||
export type DaemonIpcServer = {
|
export type DaemonIpcServer = {
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseRequest(line: string): DaemonRequest | null {
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(line) as unknown;
|
|
||||||
if (obj === null || typeof obj !== "object") return null;
|
|
||||||
const req = obj as Record<string, unknown>;
|
|
||||||
if (req.type === "trigger-workflow") {
|
|
||||||
if (typeof req.workflow !== "string" || req.workflow.length === 0) return null;
|
|
||||||
if (typeof req.prompt !== "string") return null;
|
|
||||||
if (typeof req.maxRounds !== "number") return null;
|
|
||||||
return { type: "trigger-workflow", workflow: req.workflow, prompt: req.prompt, maxRounds: req.maxRounds as number };
|
|
||||||
}
|
|
||||||
if (req.type === "trigger-sense") {
|
|
||||||
if (typeof req.sense !== "string" || req.sense.length === 0) return null;
|
|
||||||
return { type: "trigger-sense", sense: req.sense };
|
|
||||||
}
|
|
||||||
if (req.type === "list-senses") {
|
|
||||||
return { type: "list-senses" };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DaemonIpcServerOptions = {
|
export type DaemonIpcServerOptions = {
|
||||||
/** Called when a trigger-sense request arrives. Should throw if the sense is unknown. */
|
/** Called when a trigger-sense request arrives. Should throw if the sense is unknown. */
|
||||||
triggerSense: (senseName: string) => void;
|
triggerSense: (senseName: string) => void;
|
||||||
@@ -96,30 +43,43 @@ export function createDaemonIpcServer(
|
|||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (trimmed.length === 0) return;
|
if (trimmed.length === 0) return;
|
||||||
|
|
||||||
const req = parseRequest(trimmed);
|
const req = parseDaemonIpcRequest(trimmed);
|
||||||
if (req === null) {
|
if (req === null) {
|
||||||
const resp: DaemonResponse = { ok: false, error: "Invalid request" };
|
const resp: DaemonIpcResponse = { ok: false, error: "Invalid request" };
|
||||||
socket.write(`${JSON.stringify(resp)}\n`);
|
socket.write(`${JSON.stringify(resp)}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (req.type === "trigger-workflow") {
|
if (req.type === "trigger-workflow") {
|
||||||
workflowManager.startWorkflow(req.workflow, { prompt: req.prompt, maxRounds: req.maxRounds });
|
workflowManager.startWorkflow(req.workflow, {
|
||||||
const resp: DaemonResponse = { ok: true };
|
prompt: req.prompt,
|
||||||
|
maxRounds: req.maxRounds,
|
||||||
|
dryRun: req.dryRun,
|
||||||
|
});
|
||||||
|
const resp: DaemonIpcResponse = { ok: true };
|
||||||
socket.write(`${JSON.stringify(resp)}\n`);
|
socket.write(`${JSON.stringify(resp)}\n`);
|
||||||
} else if (req.type === "trigger-sense") {
|
} else if (req.type === "trigger-sense") {
|
||||||
opts.triggerSense(req.sense);
|
opts.triggerSense(req.sense);
|
||||||
const resp: DaemonResponse = { ok: true };
|
const resp: DaemonIpcResponse = { ok: true };
|
||||||
socket.write(`${JSON.stringify(resp)}\n`);
|
socket.write(`${JSON.stringify(resp)}\n`);
|
||||||
} else if (req.type === "list-senses") {
|
} else if (req.type === "list-senses") {
|
||||||
const senses = opts.listSenses();
|
const senses = opts.listSenses();
|
||||||
const resp: DaemonResponse = { ok: true, senses };
|
const resp: DaemonIpcResponse = { ok: true, senses };
|
||||||
socket.write(`${JSON.stringify(resp)}\n`);
|
socket.write(`${JSON.stringify(resp)}\n`);
|
||||||
|
} else if (req.type === "kill-workflow") {
|
||||||
|
const found = workflowManager.killThread(req.runId);
|
||||||
|
const resp: DaemonIpcResponse = found
|
||||||
|
? { ok: true }
|
||||||
|
: { ok: false, error: `Run not found or already finished: ${req.runId}` };
|
||||||
|
socket.write(`${JSON.stringify(resp)}\n`);
|
||||||
|
} else {
|
||||||
|
const _exhaustive: never = req;
|
||||||
|
void _exhaustive;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
const resp: DaemonResponse = { ok: false, error: msg };
|
const resp: DaemonIpcResponse = { ok: false, error: msg };
|
||||||
socket.write(`${JSON.stringify(resp)}\n`);
|
socket.write(`${JSON.stringify(resp)}\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export {
|
|||||||
export { createKernel } from "./kernel.js";
|
export { createKernel } from "./kernel.js";
|
||||||
export type { Kernel, KernelOptions, KernelHealth } from "./kernel.js";
|
export type { Kernel, KernelOptions, KernelHealth } from "./kernel.js";
|
||||||
|
|
||||||
export type { SenseInfo } from "./daemon-ipc.js";
|
export type { SenseInfo } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
export { createFileWatcher } from "./file-watcher.js";
|
export { createFileWatcher } from "./file-watcher.js";
|
||||||
export type { FileWatcher, FileChange, FileChangeHandler } from "./file-watcher.js";
|
export type { FileWatcher, FileChange, FileChangeHandler } from "./file-watcher.js";
|
||||||
|
|||||||
+131
-45
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Result } from "@uncaged/nerve-core";
|
import type { Result } from "@uncaged/nerve-core";
|
||||||
import { err, ok } from "@uncaged/nerve-core";
|
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
/** Parent → Worker: trigger one compute cycle for a sense */
|
/** Parent → Worker: trigger one compute cycle for a sense */
|
||||||
export type ComputeMessage = {
|
export type ComputeMessage = {
|
||||||
@@ -34,6 +34,8 @@ export type StartThreadMessage = {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
|
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
|
||||||
maxRounds: number;
|
maxRounds: number;
|
||||||
|
/** When true, roles may skip side effects (thread-level hint on the start frame). */
|
||||||
|
dryRun: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
|
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
|
||||||
@@ -44,6 +46,14 @@ export type ResumeThreadMessage = {
|
|||||||
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
|
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
|
||||||
/** Safety-valve: max moderator rounds for this thread. */
|
/** Safety-valve: max moderator rounds for this thread. */
|
||||||
maxRounds: number;
|
maxRounds: number;
|
||||||
|
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
|
||||||
|
dryRun: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parent → Workflow Worker: kill a specific running thread */
|
||||||
|
export type KillThreadMessage = {
|
||||||
|
type: "kill-thread";
|
||||||
|
runId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Union of all messages the parent sends to a worker */
|
/** Union of all messages the parent sends to a worker */
|
||||||
@@ -52,7 +62,8 @@ export type ParentToWorkerMessage =
|
|||||||
| ShutdownMessage
|
| ShutdownMessage
|
||||||
| HealthRequestMessage
|
| HealthRequestMessage
|
||||||
| StartThreadMessage
|
| StartThreadMessage
|
||||||
| ResumeThreadMessage;
|
| ResumeThreadMessage
|
||||||
|
| KillThreadMessage;
|
||||||
|
|
||||||
/** Worker → Parent: compute produced a signal */
|
/** Worker → Parent: compute produced a signal */
|
||||||
export type SignalMessage = {
|
export type SignalMessage = {
|
||||||
@@ -85,7 +96,13 @@ export type HealthResponseMessage = {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Valid lifecycle event types for a workflow thread. */
|
/** Valid lifecycle event types for a workflow thread. */
|
||||||
export type ThreadEventType = "queued" | "started" | "step_complete" | "completed" | "failed";
|
export type ThreadEventType =
|
||||||
|
| "queued"
|
||||||
|
| "started"
|
||||||
|
| "step_complete"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "killed";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow Worker → Parent: a thread lifecycle event.
|
* Workflow Worker → Parent: a thread lifecycle event.
|
||||||
@@ -102,6 +119,8 @@ export type WorkflowErrorMessage = {
|
|||||||
type: "workflow-error";
|
type: "workflow-error";
|
||||||
runId: string;
|
runId: string;
|
||||||
error: string;
|
error: string;
|
||||||
|
/** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */
|
||||||
|
exitCode: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
|
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
|
||||||
@@ -128,6 +147,7 @@ const PARENT_MSG_TYPES = new Set([
|
|||||||
"health-request",
|
"health-request",
|
||||||
"start-thread",
|
"start-thread",
|
||||||
"resume-thread",
|
"resume-thread",
|
||||||
|
"kill-thread",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
|
function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
|
||||||
@@ -135,6 +155,7 @@ function validateStartThreadMsg(obj: Record<string, unknown>): string | null {
|
|||||||
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
|
if (typeof obj.workflow !== "string") return "'start-thread' message missing string 'workflow'";
|
||||||
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
|
if (typeof obj.prompt !== "string") return "'start-thread' message missing string 'prompt'";
|
||||||
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
|
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
|
||||||
|
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,81 +164,130 @@ function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
|
|||||||
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
|
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
|
||||||
if (typeof obj.maxRounds !== "number")
|
if (typeof obj.maxRounds !== "number")
|
||||||
return "'resume-thread' message missing number 'maxRounds'";
|
return "'resume-thread' message missing number 'maxRounds'";
|
||||||
|
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate and parse an unknown IPC message received from the parent process. */
|
/** Validate and parse an unknown IPC message received from the parent process. */
|
||||||
export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage> {
|
export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage> {
|
||||||
if (raw === null || typeof raw !== "object") {
|
if (!isPlainRecord(raw)) {
|
||||||
return err(new Error("IPC message is not an object"));
|
return err(new Error("IPC message is not an object"));
|
||||||
}
|
}
|
||||||
const obj = raw as Record<string, unknown>;
|
const obj = raw;
|
||||||
if (typeof obj.type !== "string") {
|
if (typeof obj.type !== "string") {
|
||||||
return err(new Error("IPC message missing string 'type' field"));
|
return err(new Error("IPC message missing string 'type' field"));
|
||||||
}
|
}
|
||||||
if (!PARENT_MSG_TYPES.has(obj.type)) {
|
if (!PARENT_MSG_TYPES.has(obj.type)) {
|
||||||
return err(new Error(`Unknown IPC message type: "${obj.type}"`));
|
return err(new Error(`Unknown IPC message type: "${obj.type}"`));
|
||||||
}
|
}
|
||||||
|
if (obj.type === "compute") {
|
||||||
|
if (typeof obj.sense !== "string") {
|
||||||
|
return err(new Error("IPC 'compute' message missing string 'sense' field"));
|
||||||
|
}
|
||||||
|
return ok({ type: "compute", sense: obj.sense });
|
||||||
|
}
|
||||||
|
if (obj.type === "shutdown") {
|
||||||
|
return ok({ type: "shutdown" });
|
||||||
|
}
|
||||||
|
if (obj.type === "health-request") {
|
||||||
|
return ok({ type: "health-request" });
|
||||||
|
}
|
||||||
if (obj.type === "start-thread") {
|
if (obj.type === "start-thread") {
|
||||||
const errMsg = validateStartThreadMsg(obj);
|
const errMsg = validateStartThreadMsg(obj);
|
||||||
if (errMsg !== null) return err(new Error(errMsg));
|
if (errMsg !== null) return err(new Error(errMsg));
|
||||||
|
// Field types are validated above; `Record<string, unknown>` values stay `unknown` to TypeScript.
|
||||||
|
return ok({
|
||||||
|
type: "start-thread",
|
||||||
|
runId: obj.runId,
|
||||||
|
workflow: obj.workflow,
|
||||||
|
prompt: obj.prompt,
|
||||||
|
maxRounds: obj.maxRounds,
|
||||||
|
dryRun: obj.dryRun,
|
||||||
|
} as StartThreadMessage);
|
||||||
}
|
}
|
||||||
if (obj.type === "resume-thread") {
|
if (obj.type === "resume-thread") {
|
||||||
const errMsg = validateResumeThreadMsg(obj);
|
const errMsg = validateResumeThreadMsg(obj);
|
||||||
if (errMsg !== null) return err(new Error(errMsg));
|
if (errMsg !== null) return err(new Error(errMsg));
|
||||||
|
// Elements are validated as plain objects by the kernel; trust the wire shape here.
|
||||||
|
return ok({
|
||||||
|
type: "resume-thread",
|
||||||
|
runId: obj.runId,
|
||||||
|
messages: obj.messages as ResumeThreadMessage["messages"],
|
||||||
|
maxRounds: obj.maxRounds,
|
||||||
|
dryRun: obj.dryRun,
|
||||||
|
} as ResumeThreadMessage);
|
||||||
}
|
}
|
||||||
return ok(raw as ParentToWorkerMessage);
|
if (obj.type === "kill-thread") {
|
||||||
|
if (typeof obj.runId !== "string") {
|
||||||
|
return err(new Error("'kill-thread' message missing string 'runId'"));
|
||||||
|
}
|
||||||
|
return ok({ type: "kill-thread", runId: obj.runId } as KillThreadMessage);
|
||||||
|
}
|
||||||
|
return err(new Error(`Unhandled IPC message type: "${obj.type}"`));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSignalMsg(obj: Record<string, unknown>, raw: unknown): Result<WorkerToParentMessage> {
|
function parseSignalMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||||
if (typeof obj.sense !== "string") {
|
if (typeof obj.sense !== "string") {
|
||||||
return err(new Error("Worker 'signal' message missing string 'sense' field"));
|
return err(new Error("Worker 'signal' message missing string 'sense' field"));
|
||||||
}
|
}
|
||||||
if (!("payload" in obj)) {
|
if (!("payload" in obj)) {
|
||||||
return err(new Error("Worker 'signal' message missing 'payload' field"));
|
return err(new Error("Worker 'signal' message missing 'payload' field"));
|
||||||
}
|
}
|
||||||
return ok(raw as SignalMessage);
|
return ok({
|
||||||
|
type: "signal",
|
||||||
|
sense: obj.sense,
|
||||||
|
payload: obj.payload,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseErrorMsg(obj: Record<string, unknown>, raw: unknown): Result<WorkerToParentMessage> {
|
function parseErrorMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||||
if (typeof obj.sense !== "string") {
|
if (typeof obj.sense !== "string") {
|
||||||
return err(new Error("Worker 'error' message missing string 'sense' field"));
|
return err(new Error("Worker 'error' message missing string 'sense' field"));
|
||||||
}
|
}
|
||||||
if (typeof obj.error !== "string") {
|
if (typeof obj.error !== "string") {
|
||||||
return err(new Error("Worker 'error' message missing string 'error' field"));
|
return err(new Error("Worker 'error' message missing string 'error' field"));
|
||||||
}
|
}
|
||||||
return ok(raw as ErrorMessage);
|
return ok({
|
||||||
|
type: "error",
|
||||||
|
sense: obj.sense,
|
||||||
|
error: obj.error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHealthResponseMsg(
|
function parseHealthResponseMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||||
obj: Record<string, unknown>,
|
|
||||||
raw: unknown,
|
|
||||||
): Result<WorkerToParentMessage> {
|
|
||||||
if (!Array.isArray(obj.senses)) {
|
if (!Array.isArray(obj.senses)) {
|
||||||
return err(new Error("Worker 'health-response' message missing 'senses' array"));
|
return err(new Error("Worker 'health-response' message missing 'senses' array"));
|
||||||
}
|
}
|
||||||
if (typeof obj.inFlightCount !== "number") {
|
if (typeof obj.inFlightCount !== "number") {
|
||||||
return err(new Error("Worker 'health-response' message missing 'inFlightCount' number"));
|
return err(new Error("Worker 'health-response' message missing 'inFlightCount' number"));
|
||||||
}
|
}
|
||||||
return ok(raw as HealthResponseMessage);
|
return ok({
|
||||||
|
type: "health-response",
|
||||||
|
// Kernel only sends string[] today; keep accepting any array elements without filtering.
|
||||||
|
senses: obj.senses as string[],
|
||||||
|
inFlightCount: obj.inFlightCount,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const THREAD_EVENT_TYPES = new Set<string>([
|
function isThreadEventType(value: string): value is ThreadEventType {
|
||||||
"queued",
|
switch (value) {
|
||||||
"started",
|
case "queued":
|
||||||
"step_complete",
|
case "started":
|
||||||
"completed",
|
case "step_complete":
|
||||||
"failed",
|
case "completed":
|
||||||
]);
|
case "failed":
|
||||||
|
case "killed":
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseThreadEventMsg(
|
function parseThreadEventMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||||
obj: Record<string, unknown>,
|
|
||||||
raw: unknown,
|
|
||||||
): Result<WorkerToParentMessage> {
|
|
||||||
if (typeof obj.runId !== "string") {
|
if (typeof obj.runId !== "string") {
|
||||||
return err(new Error("Worker 'thread-event' message missing string 'runId' field"));
|
return err(new Error("Worker 'thread-event' message missing string 'runId' field"));
|
||||||
}
|
}
|
||||||
if (typeof obj.eventType !== "string" || !THREAD_EVENT_TYPES.has(obj.eventType)) {
|
if (typeof obj.eventType !== "string" || !isThreadEventType(obj.eventType)) {
|
||||||
return err(
|
return err(
|
||||||
new Error(`Worker 'thread-event' message has invalid 'eventType': "${obj.eventType}"`),
|
new Error(`Worker 'thread-event' message has invalid 'eventType': "${obj.eventType}"`),
|
||||||
);
|
);
|
||||||
@@ -225,20 +295,28 @@ function parseThreadEventMsg(
|
|||||||
if (!("payload" in obj)) {
|
if (!("payload" in obj)) {
|
||||||
return err(new Error("Worker 'thread-event' message missing 'payload' field"));
|
return err(new Error("Worker 'thread-event' message missing 'payload' field"));
|
||||||
}
|
}
|
||||||
return ok(raw as ThreadEventMessage);
|
return ok({
|
||||||
|
type: "thread-event",
|
||||||
|
runId: obj.runId,
|
||||||
|
eventType: obj.eventType,
|
||||||
|
payload: obj.payload,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseWorkflowErrorMsg(
|
function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkerToParentMessage> {
|
||||||
obj: Record<string, unknown>,
|
|
||||||
raw: unknown,
|
|
||||||
): Result<WorkerToParentMessage> {
|
|
||||||
if (typeof obj.runId !== "string") {
|
if (typeof obj.runId !== "string") {
|
||||||
return err(new Error("Worker 'workflow-error' message missing string 'runId' field"));
|
return err(new Error("Worker 'workflow-error' message missing string 'runId' field"));
|
||||||
}
|
}
|
||||||
if (typeof obj.error !== "string") {
|
if (typeof obj.error !== "string") {
|
||||||
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
|
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
|
||||||
}
|
}
|
||||||
return ok(raw as WorkflowErrorMessage);
|
const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1;
|
||||||
|
return ok({
|
||||||
|
type: "workflow-error",
|
||||||
|
runId: obj.runId,
|
||||||
|
error: obj.error,
|
||||||
|
exitCode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORKER_MSG_TYPES = new Set([
|
const WORKER_MSG_TYPES = new Set([
|
||||||
@@ -253,15 +331,14 @@ const WORKER_MSG_TYPES = new Set([
|
|||||||
|
|
||||||
function parseThreadWorkflowMessageMsg(
|
function parseThreadWorkflowMessageMsg(
|
||||||
obj: Record<string, unknown>,
|
obj: Record<string, unknown>,
|
||||||
raw: unknown,
|
|
||||||
): Result<WorkerToParentMessage> {
|
): Result<WorkerToParentMessage> {
|
||||||
if (typeof obj.runId !== "string") {
|
if (typeof obj.runId !== "string") {
|
||||||
return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field"));
|
return err(new Error("Worker 'thread-workflow-message' missing string 'runId' field"));
|
||||||
}
|
}
|
||||||
if (obj.message === null || typeof obj.message !== "object") {
|
if (!isPlainRecord(obj.message)) {
|
||||||
return err(new Error("Worker 'thread-workflow-message' missing object 'message' field"));
|
return err(new Error("Worker 'thread-workflow-message' missing object 'message' field"));
|
||||||
}
|
}
|
||||||
const msg = obj.message as Record<string, unknown>;
|
const msg = obj.message;
|
||||||
if (typeof msg.role !== "string") {
|
if (typeof msg.role !== "string") {
|
||||||
return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field"));
|
return err(new Error("Worker 'thread-workflow-message' message missing string 'role' field"));
|
||||||
}
|
}
|
||||||
@@ -275,26 +352,35 @@ function parseThreadWorkflowMessageMsg(
|
|||||||
new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"),
|
new Error("Worker 'thread-workflow-message' message missing number 'timestamp' field"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ok(raw as ThreadWorkflowMessageMessage);
|
return ok({
|
||||||
|
type: "thread-workflow-message",
|
||||||
|
runId: obj.runId,
|
||||||
|
message: {
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
meta: "meta" in msg ? msg.meta : undefined,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate and parse an unknown IPC message received from a worker process. */
|
/** Validate and parse an unknown IPC message received from a worker process. */
|
||||||
export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage> {
|
export function parseWorkerMessage(raw: unknown): Result<WorkerToParentMessage> {
|
||||||
if (raw === null || typeof raw !== "object") {
|
if (!isPlainRecord(raw)) {
|
||||||
return err(new Error("Worker IPC message is not an object"));
|
return err(new Error("Worker IPC message is not an object"));
|
||||||
}
|
}
|
||||||
const obj = raw as Record<string, unknown>;
|
const obj = raw;
|
||||||
if (typeof obj.type !== "string") {
|
if (typeof obj.type !== "string") {
|
||||||
return err(new Error("Worker IPC message missing string 'type' field"));
|
return err(new Error("Worker IPC message missing string 'type' field"));
|
||||||
}
|
}
|
||||||
if (!WORKER_MSG_TYPES.has(obj.type)) {
|
if (!WORKER_MSG_TYPES.has(obj.type)) {
|
||||||
return err(new Error(`Unknown worker IPC message type: "${obj.type}"`));
|
return err(new Error(`Unknown worker IPC message type: "${obj.type}"`));
|
||||||
}
|
}
|
||||||
if (obj.type === "signal") return parseSignalMsg(obj, raw);
|
if (obj.type === "signal") return parseSignalMsg(obj);
|
||||||
if (obj.type === "error") return parseErrorMsg(obj, raw);
|
if (obj.type === "error") return parseErrorMsg(obj);
|
||||||
if (obj.type === "health-response") return parseHealthResponseMsg(obj, raw);
|
if (obj.type === "health-response") return parseHealthResponseMsg(obj);
|
||||||
if (obj.type === "thread-event") return parseThreadEventMsg(obj, raw);
|
if (obj.type === "thread-event") return parseThreadEventMsg(obj);
|
||||||
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj, raw);
|
if (obj.type === "workflow-error") return parseWorkflowErrorMsg(obj);
|
||||||
if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj, raw);
|
if (obj.type === "thread-workflow-message") return parseThreadWorkflowMessageMsg(obj);
|
||||||
return ok({ type: "ready" });
|
return ok({ type: "ready" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function createKernelFileWatchHandlers(deps: KernelFileWatchDeps): Kernel
|
|||||||
type: "sense_reload",
|
type: "sense_reload",
|
||||||
refId: senseName,
|
refId: senseName,
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
deps.restartGroup(sc.group).catch((e) => {
|
deps.restartGroup(sc.group).catch((e) => {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
@@ -55,12 +55,9 @@ export function createKernelFileWatchHandlers(deps: KernelFileWatchDeps): Kernel
|
|||||||
type: "workflow_reload",
|
type: "workflow_reload",
|
||||||
refId: workflowName,
|
refId: workflowName,
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
|
||||||
deps.workflowManager.drainAndRespawn(workflowName).catch((e) => {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
process.stderr.write(`[kernel] drainAndRespawn error for "${workflowName}": ${msg}\n`);
|
|
||||||
});
|
});
|
||||||
|
deps.workflowManager.drainWhenIdle(workflowName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onConfigFileChange(): void {
|
function onConfigFileChange(): void {
|
||||||
@@ -70,7 +67,7 @@ export function createKernelFileWatchHandlers(deps: KernelFileWatchDeps): Kernel
|
|||||||
type: "config_reload",
|
type: "config_reload",
|
||||||
refId: null,
|
refId: null,
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(join(deps.nerveRoot, "nerve.yaml"), "utf8");
|
const raw = readFileSync(join(deps.nerveRoot, "nerve.yaml"), "utf8");
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function createKernel(
|
|||||||
type: "start",
|
type: "start",
|
||||||
refId: null,
|
refId: null,
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: startTime,
|
timestamp: startTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
let config = initialConfig;
|
let config = initialConfig;
|
||||||
@@ -100,7 +100,8 @@ export function createKernel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let scheduler: ReflexScheduler = null as unknown as ReflexScheduler;
|
/** Assigned before workers start; `handleWorkerMessage` only runs after this is set. */
|
||||||
|
let scheduler!: ReflexScheduler;
|
||||||
|
|
||||||
let readyResolve: (() => void) | undefined;
|
let readyResolve: (() => void) | undefined;
|
||||||
const ready = new Promise<void>((resolve) => {
|
const ready = new Promise<void>((resolve) => {
|
||||||
@@ -137,7 +138,7 @@ export function createKernel(
|
|||||||
type: "error",
|
type: "error",
|
||||||
refId: msg.sense,
|
refId: msg.sense,
|
||||||
payload: JSON.stringify({ error: msg.error }),
|
payload: JSON.stringify({ error: msg.error }),
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
scheduler.onComputeComplete(msg.sense);
|
scheduler.onComputeComplete(msg.sense);
|
||||||
return;
|
return;
|
||||||
@@ -147,27 +148,27 @@ export function createKernel(
|
|||||||
const route = routeSenseComputeOutput(msg.payload);
|
const route = routeSenseComputeOutput(msg.payload);
|
||||||
if (route.kind === "launch") {
|
if (route.kind === "launch") {
|
||||||
const { workflowName, maxRounds, prompt } = route.launch;
|
const { workflowName, maxRounds, prompt } = route.launch;
|
||||||
workflowManager.startWorkflow(workflowName, { prompt, maxRounds });
|
workflowManager.startWorkflow(workflowName, { prompt, maxRounds, dryRun: false });
|
||||||
logStore.append({
|
logStore.append({
|
||||||
source: "sense",
|
source: "sense",
|
||||||
type: "workflow-launch",
|
type: "workflow-launch",
|
||||||
refId: msg.sense,
|
refId: msg.sense,
|
||||||
payload: JSON.stringify(route.launch),
|
payload: JSON.stringify(route.launch),
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const signal: Signal = {
|
const signal: Signal = {
|
||||||
id: nextSignalId(),
|
id: nextSignalId(),
|
||||||
senseId: msg.sense,
|
senseId: msg.sense,
|
||||||
payload: route.payload,
|
payload: route.payload,
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
logStore.append({
|
logStore.append({
|
||||||
source: "sense",
|
source: "sense",
|
||||||
type: "signal",
|
type: "signal",
|
||||||
refId: msg.sense,
|
refId: msg.sense,
|
||||||
payload: JSON.stringify(route.payload),
|
payload: JSON.stringify(route.payload),
|
||||||
ts: signal.ts,
|
timestamp: signal.timestamp,
|
||||||
});
|
});
|
||||||
bus.emit(signal);
|
bus.emit(signal);
|
||||||
}
|
}
|
||||||
@@ -238,7 +239,7 @@ export function createKernel(
|
|||||||
function reloadConfig(newConfig: NerveConfig): void {
|
function reloadConfig(newConfig: NerveConfig): void {
|
||||||
const oldGroups = collectSenseGroups(config);
|
const oldGroups = collectSenseGroups(config);
|
||||||
const oldConfig = config;
|
const oldConfig = config;
|
||||||
const oldWorkflows = config.workflows ?? {};
|
const oldWorkflows = config.workflows;
|
||||||
config = newConfig;
|
config = newConfig;
|
||||||
scheduler.stop();
|
scheduler.stop();
|
||||||
scheduler = createReflexScheduler(config, bus, triggerFn, {
|
scheduler = createReflexScheduler(config, bus, triggerFn, {
|
||||||
@@ -246,7 +247,7 @@ export function createKernel(
|
|||||||
});
|
});
|
||||||
workflowManager.updateConfig(newConfig);
|
workflowManager.updateConfig(newConfig);
|
||||||
|
|
||||||
const newWorkflows = newConfig.workflows ?? {};
|
const newWorkflows = newConfig.workflows;
|
||||||
|
|
||||||
for (const workflowName of Object.keys(oldWorkflows)) {
|
for (const workflowName of Object.keys(oldWorkflows)) {
|
||||||
if (!(workflowName in newWorkflows)) {
|
if (!(workflowName in newWorkflows)) {
|
||||||
@@ -326,7 +327,7 @@ export function createKernel(
|
|||||||
group: senseConfig.group,
|
group: senseConfig.group,
|
||||||
throttle: senseConfig.throttle,
|
throttle: senseConfig.throttle,
|
||||||
timeout: senseConfig.timeout,
|
timeout: senseConfig.timeout,
|
||||||
lastSignalTs: lastEntry !== null ? lastEntry.ts : null,
|
lastSignalTimestamp: lastEntry !== null ? lastEntry.timestamp : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -351,7 +352,7 @@ export function createKernel(
|
|||||||
type: "stop",
|
type: "stop",
|
||||||
refId: null,
|
refId: null,
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
logStore.close();
|
logStore.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function createReflexScheduler(
|
|||||||
type: "run_start",
|
type: "run_start",
|
||||||
refId: senseName,
|
refId: senseName,
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
triggerFn(senseName);
|
triggerFn(senseName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { drizzle } from "drizzle-orm/node-sqlite";
|
|||||||
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
||||||
|
|
||||||
import type { Result } from "@uncaged/nerve-core";
|
import type { Result } from "@uncaged/nerve-core";
|
||||||
import { err, ok } from "@uncaged/nerve-core";
|
import { err, isPlainRecord, ok } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
import type { BlobStore } from "@uncaged/nerve-store";
|
import type { BlobStore } from "@uncaged/nerve-store";
|
||||||
|
|
||||||
@@ -108,10 +108,11 @@ export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Resu
|
|||||||
const filesResult = listMigrationFiles(migrationsDir);
|
const filesResult = listMigrationFiles(migrationsDir);
|
||||||
if (!filesResult.ok) return filesResult;
|
if (!filesResult.ok) return filesResult;
|
||||||
|
|
||||||
|
const migrationRows = sqlite.prepare("SELECT name FROM _migrations").all();
|
||||||
const applied = new Set<string>(
|
const applied = new Set<string>(
|
||||||
(sqlite.prepare("SELECT name FROM _migrations").all() as Array<{ name: string }>).map(
|
migrationRows
|
||||||
(r) => r.name,
|
.filter((r): r is { name: string } => isPlainRecord(r) && typeof r.name === "string")
|
||||||
),
|
.map((r) => r.name),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const file of filesResult.value) {
|
for (const file of filesResult.value) {
|
||||||
@@ -145,6 +146,7 @@ export function openSenseDb(
|
|||||||
const migResult = runMigrations(sqlite, migrationsDir);
|
const migResult = runMigrations(sqlite, migrationsDir);
|
||||||
if (!migResult.ok) return migResult;
|
if (!migResult.ok) return migResult;
|
||||||
|
|
||||||
|
// Drizzle infers a schema-specific DB type; senses are schema-agnostic at this layer.
|
||||||
const db = drizzle({ client: sqlite }) as DrizzleDB;
|
const db = drizzle({ client: sqlite }) as DrizzleDB;
|
||||||
return ok({ sqlite, db });
|
return ok({ sqlite, db });
|
||||||
}
|
}
|
||||||
@@ -162,6 +164,7 @@ export function openPeerDb(dbPath: string): Result<DrizzleDB> {
|
|||||||
return err(new Error(`Failed to open peer database "${dbPath}" (readonly): ${msg}`));
|
return err(new Error(`Failed to open peer database "${dbPath}" (readonly): ${msg}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same schema-agnostic Drizzle wrapper as openSenseDb.
|
||||||
return ok(drizzle({ client: sqlite }) as DrizzleDB);
|
return ok(drizzle({ client: sqlite }) as DrizzleDB);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,18 +183,13 @@ export async function loadComputeFn(senseIndexPath: string): Promise<Result<Comp
|
|||||||
return err(new Error(`Failed to import sense module "${senseIndexPath}": ${msg}`));
|
return err(new Error(`Failed to import sense module "${senseIndexPath}": ${msg}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!isPlainRecord(mod) || !("compute" in mod) || typeof mod.compute !== "function") {
|
||||||
mod === null ||
|
|
||||||
typeof mod !== "object" ||
|
|
||||||
!("compute" in mod) ||
|
|
||||||
typeof (mod as Record<string, unknown>).compute !== "function"
|
|
||||||
) {
|
|
||||||
return err(
|
return err(
|
||||||
new Error(`Sense module "${senseIndexPath}" must export a named "compute" function`),
|
new Error(`Sense module "${senseIndexPath}" must export a named "compute" function`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok((mod as { compute: ComputeFn }).compute);
|
return ok(mod.compute as ComputeFn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,7 +230,9 @@ export async function executeCompute(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
return err(new Error(`compute("${runtime.name}") timed out after ${timeoutMs as number}ms`));
|
return err(
|
||||||
|
new Error(`compute("${runtime.name}") timed out after ${String(timeoutMs ?? "?")}ms`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return err(new Error(`compute("${runtime.name}") threw: ${msg}`));
|
return err(new Error(`compute("${runtime.name}") threw: ${msg}`));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ import { dirname, join } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import type { NerveConfig, WorkflowConfig, WorkflowMessage } from "@uncaged/nerve-core";
|
import type { NerveConfig, WorkflowConfig, WorkflowMessage } from "@uncaged/nerve-core";
|
||||||
import { START } from "@uncaged/nerve-core";
|
import { START, isPlainRecord } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
|
import type { LogStore, WorkflowRunStatus } from "@uncaged/nerve-store";
|
||||||
import type {
|
import type {
|
||||||
|
KillThreadMessage,
|
||||||
ResumeThreadMessage,
|
ResumeThreadMessage,
|
||||||
ShutdownMessage,
|
ShutdownMessage,
|
||||||
StartThreadMessage,
|
StartThreadMessage,
|
||||||
ThreadEventMessage,
|
ThreadEventMessage,
|
||||||
} from "./ipc.js";
|
} from "./ipc.js";
|
||||||
import { parseWorkerMessage } from "./ipc.js";
|
import { parseWorkerMessage } from "./ipc.js";
|
||||||
import type { LogStore, WorkflowRunStatus } from "@uncaged/nerve-store";
|
|
||||||
import {
|
import {
|
||||||
formatCapturedStderrTail,
|
formatCapturedStderrTail,
|
||||||
formatChildExitSummary,
|
formatChildExitSummary,
|
||||||
@@ -31,11 +32,17 @@ import {
|
|||||||
export type WorkflowLaunchParams = {
|
export type WorkflowLaunchParams = {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
maxRounds: number;
|
maxRounds: number;
|
||||||
|
dryRun: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowManager = {
|
export type WorkflowManager = {
|
||||||
/** Trigger a new workflow thread (Sense-driven launch or CLI / IPC). */
|
/** Trigger a new workflow thread (Sense-driven launch or CLI / IPC). */
|
||||||
startWorkflow: (workflowName: string, launch: WorkflowLaunchParams) => void;
|
startWorkflow: (workflowName: string, launch: WorkflowLaunchParams) => void;
|
||||||
|
/**
|
||||||
|
* Kill a running or queued workflow thread by runId.
|
||||||
|
* Returns true if the thread was found, false if not found.
|
||||||
|
*/
|
||||||
|
killThread: (runId: string) => boolean;
|
||||||
/** Number of currently active (running) threads for a workflow. */
|
/** Number of currently active (running) threads for a workflow. */
|
||||||
activeCount: (workflowName: string) => number;
|
activeCount: (workflowName: string) => number;
|
||||||
/** Number of pending queued threads waiting to run for a workflow. */
|
/** Number of pending queued threads waiting to run for a workflow. */
|
||||||
@@ -50,6 +57,12 @@ export type WorkflowManager = {
|
|||||||
* Waits up to `drainTimeoutMs` for threads to complete before force-killing.
|
* Waits up to `drainTimeoutMs` for threads to complete before force-killing.
|
||||||
*/
|
*/
|
||||||
drainAndRespawn: (workflowName: string, drainTimeoutMs?: number) => Promise<void>;
|
drainAndRespawn: (workflowName: string, drainTimeoutMs?: number) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Schedule a drain+respawn that waits for in-flight runs to finish first.
|
||||||
|
* If no runs are active, drains immediately. Otherwise marks a pending reload
|
||||||
|
* and drains automatically when the last active run completes.
|
||||||
|
*/
|
||||||
|
drainWhenIdle: (workflowName: string) => void;
|
||||||
/** Gracefully shut down all workflow workers. */
|
/** Gracefully shut down all workflow workers. */
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -58,6 +71,7 @@ type PendingThread = {
|
|||||||
runId: string;
|
runId: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
maxRounds: number;
|
maxRounds: number;
|
||||||
|
dryRun: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkflowState = {
|
type WorkflowState = {
|
||||||
@@ -90,20 +104,22 @@ const DEFAULT_MAX_QUEUE = 100;
|
|||||||
function readLaunchFromTriggerPayload(
|
function readLaunchFromTriggerPayload(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
engineDefaultMaxRounds: number,
|
engineDefaultMaxRounds: number,
|
||||||
): { prompt: string; maxRounds: number } {
|
): { prompt: string; maxRounds: number; dryRun: boolean } {
|
||||||
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
if (isPlainRecord(raw)) {
|
||||||
const o = raw as Record<string, unknown>;
|
const o = raw;
|
||||||
if (typeof o.prompt === "string" && typeof o.maxRounds === "number") {
|
if (typeof o.prompt === "string" && typeof o.maxRounds === "number") {
|
||||||
return { prompt: o.prompt, maxRounds: o.maxRounds };
|
const dryRun = typeof o.dryRun === "boolean" ? o.dryRun : false;
|
||||||
|
return { prompt: o.prompt, maxRounds: o.maxRounds, dryRun };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { prompt: "", maxRounds: engineDefaultMaxRounds };
|
return { prompt: "", maxRounds: engineDefaultMaxRounds, dryRun: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureThreadMessagesWithStart(
|
function ensureThreadMessagesWithStart(
|
||||||
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>,
|
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>,
|
||||||
fallbackPrompt: string,
|
fallbackPrompt: string,
|
||||||
fallbackMaxRounds: number,
|
fallbackMaxRounds: number,
|
||||||
|
fallbackDryRun: boolean,
|
||||||
): WorkflowMessage[] {
|
): WorkflowMessage[] {
|
||||||
const mapped: WorkflowMessage[] = messages.map((m) => ({
|
const mapped: WorkflowMessage[] = messages.map((m) => ({
|
||||||
role: m.role,
|
role: m.role,
|
||||||
@@ -117,7 +133,7 @@ function ensureThreadMessagesWithStart(
|
|||||||
const start: WorkflowMessage = {
|
const start: WorkflowMessage = {
|
||||||
role: START,
|
role: START,
|
||||||
content: fallbackPrompt,
|
content: fallbackPrompt,
|
||||||
meta: { maxRounds: fallbackMaxRounds },
|
meta: { maxRounds: fallbackMaxRounds, dryRun: fallbackDryRun },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
return [start, ...mapped];
|
return [start, ...mapped];
|
||||||
@@ -177,6 +193,16 @@ function sendResumeThread(worker: ChildProcess, msg: ResumeThreadMessage): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendKillThread(worker: ChildProcess, runId: string): void {
|
||||||
|
if (worker.connected === false) return;
|
||||||
|
const msg: KillThreadMessage = { type: "kill-thread", runId };
|
||||||
|
try {
|
||||||
|
worker.send(msg);
|
||||||
|
} catch {
|
||||||
|
// IPC channel closed between connected check and send
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<void> {
|
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -202,6 +228,7 @@ export function createWorkflowManager(
|
|||||||
const crashTimestamps = new Map<string, number[]>();
|
const crashTimestamps = new Map<string, number[]>();
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let config = initialConfig;
|
let config = initialConfig;
|
||||||
|
const pendingDrains = new Set<string>();
|
||||||
|
|
||||||
function getOrCreateState(workflowName: string): WorkflowState {
|
function getOrCreateState(workflowName: string): WorkflowState {
|
||||||
let state = states.get(workflowName);
|
let state = states.get(workflowName);
|
||||||
@@ -213,7 +240,7 @@ export function createWorkflowManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function workflowConfig(workflowName: string): WorkflowConfig | null {
|
function workflowConfig(workflowName: string): WorkflowConfig | null {
|
||||||
return config.workflows?.[workflowName] ?? null;
|
return config.workflows[workflowName] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toWorkflowRunStatus(eventType: string): WorkflowRunStatus | null {
|
function toWorkflowRunStatus(eventType: string): WorkflowRunStatus | null {
|
||||||
@@ -225,24 +252,39 @@ export function createWorkflowManager(
|
|||||||
crashed: "crashed",
|
crashed: "crashed",
|
||||||
dropped: "dropped",
|
dropped: "dropped",
|
||||||
interrupted: "interrupted",
|
interrupted: "interrupted",
|
||||||
|
killed: "killed",
|
||||||
};
|
};
|
||||||
return map[eventType] ?? null;
|
return map[eventType] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractExitCode(payload: unknown): number | null {
|
||||||
|
if (isPlainRecord(payload) && typeof payload.exitCode === "number") {
|
||||||
|
return payload.exitCode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function logWorkflowEvent(
|
function logWorkflowEvent(
|
||||||
workflowName: string,
|
workflowName: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
eventType: string,
|
eventType: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
|
exitCode: number | null = null,
|
||||||
): void {
|
): void {
|
||||||
const ts = Date.now();
|
const timestamp = Date.now();
|
||||||
const serialised = payload !== undefined ? JSON.stringify(payload) : null;
|
const serialised = payload !== undefined ? JSON.stringify(payload) : null;
|
||||||
const status = toWorkflowRunStatus(eventType);
|
const status = toWorkflowRunStatus(eventType);
|
||||||
|
|
||||||
if (status !== null) {
|
if (status !== null) {
|
||||||
logStore.upsertWorkflowRun(
|
logStore.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: eventType, refId: runId, payload: serialised, ts },
|
{
|
||||||
{ runId, workflow: workflowName, status, ts },
|
source: "workflow",
|
||||||
|
type: eventType,
|
||||||
|
refId: runId,
|
||||||
|
payload: serialised,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
|
{ runId, workflow: workflowName, status, timestamp, exitCode },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logStore.append({
|
logStore.append({
|
||||||
@@ -250,7 +292,7 @@ export function createWorkflowManager(
|
|||||||
type: eventType,
|
type: eventType,
|
||||||
refId: runId,
|
refId: runId,
|
||||||
payload: serialised,
|
payload: serialised,
|
||||||
ts,
|
timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,6 +302,7 @@ export function createWorkflowManager(
|
|||||||
runId: string,
|
runId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
maxRounds: number,
|
maxRounds: number,
|
||||||
|
dryRun: boolean,
|
||||||
): void {
|
): void {
|
||||||
const state = getOrCreateState(workflowName);
|
const state = getOrCreateState(workflowName);
|
||||||
state.active.add(runId);
|
state.active.add(runId);
|
||||||
@@ -271,9 +314,10 @@ export function createWorkflowManager(
|
|||||||
workflow: workflowName,
|
workflow: workflowName,
|
||||||
prompt,
|
prompt,
|
||||||
maxRounds,
|
maxRounds,
|
||||||
|
dryRun,
|
||||||
};
|
};
|
||||||
sendStartThread(worker.process, msg);
|
sendStartThread(worker.process, msg);
|
||||||
logWorkflowEvent(workflowName, runId, "started", { prompt, maxRounds });
|
logWorkflowEvent(workflowName, runId, "started", { prompt, maxRounds, dryRun });
|
||||||
}
|
}
|
||||||
|
|
||||||
function dequeueNext(workflowName: string): void {
|
function dequeueNext(workflowName: string): void {
|
||||||
@@ -286,29 +330,54 @@ export function createWorkflowManager(
|
|||||||
if (state.active.size < concurrency) {
|
if (state.active.size < concurrency) {
|
||||||
const next = state.queue.shift();
|
const next = state.queue.shift();
|
||||||
if (next !== undefined) {
|
if (next !== undefined) {
|
||||||
dispatchThread(workflowName, next.runId, next.prompt, next.maxRounds);
|
dispatchThread(workflowName, next.runId, next.prompt, next.maxRounds, next.dryRun);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** If a hot-reload was deferred, run drain+respawn once there are no active threads. */
|
||||||
|
function maybeDeferredHotReloadDrain(workflowName: string): void {
|
||||||
|
if (!pendingDrains.has(workflowName)) return;
|
||||||
|
const state = states.get(workflowName);
|
||||||
|
if (state === undefined || state.active.size !== 0) return;
|
||||||
|
|
||||||
|
pendingDrains.delete(workflowName);
|
||||||
|
process.stderr.write(
|
||||||
|
`[workflow-manager] all runs complete for "${workflowName}", executing deferred hot-reload drain\n`,
|
||||||
|
);
|
||||||
|
drainAndRespawn(workflowName).catch((e) => {
|
||||||
|
const errMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(
|
||||||
|
`[workflow-manager] deferred drainAndRespawn error for "${workflowName}": ${errMsg}\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleThreadEvent(workflowName: string, msg: ThreadEventMessage): void {
|
function handleThreadEvent(workflowName: string, msg: ThreadEventMessage): void {
|
||||||
const state = states.get(workflowName);
|
const state = states.get(workflowName);
|
||||||
if (state === undefined) return;
|
if (state === undefined) return;
|
||||||
|
|
||||||
if (msg.eventType === "completed" || msg.eventType === "failed") {
|
if (msg.eventType === "completed" || msg.eventType === "failed" || msg.eventType === "killed") {
|
||||||
state.active.delete(msg.runId);
|
state.active.delete(msg.runId);
|
||||||
dequeueNext(workflowName);
|
dequeueNext(workflowName);
|
||||||
}
|
const exitCode = extractExitCode(msg.payload);
|
||||||
|
logWorkflowEvent(workflowName, msg.runId, msg.eventType, msg.payload, exitCode);
|
||||||
if (msg.eventType === "completed" || msg.eventType === "failed") {
|
maybeDeferredHotReloadDrain(workflowName);
|
||||||
logWorkflowEvent(workflowName, msg.runId, msg.eventType, msg.payload);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recoverQueuedRun(workflowName: string, runId: string, state: WorkflowState): void {
|
function recoverQueuedRun(workflowName: string, runId: string, state: WorkflowState): void {
|
||||||
if (state.queue.some((q) => q.runId === runId)) return;
|
if (state.queue.some((q) => q.runId === runId)) return;
|
||||||
const launch = readLaunchFromTriggerPayload(logStore.getTriggerPayload(runId), config.maxRounds);
|
const launch = readLaunchFromTriggerPayload(
|
||||||
state.queue.push({ runId, prompt: launch.prompt, maxRounds: launch.maxRounds });
|
logStore.getTriggerPayload(runId),
|
||||||
|
config.maxRounds,
|
||||||
|
);
|
||||||
|
state.queue.push({
|
||||||
|
runId,
|
||||||
|
prompt: launch.prompt,
|
||||||
|
maxRounds: launch.maxRounds,
|
||||||
|
dryRun: launch.dryRun,
|
||||||
|
});
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[workflow-manager] crash-recovery: re-queued thread "${runId}" for "${workflowName}"\n`,
|
`[workflow-manager] crash-recovery: re-queued thread "${runId}" for "${workflowName}"\n`,
|
||||||
);
|
);
|
||||||
@@ -322,14 +391,23 @@ export function createWorkflowManager(
|
|||||||
): void {
|
): void {
|
||||||
if (state.active.has(runId)) return;
|
if (state.active.has(runId)) return;
|
||||||
const rawMessages = logStore.getThreadMessages(runId);
|
const rawMessages = logStore.getThreadMessages(runId);
|
||||||
const launch = readLaunchFromTriggerPayload(logStore.getTriggerPayload(runId), config.maxRounds);
|
const launch = readLaunchFromTriggerPayload(
|
||||||
const messages = ensureThreadMessagesWithStart(rawMessages, launch.prompt, launch.maxRounds);
|
logStore.getTriggerPayload(runId),
|
||||||
|
config.maxRounds,
|
||||||
|
);
|
||||||
|
const messages = ensureThreadMessagesWithStart(
|
||||||
|
rawMessages,
|
||||||
|
launch.prompt,
|
||||||
|
launch.maxRounds,
|
||||||
|
launch.dryRun,
|
||||||
|
);
|
||||||
state.active.add(runId);
|
state.active.add(runId);
|
||||||
const msg: ResumeThreadMessage = {
|
const msg: ResumeThreadMessage = {
|
||||||
type: "resume-thread",
|
type: "resume-thread",
|
||||||
runId,
|
runId,
|
||||||
messages,
|
messages,
|
||||||
maxRounds: launch.maxRounds,
|
maxRounds: launch.maxRounds,
|
||||||
|
dryRun: launch.dryRun,
|
||||||
};
|
};
|
||||||
sendResumeThread(worker.process, msg);
|
sendResumeThread(worker.process, msg);
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
@@ -370,12 +448,13 @@ export function createWorkflowManager(
|
|||||||
`[workflow-manager] worker for "${workflowName}" crashed with ${crashedCount} active thread(s)\n`,
|
`[workflow-manager] worker for "${workflowName}" crashed with ${crashedCount} active thread(s)\n`,
|
||||||
);
|
);
|
||||||
for (const runId of state.active) {
|
for (const runId of state.active) {
|
||||||
logWorkflowEvent(workflowName, runId, "crashed");
|
logWorkflowEvent(workflowName, runId, "crashed", undefined, 255);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.active.clear();
|
state.active.clear();
|
||||||
workers.delete(workflowName);
|
workers.delete(workflowName);
|
||||||
|
pendingDrains.delete(workflowName);
|
||||||
|
|
||||||
if (stopped || workflowConfig(workflowName) === null) return;
|
if (stopped || workflowConfig(workflowName) === null) return;
|
||||||
|
|
||||||
@@ -417,7 +496,7 @@ export function createWorkflowManager(
|
|||||||
type: "thread_workflow_message",
|
type: "thread_workflow_message",
|
||||||
refId: msg.runId,
|
refId: msg.runId,
|
||||||
payload: JSON.stringify(msg.message),
|
payload: JSON.stringify(msg.message),
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -431,7 +510,8 @@ export function createWorkflowManager(
|
|||||||
state.active.delete(msg.runId);
|
state.active.delete(msg.runId);
|
||||||
dequeueNext(workflowName);
|
dequeueNext(workflowName);
|
||||||
}
|
}
|
||||||
logWorkflowEvent(workflowName, msg.runId, "failed", { error: msg.error });
|
logWorkflowEvent(workflowName, msg.runId, "failed", { error: msg.error }, msg.exitCode);
|
||||||
|
maybeDeferredHotReloadDrain(workflowName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +592,26 @@ export function createWorkflowManager(
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function killThread(runId: string): boolean {
|
||||||
|
for (const [workflowName, state] of states) {
|
||||||
|
const queueIdx = state.queue.findIndex((q) => q.runId === runId);
|
||||||
|
if (queueIdx !== -1) {
|
||||||
|
state.queue.splice(queueIdx, 1);
|
||||||
|
logWorkflowEvent(workflowName, runId, "killed", { exitCode: 137 }, 137);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.active.has(runId)) {
|
||||||
|
const workerEntry = workers.get(workflowName);
|
||||||
|
if (workerEntry !== undefined) {
|
||||||
|
sendKillThread(workerEntry.process, runId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function startWorkflow(workflowName: string, launch: WorkflowLaunchParams): void {
|
function startWorkflow(workflowName: string, launch: WorkflowLaunchParams): void {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
|
||||||
@@ -525,10 +625,10 @@ export function createWorkflowManager(
|
|||||||
|
|
||||||
const state = getOrCreateState(workflowName);
|
const state = getOrCreateState(workflowName);
|
||||||
const runId = crypto.randomUUID();
|
const runId = crypto.randomUUID();
|
||||||
const { prompt, maxRounds } = launch;
|
const { prompt, maxRounds, dryRun } = launch;
|
||||||
|
|
||||||
if (state.active.size < wfConfig.concurrency) {
|
if (state.active.size < wfConfig.concurrency) {
|
||||||
dispatchThread(workflowName, runId, prompt, maxRounds);
|
dispatchThread(workflowName, runId, prompt, maxRounds, dryRun);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +653,7 @@ export function createWorkflowManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.queue.push({ runId, prompt, maxRounds });
|
state.queue.push({ runId, prompt, maxRounds, dryRun });
|
||||||
logWorkflowEvent(workflowName, runId, "queued");
|
logWorkflowEvent(workflowName, runId, "queued");
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[workflow-manager] queued thread for "${workflowName}" runId "${runId}" (queue length: ${state.queue.length})\n`,
|
`[workflow-manager] queued thread for "${workflowName}" runId "${runId}" (queue length: ${state.queue.length})\n`,
|
||||||
@@ -609,9 +709,38 @@ export function createWorkflowManager(
|
|||||||
await waitForExit(entry.process, drainTimeoutMs);
|
await waitForExit(entry.process, drainTimeoutMs);
|
||||||
// The exit handler (draining branch) will respawn the worker automatically
|
// The exit handler (draining branch) will respawn the worker automatically
|
||||||
}
|
}
|
||||||
|
function drainWhenIdle(workflowName: string): void {
|
||||||
|
const state = states.get(workflowName);
|
||||||
|
const hasActiveRuns = state !== undefined && state.active.size > 0;
|
||||||
|
|
||||||
|
if (!hasActiveRuns) {
|
||||||
|
pendingDrains.delete(workflowName);
|
||||||
|
drainAndRespawn(workflowName).catch((e) => {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
process.stderr.write(
|
||||||
|
`[workflow-manager] drainAndRespawn error for "${workflowName}": ${msg}\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer until all active runs finish
|
||||||
|
if (pendingDrains.has(workflowName)) {
|
||||||
|
process.stderr.write(
|
||||||
|
`[workflow-manager] hot-reload already pending for "${workflowName}", skipping duplicate\n`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDrains.add(workflowName);
|
||||||
|
process.stderr.write(
|
||||||
|
`[workflow-manager] deferring hot-reload for "${workflowName}" until ${state.active.size} active run(s) complete\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function stop(): Promise<void> {
|
async function stop(): Promise<void> {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
pendingDrains.clear();
|
||||||
const exitPromises: Promise<void>[] = [];
|
const exitPromises: Promise<void>[] = [];
|
||||||
for (const entry of workers.values()) {
|
for (const entry of workers.values()) {
|
||||||
sendShutdown(entry.process, entry);
|
sendShutdown(entry.process, entry);
|
||||||
@@ -623,11 +752,13 @@ export function createWorkflowManager(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
startWorkflow,
|
startWorkflow,
|
||||||
|
killThread,
|
||||||
activeCount,
|
activeCount,
|
||||||
queueLength,
|
queueLength,
|
||||||
totalActiveCount,
|
totalActiveCount,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
drainAndRespawn,
|
drainAndRespawn,
|
||||||
|
drainWhenIdle,
|
||||||
stop,
|
stop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
import type { RoleMeta, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core";
|
import type { RoleMeta, StartStep, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core";
|
||||||
import { END, START } from "@uncaged/nerve-core";
|
import { END, START, isPlainRecord } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ThreadEventType,
|
ThreadEventType,
|
||||||
@@ -41,8 +41,8 @@ function sendThreadEvent(runId: string, eventType: ThreadEventType, payload: unk
|
|||||||
send({ type: "thread-event", runId, eventType, payload });
|
send({ type: "thread-event", runId, eventType, payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendWorkflowError(runId: string, error: string): void {
|
function sendWorkflowError(runId: string, error: string, exitCode = 1): void {
|
||||||
send({ type: "workflow-error", runId, error });
|
send({ type: "workflow-error", runId, error, exitCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendWorkflowMessage(runId: string, message: WorkflowMessage): void {
|
function sendWorkflowMessage(runId: string, message: WorkflowMessage): void {
|
||||||
@@ -63,81 +63,186 @@ function sendWorkflowMessage(runId: string, message: WorkflowMessage): void {
|
|||||||
// Thread loop (signal-driven automaton, issue #80)
|
// Thread loop (signal-driven automaton, issue #80)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function validateRoleResult(
|
||||||
|
result: { content: string; meta: Record<string, unknown> },
|
||||||
|
roleName: string,
|
||||||
|
runId: string,
|
||||||
|
): boolean {
|
||||||
|
if (typeof result.content !== "string") {
|
||||||
|
sendWorkflowError(runId, `Role "${roleName}" returned non-string content`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (result.meta === null || typeof result.meta !== "object" || Array.isArray(result.meta)) {
|
||||||
|
sendWorkflowError(runId, `Role "${roleName}" returned invalid meta (must be a plain object)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStartMeta(meta: unknown): meta is StartStep["meta"] {
|
||||||
|
return (
|
||||||
|
isPlainRecord(meta) && typeof meta.maxRounds === "number" && typeof meta.dryRun === "boolean"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStartMeta(meta: unknown, maxRoundsFallback: number): StartStep["meta"] {
|
||||||
|
if (!isPlainRecord(meta)) {
|
||||||
|
return { maxRounds: maxRoundsFallback, dryRun: false };
|
||||||
|
}
|
||||||
|
const maxRounds = typeof meta.maxRounds === "number" ? meta.maxRounds : maxRoundsFallback;
|
||||||
|
const dryRun = typeof meta.dryRun === "boolean" ? meta.dryRun : false;
|
||||||
|
return { maxRounds, dryRun };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStepFromWorkflowMessage(msg: WorkflowMessage, maxRoundsFallback: number): StartStep {
|
||||||
|
if (msg.role !== START) {
|
||||||
|
return {
|
||||||
|
role: START,
|
||||||
|
content: "",
|
||||||
|
meta: { maxRounds: maxRoundsFallback, dryRun: false },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const meta = isStartMeta(msg.meta) ? msg.meta : normalizeStartMeta(msg.meta, maxRoundsFallback);
|
||||||
|
return {
|
||||||
|
role: START,
|
||||||
|
content: msg.content,
|
||||||
|
meta,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThreadMessagesState = {
|
||||||
|
start: StartStep;
|
||||||
|
/** Role outputs only; never includes the `__start__` frame. */
|
||||||
|
messages: WorkflowMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function initThreadMessages(
|
||||||
|
runId: string,
|
||||||
|
resumeMessages: WorkflowMessage[],
|
||||||
|
freshPrompt: string | null,
|
||||||
|
maxRounds: number,
|
||||||
|
dryRun: boolean,
|
||||||
|
): ThreadMessagesState {
|
||||||
|
if (resumeMessages.length > 0) {
|
||||||
|
const [first, ...rest] = resumeMessages;
|
||||||
|
if (first.role === START) {
|
||||||
|
return {
|
||||||
|
start: startStepFromWorkflowMessage(first, maxRounds),
|
||||||
|
messages: [...rest],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const prompt = freshPrompt ?? "";
|
||||||
|
return {
|
||||||
|
start: {
|
||||||
|
role: START,
|
||||||
|
content: prompt,
|
||||||
|
meta: { maxRounds, dryRun },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
messages: [...resumeMessages],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const prompt = freshPrompt ?? "";
|
||||||
|
const start: StartStep = {
|
||||||
|
role: START,
|
||||||
|
content: prompt,
|
||||||
|
meta: { maxRounds, dryRun },
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
sendWorkflowMessage(runId, {
|
||||||
|
role: start.role,
|
||||||
|
content: start.content,
|
||||||
|
meta: start.meta,
|
||||||
|
timestamp: start.timestamp,
|
||||||
|
});
|
||||||
|
return { start, messages: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRole(
|
||||||
|
def: WorkflowDefinition<RoleMeta>,
|
||||||
|
nextRole: string,
|
||||||
|
start: StartStep,
|
||||||
|
messages: WorkflowMessage[],
|
||||||
|
runId: string,
|
||||||
|
): Promise<{ content: string; meta: Record<string, unknown> } | null> {
|
||||||
|
const role = def.roles[nextRole];
|
||||||
|
if (!role) {
|
||||||
|
sendWorkflowError(runId, `Unknown role: ${nextRole}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: { content: string; meta: Record<string, unknown> };
|
||||||
|
try {
|
||||||
|
result = await role(start, messages);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const errMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
sendThreadEvent(runId, "failed", { error: errMsg, exitCode: 1 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateRoleResult(result, nextRole, runId)) return null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KillFlag = { value: boolean };
|
||||||
|
|
||||||
async function runThread(
|
async function runThread(
|
||||||
def: WorkflowDefinition<RoleMeta>,
|
def: WorkflowDefinition<RoleMeta>,
|
||||||
runId: string,
|
runId: string,
|
||||||
maxRounds: number,
|
maxRounds: number,
|
||||||
|
killFlag: KillFlag,
|
||||||
resumeMessages: WorkflowMessage[] = [],
|
resumeMessages: WorkflowMessage[] = [],
|
||||||
freshPrompt: string | null = null,
|
freshPrompt: string | null = null,
|
||||||
|
dryRun = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let chain: WorkflowMessage[];
|
const { start, messages: roleMessages } = initThreadMessages(
|
||||||
|
runId,
|
||||||
if (resumeMessages.length > 0) {
|
resumeMessages,
|
||||||
chain = [...resumeMessages];
|
freshPrompt,
|
||||||
} else {
|
|
||||||
const prompt = freshPrompt ?? "";
|
|
||||||
const startMsg: WorkflowMessage = {
|
|
||||||
role: START,
|
|
||||||
content: prompt,
|
|
||||||
meta: { maxRounds },
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
chain = [startMsg];
|
|
||||||
sendWorkflowMessage(runId, startMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
let roleRound = chain.filter((m) => m.role !== START).length;
|
|
||||||
const lastMsg = chain[chain.length - 1];
|
|
||||||
if (lastMsg === undefined) {
|
|
||||||
sendWorkflowError(runId, "empty workflow message chain");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastSignal =
|
|
||||||
lastMsg.role === START
|
|
||||||
? {
|
|
||||||
role: START,
|
|
||||||
content: lastMsg.content,
|
|
||||||
meta: lastMsg.meta as { maxRounds: number },
|
|
||||||
timestamp: lastMsg.timestamp,
|
|
||||||
}
|
|
||||||
: { role: lastMsg.role, meta: lastMsg.meta as Record<string, unknown> };
|
|
||||||
|
|
||||||
let nextRole = def.moderator(
|
|
||||||
lastSignal as Parameters<typeof def.moderator>[0],
|
|
||||||
roleRound,
|
|
||||||
maxRounds,
|
maxRounds,
|
||||||
|
dryRun,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nextRole === END) {
|
const steps: Array<{
|
||||||
sendThreadEvent(runId, "completed", null);
|
role: string;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Rebuild steps from any resumed messages
|
||||||
|
for (const msg of roleMessages) {
|
||||||
|
steps.push({
|
||||||
|
role: msg.role,
|
||||||
|
meta: msg.meta as Record<string, unknown>,
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (killFlag.value) {
|
||||||
|
sendThreadEvent(runId, "killed", { exitCode: 137 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (roleRound < maxRounds) {
|
let nextRole = def.moderator({ start, steps });
|
||||||
const role = def.roles[nextRole];
|
|
||||||
if (!role) {
|
if (nextRole === END) {
|
||||||
sendWorkflowError(runId, `Unknown role: ${nextRole}`);
|
sendThreadEvent(runId, "completed", { exitCode: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (steps.length < maxRounds) {
|
||||||
|
const result = await executeRole(def, nextRole, start, roleMessages, runId);
|
||||||
|
|
||||||
|
if (killFlag.value) {
|
||||||
|
sendThreadEvent(runId, "killed", { exitCode: 137 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: { content: string; meta: Record<string, unknown> };
|
if (result === null) return;
|
||||||
try {
|
|
||||||
result = await role(chain);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const errMsg = e instanceof Error ? e.message : String(e);
|
|
||||||
sendThreadEvent(runId, "failed", { error: errMsg });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof result.content !== "string") {
|
|
||||||
sendWorkflowError(runId, `Role "${nextRole}" returned non-string content`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.meta === null || typeof result.meta !== "object" || Array.isArray(result.meta)) {
|
|
||||||
sendWorkflowError(runId, `Role "${nextRole}" returned invalid meta (must be a plain object)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message: WorkflowMessage = {
|
const message: WorkflowMessage = {
|
||||||
role: nextRole,
|
role: nextRole,
|
||||||
@@ -145,27 +250,42 @@ async function runThread(
|
|||||||
meta: result.meta,
|
meta: result.meta,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
chain.push(message);
|
roleMessages.push(message);
|
||||||
sendWorkflowMessage(runId, message);
|
sendWorkflowMessage(runId, message);
|
||||||
|
|
||||||
roleRound += 1;
|
steps.push({
|
||||||
|
role: nextRole,
|
||||||
|
meta: result.meta,
|
||||||
|
content: result.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
const signal = { role: nextRole, meta: result.meta };
|
nextRole = def.moderator({ start, steps });
|
||||||
nextRole = def.moderator(signal as Parameters<typeof def.moderator>[0], roleRound, maxRounds);
|
|
||||||
|
|
||||||
if (nextRole === END) {
|
if (nextRole === END) {
|
||||||
sendThreadEvent(runId, "completed", null);
|
sendThreadEvent(runId, "completed", { exitCode: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendWorkflowError(runId, `Thread exceeded maximum rounds (${maxRounds})`);
|
sendWorkflowError(runId, `Thread exceeded maximum rounds (${maxRounds})`, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Workflow definition loader
|
// Workflow definition loader
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isWorkflowDefinitionShape(def: unknown): def is WorkflowDefinition<RoleMeta> {
|
||||||
|
if (!isPlainRecord(def)) return false;
|
||||||
|
return (
|
||||||
|
typeof def.moderator === "function" &&
|
||||||
|
typeof def.roles === "object" &&
|
||||||
|
def.roles !== null &&
|
||||||
|
!Array.isArray(def.roles) &&
|
||||||
|
typeof def.name === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadWorkflowDefinition(
|
async function loadWorkflowDefinition(
|
||||||
nerveRoot: string,
|
nerveRoot: string,
|
||||||
workflowName: string,
|
workflowName: string,
|
||||||
@@ -186,19 +306,13 @@ async function loadWorkflowDefinition(
|
|||||||
const mod = await import(indexPath);
|
const mod = await import(indexPath);
|
||||||
const def: unknown = mod.default ?? mod;
|
const def: unknown = mod.default ?? mod;
|
||||||
|
|
||||||
if (
|
if (!isWorkflowDefinitionShape(def)) {
|
||||||
def === null ||
|
|
||||||
typeof def !== "object" ||
|
|
||||||
typeof (def as WorkflowDefinition<RoleMeta>).moderator !== "function" ||
|
|
||||||
typeof (def as WorkflowDefinition<RoleMeta>).roles !== "object" ||
|
|
||||||
typeof (def as WorkflowDefinition<RoleMeta>).name !== "string"
|
|
||||||
) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Workflow "${workflowName}" must export a WorkflowDefinition with "name", "roles", and "moderator".`,
|
`Workflow "${workflowName}" must export a WorkflowDefinition with "name", "roles", and "moderator".`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return def as WorkflowDefinition<RoleMeta>;
|
return def;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -209,6 +323,7 @@ function handleMessage(
|
|||||||
raw: unknown,
|
raw: unknown,
|
||||||
def: WorkflowDefinition<RoleMeta>,
|
def: WorkflowDefinition<RoleMeta>,
|
||||||
inFlight: Map<string, Promise<void>>,
|
inFlight: Map<string, Promise<void>>,
|
||||||
|
killFlags: Map<string, KillFlag>,
|
||||||
shuttingDown: { value: boolean },
|
shuttingDown: { value: boolean },
|
||||||
): void {
|
): void {
|
||||||
const parseResult = parseParentMessage(raw);
|
const parseResult = parseParentMessage(raw);
|
||||||
@@ -230,17 +345,21 @@ function handleMessage(
|
|||||||
|
|
||||||
if (msg.type === "start-thread") {
|
if (msg.type === "start-thread") {
|
||||||
if (shuttingDown.value) return;
|
if (shuttingDown.value) return;
|
||||||
const { runId, prompt, maxRounds } = msg;
|
const { runId, prompt, maxRounds, dryRun } = msg;
|
||||||
|
|
||||||
|
const killFlag: KillFlag = { value: false };
|
||||||
|
killFlags.set(runId, killFlag);
|
||||||
|
|
||||||
const previous = inFlight.get(runId) ?? Promise.resolve();
|
const previous = inFlight.get(runId) ?? Promise.resolve();
|
||||||
const next = previous
|
const next = previous
|
||||||
.then(() => runThread(def, runId, maxRounds, [], prompt))
|
.then(() => runThread(def, runId, maxRounds, killFlag, [], prompt, dryRun))
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
const errMsg = e instanceof Error ? e.message : String(e);
|
const errMsg = e instanceof Error ? e.message : String(e);
|
||||||
sendWorkflowError(runId, errMsg);
|
sendWorkflowError(runId, errMsg);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
inFlight.delete(runId);
|
inFlight.delete(runId);
|
||||||
|
killFlags.delete(runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
inFlight.set(runId, next);
|
inFlight.set(runId, next);
|
||||||
@@ -249,22 +368,34 @@ function handleMessage(
|
|||||||
|
|
||||||
if (msg.type === "resume-thread") {
|
if (msg.type === "resume-thread") {
|
||||||
if (shuttingDown.value) return;
|
if (shuttingDown.value) return;
|
||||||
const { runId, messages, maxRounds } = msg;
|
const { runId, messages, maxRounds, dryRun } = msg;
|
||||||
|
|
||||||
|
const killFlag: KillFlag = { value: false };
|
||||||
|
killFlags.set(runId, killFlag);
|
||||||
|
|
||||||
const previous = inFlight.get(runId) ?? Promise.resolve();
|
const previous = inFlight.get(runId) ?? Promise.resolve();
|
||||||
const next = previous
|
const next = previous
|
||||||
.then(() => runThread(def, runId, maxRounds, messages as WorkflowMessage[], null))
|
.then(() => runThread(def, runId, maxRounds, killFlag, messages, null, dryRun))
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
const errMsg = e instanceof Error ? e.message : String(e);
|
const errMsg = e instanceof Error ? e.message : String(e);
|
||||||
sendWorkflowError(runId, errMsg);
|
sendWorkflowError(runId, errMsg);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
inFlight.delete(runId);
|
inFlight.delete(runId);
|
||||||
|
killFlags.delete(runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
inFlight.set(runId, next);
|
inFlight.set(runId, next);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === "kill-thread") {
|
||||||
|
const flag = killFlags.get(msg.runId);
|
||||||
|
if (flag !== undefined) {
|
||||||
|
flag.value = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -282,12 +413,13 @@ async function bootstrap(nerveRoot: string, workflowName: string): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inFlight = new Map<string, Promise<void>>();
|
const inFlight = new Map<string, Promise<void>>();
|
||||||
|
const killFlags = new Map<string, KillFlag>();
|
||||||
const shuttingDown = { value: false };
|
const shuttingDown = { value: false };
|
||||||
|
|
||||||
sendReady();
|
sendReady();
|
||||||
|
|
||||||
process.on("message", (raw: unknown) => {
|
process.on("message", (raw: unknown) => {
|
||||||
handleMessage(raw, def, inFlight, shuttingDown);
|
handleMessage(raw, def, inFlight, killFlags, shuttingDown);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- Agent registry
|
||||||
|
CREATE TABLE agents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Workflow threads
|
||||||
|
CREATE TABLE threads (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workflow TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
initiator TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
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,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
meta TEXT,
|
||||||
|
step INTEGER NOT NULL,
|
||||||
|
agent_id TEXT,
|
||||||
|
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,
|
||||||
|
thread_id TEXT NOT NULL REFERENCES threads (id),
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
instruction TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
claim_id TEXT,
|
||||||
|
claimed_by TEXT,
|
||||||
|
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);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/khala",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"test": "vitest run",
|
||||||
|
"check": "biome check ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/nerve-core": "workspace:*",
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"jsonata": "^2.0.5",
|
||||||
|
"ulidx": "^2.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/vitest-pool-workers": "^0.14.0",
|
||||||
|
"@cloudflare/workers-types": "^4.20250410.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "^4.1.5",
|
||||||
|
"wrangler": "^4.14.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { err, ok } from "@uncaged/nerve-core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("Result", () => {
|
||||||
|
it("ok wraps a value", () => {
|
||||||
|
const r = ok(42);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.value).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("err wraps an error", () => {
|
||||||
|
const r = err("boom");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) expect(r.error).toBe("boom");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createMiddleware } from "hono/factory";
|
||||||
|
import type { KhalaBindings } from "./env.js";
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
function hexDigest(buf: ArrayBuffer): string {
|
||||||
|
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sha256Hex(input: string): Promise<string> {
|
||||||
|
const data = encoder.encode(input);
|
||||||
|
const dig = await crypto.subtle.digest("SHA-256", data);
|
||||||
|
return hexDigest(dig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentAuth = createMiddleware<{
|
||||||
|
Bindings: KhalaBindings;
|
||||||
|
Variables: { agentId: string };
|
||||||
|
}>(async (c, next) => {
|
||||||
|
const header = c.req.header("Authorization");
|
||||||
|
if (header === undefined || !header.startsWith("Bearer ")) {
|
||||||
|
return c.json({ error: "missing token" }, 401);
|
||||||
|
}
|
||||||
|
const token = header.slice(7);
|
||||||
|
const hash = await sha256Hex(token);
|
||||||
|
const agent = await c.env.DB.prepare("SELECT id FROM agents WHERE token_hash = ?")
|
||||||
|
.bind(hash)
|
||||||
|
.first<{ id: string }>();
|
||||||
|
if (agent === null) {
|
||||||
|
return c.json({ error: "invalid token" }, 401);
|
||||||
|
}
|
||||||
|
c.set("agentId", agent.id);
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export function requireAdmin(c: {
|
||||||
|
req: { header: (k: string) => string | undefined };
|
||||||
|
env: KhalaBindings;
|
||||||
|
}): boolean {
|
||||||
|
const header = c.req.header("Authorization");
|
||||||
|
const expected = c.env.ADMIN_SECRET;
|
||||||
|
if (expected.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (header === undefined || !header.startsWith("Bearer ")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return header.slice(7) === expected;
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import type { Result } from "@uncaged/nerve-core";
|
||||||
|
import { err, ok } from "@uncaged/nerve-core";
|
||||||
|
import { type ULID, monotonicFactory } from "ulidx";
|
||||||
|
import type { Agent, GetThreadMessagesOpts, Message, Task, Thread } from "./types.js";
|
||||||
|
|
||||||
|
const generateUlid = monotonicFactory((): number => Date.now());
|
||||||
|
|
||||||
|
export function newId(): ULID {
|
||||||
|
return generateUlid();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createThread(
|
||||||
|
db: D1Database,
|
||||||
|
workflow: string,
|
||||||
|
initiator: string,
|
||||||
|
): Promise<Thread> {
|
||||||
|
const id = newId();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO threads (id, workflow, status, initiator, result, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'active', ?, NULL, datetime('now'), datetime('now'))`,
|
||||||
|
)
|
||||||
|
.bind(id, workflow, initiator)
|
||||||
|
.run();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
workflow,
|
||||||
|
status: "active",
|
||||||
|
initiator,
|
||||||
|
result: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThread(db: D1Database, id: string): Promise<Thread | null> {
|
||||||
|
const row = await db.prepare("SELECT * FROM threads WHERE id = ?").bind(id).first<ThreadRow>();
|
||||||
|
if (!row) return null;
|
||||||
|
return rowToThread(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThreadRow = {
|
||||||
|
id: string;
|
||||||
|
workflow: string;
|
||||||
|
status: string;
|
||||||
|
initiator: string;
|
||||||
|
result: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function rowToThread(row: ThreadRow): Thread {
|
||||||
|
const status = row.status === "completed" || row.status === "failed" ? row.status : "active";
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
workflow: row.workflow,
|
||||||
|
status,
|
||||||
|
initiator: row.initiator,
|
||||||
|
result: row.result,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setThreadResult(
|
||||||
|
db: D1Database,
|
||||||
|
id: string,
|
||||||
|
result: string,
|
||||||
|
status: "completed" | "failed",
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(`UPDATE threads SET result = ?, status = ?, updated_at = datetime('now') WHERE id = ?`)
|
||||||
|
.bind(result, status, id)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function failThread(db: D1Database, id: string, result: string): Promise<void> {
|
||||||
|
await setThreadResult(db, id, result, "failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendMessage(
|
||||||
|
db: D1Database,
|
||||||
|
threadId: string,
|
||||||
|
role: string,
|
||||||
|
content: string,
|
||||||
|
meta: string | null,
|
||||||
|
step: number,
|
||||||
|
agentId: string | null,
|
||||||
|
): Promise<Message> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages (thread_id, role, content, meta, step, agent_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
RETURNING *`,
|
||||||
|
)
|
||||||
|
.bind(threadId, role, content, meta, step, agentId)
|
||||||
|
.first<MessageRow>();
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("appendMessage: INSERT RETURNING returned no row");
|
||||||
|
}
|
||||||
|
return rowToMessage(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageRow = {
|
||||||
|
id: number;
|
||||||
|
thread_id: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
meta: string | null;
|
||||||
|
step: number;
|
||||||
|
agent_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function rowToMessage(row: MessageRow): Message {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
thread_id: row.thread_id,
|
||||||
|
role: row.role,
|
||||||
|
content: row.content,
|
||||||
|
meta: row.meta,
|
||||||
|
step: row.step,
|
||||||
|
agent_id: row.agent_id,
|
||||||
|
created_at: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThreadMessages(
|
||||||
|
db: D1Database,
|
||||||
|
threadId: string,
|
||||||
|
opts: GetThreadMessagesOpts | null,
|
||||||
|
): Promise<Message[]> {
|
||||||
|
const o = opts ?? { role: null, minStep: null, maxStep: null, limit: null };
|
||||||
|
const maxRows = o.limit === null ? 10_000 : Math.min(10_000, Math.max(1, o.limit));
|
||||||
|
let sql = "SELECT * FROM messages WHERE thread_id = ?";
|
||||||
|
const binds: (string | number)[] = [threadId];
|
||||||
|
if (o.role !== null) {
|
||||||
|
sql += " AND role = ?";
|
||||||
|
binds.push(o.role);
|
||||||
|
}
|
||||||
|
if (o.minStep !== null) {
|
||||||
|
sql += " AND step >= ?";
|
||||||
|
binds.push(o.minStep);
|
||||||
|
}
|
||||||
|
if (o.maxStep !== null) {
|
||||||
|
sql += " AND step <= ?";
|
||||||
|
binds.push(o.maxStep);
|
||||||
|
}
|
||||||
|
sql += " ORDER BY step ASC, id ASC LIMIT ?";
|
||||||
|
binds.push(maxRows);
|
||||||
|
const st = await db
|
||||||
|
.prepare(sql)
|
||||||
|
.bind(...binds)
|
||||||
|
.all<MessageRow>();
|
||||||
|
const list = (st.results as MessageRow[] | null) ?? [];
|
||||||
|
return list.map(rowToMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMaxMessageStep(db: D1Database, threadId: string): Promise<number> {
|
||||||
|
const row = await db
|
||||||
|
.prepare("SELECT MAX(step) as m FROM messages WHERE thread_id = ?")
|
||||||
|
.bind(threadId)
|
||||||
|
.first<{ m: number | null }>();
|
||||||
|
if (!row || row.m === null) return -1;
|
||||||
|
return row.m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTask(
|
||||||
|
db: D1Database,
|
||||||
|
threadId: string,
|
||||||
|
role: string,
|
||||||
|
instruction: string,
|
||||||
|
timeoutSeconds: number,
|
||||||
|
): Promise<Task> {
|
||||||
|
const id = newId();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const to = Math.max(1, Math.floor(timeoutSeconds));
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO tasks (id, thread_id, role, instruction, status, claim_id, claimed_by, claimed_at, timeout_seconds, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'open', NULL, NULL, NULL, ?, datetime('now'))`,
|
||||||
|
)
|
||||||
|
.bind(id, threadId, role, instruction, to)
|
||||||
|
.run();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
thread_id: threadId,
|
||||||
|
role,
|
||||||
|
instruction,
|
||||||
|
status: "open",
|
||||||
|
claim_id: null,
|
||||||
|
claimed_by: null,
|
||||||
|
claimed_at: null,
|
||||||
|
timeout_seconds: to,
|
||||||
|
created_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskRow = {
|
||||||
|
id: string;
|
||||||
|
thread_id: string;
|
||||||
|
role: string;
|
||||||
|
instruction: string;
|
||||||
|
status: string;
|
||||||
|
claim_id: string | null;
|
||||||
|
claimed_by: string | null;
|
||||||
|
claimed_at: string | null;
|
||||||
|
timeout_seconds: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function rowToTask(row: TaskRow): Task {
|
||||||
|
const status =
|
||||||
|
row.status === "open" ||
|
||||||
|
row.status === "claimed" ||
|
||||||
|
row.status === "completed" ||
|
||||||
|
row.status === "expired"
|
||||||
|
? row.status
|
||||||
|
: "open";
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
thread_id: row.thread_id,
|
||||||
|
role: row.role,
|
||||||
|
instruction: row.instruction,
|
||||||
|
status,
|
||||||
|
claim_id: row.claim_id,
|
||||||
|
claimed_by: row.claimed_by,
|
||||||
|
claimed_at: row.claimed_at,
|
||||||
|
timeout_seconds: row.timeout_seconds,
|
||||||
|
created_at: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClaimResult = { ok: true; claimId: string } | { ok: false };
|
||||||
|
|
||||||
|
export async function claimTask(
|
||||||
|
db: D1Database,
|
||||||
|
taskId: string,
|
||||||
|
agentId: string,
|
||||||
|
): Promise<ClaimResult> {
|
||||||
|
const claimId = newId();
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE tasks
|
||||||
|
SET status = 'claimed', claim_id = ?, claimed_by = ?, claimed_at = datetime('now')
|
||||||
|
WHERE id = ? AND status = 'open'`,
|
||||||
|
)
|
||||||
|
.bind(claimId, agentId, taskId)
|
||||||
|
.run();
|
||||||
|
const changes = res.meta.changes;
|
||||||
|
if (typeof changes === "number" && changes > 0) {
|
||||||
|
return { ok: true, claimId };
|
||||||
|
}
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskById(db: D1Database, id: string): Promise<Task | null> {
|
||||||
|
const row = await db.prepare("SELECT * FROM tasks WHERE id = ?").bind(id).first<TaskRow>();
|
||||||
|
if (!row) return null;
|
||||||
|
return rowToTask(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeTask(
|
||||||
|
db: D1Database,
|
||||||
|
taskId: string,
|
||||||
|
claimId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE tasks SET status = 'completed' WHERE id = ? AND claim_id = ? AND status = 'claimed'`,
|
||||||
|
)
|
||||||
|
.bind(taskId, claimId)
|
||||||
|
.run();
|
||||||
|
const changes = res.meta.changes;
|
||||||
|
if (typeof changes === "number") return changes > 0;
|
||||||
|
return res.success === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseTask(
|
||||||
|
db: D1Database,
|
||||||
|
taskId: string,
|
||||||
|
claimId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE tasks
|
||||||
|
SET status = 'open', claim_id = NULL, claimed_by = NULL, claimed_at = NULL
|
||||||
|
WHERE id = ? AND claim_id = ? AND status = 'claimed'`,
|
||||||
|
)
|
||||||
|
.bind(taskId, claimId)
|
||||||
|
.run();
|
||||||
|
const changes = res.meta.changes;
|
||||||
|
if (typeof changes === "number") return changes > 0;
|
||||||
|
return res.success === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expireTimedOutTasks(db: D1Database): Promise<number> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE tasks
|
||||||
|
SET status = 'open', claim_id = NULL, claimed_by = NULL, claimed_at = NULL
|
||||||
|
WHERE status = 'claimed'
|
||||||
|
AND claimed_at IS NOT NULL
|
||||||
|
AND (strftime('%s', 'now') - strftime('%s', claimed_at)) > timeout_seconds`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
const changes = res.meta.changes;
|
||||||
|
if (typeof changes === "number") return changes;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpenTasks(
|
||||||
|
db: D1Database,
|
||||||
|
limit: number,
|
||||||
|
workflow: string | null,
|
||||||
|
): Promise<Task[]> {
|
||||||
|
const cap = Math.min(500, Math.max(1, limit));
|
||||||
|
if (workflow === null) {
|
||||||
|
const st = await db
|
||||||
|
.prepare(`SELECT t.* FROM tasks t WHERE t.status = 'open' ORDER BY t.created_at ASC LIMIT ?`)
|
||||||
|
.bind(cap)
|
||||||
|
.all<TaskRow>();
|
||||||
|
const results = (st.results as TaskRow[] | null) ?? [];
|
||||||
|
return results.map(rowToTask);
|
||||||
|
}
|
||||||
|
const st = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.* FROM tasks t
|
||||||
|
INNER JOIN threads th ON t.thread_id = th.id
|
||||||
|
WHERE t.status = 'open' AND th.workflow = ?
|
||||||
|
ORDER BY t.created_at ASC LIMIT ?`,
|
||||||
|
)
|
||||||
|
.bind(workflow, cap)
|
||||||
|
.all<TaskRow>();
|
||||||
|
const results = (st.results as TaskRow[] | null) ?? [];
|
||||||
|
return results.map(rowToTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAgent(
|
||||||
|
db: D1Database,
|
||||||
|
id: string,
|
||||||
|
tokenHash: string,
|
||||||
|
): Promise<Result<Agent, string>> {
|
||||||
|
const existing = await db.prepare("SELECT id FROM agents WHERE id = ?").bind(id).first<{
|
||||||
|
id: string;
|
||||||
|
}>();
|
||||||
|
if (existing) {
|
||||||
|
return err(`agent already exists: ${id}`);
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.prepare("INSERT INTO agents (id, token_hash, created_at) VALUES (?, ?, datetime('now'))")
|
||||||
|
.bind(id, tokenHash)
|
||||||
|
.run();
|
||||||
|
const row = await db.prepare("SELECT * FROM agents WHERE id = ?").bind(id).first<Agent>();
|
||||||
|
if (!row) return err("registerAgent: insert failed");
|
||||||
|
return ok(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAgents(
|
||||||
|
db: D1Database,
|
||||||
|
): Promise<ReadonlyArray<{ id: string; created_at: string }>> {
|
||||||
|
const st = await db
|
||||||
|
.prepare("SELECT id, created_at FROM agents ORDER BY id ASC")
|
||||||
|
.all<{ id: string; created_at: string }>();
|
||||||
|
return (st.results as { id: string; created_at: string }[] | null) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAgent(db: D1Database, id: string): Promise<boolean> {
|
||||||
|
const res = await db.prepare("DELETE FROM agents WHERE id = ?").bind(id).run();
|
||||||
|
const changes = res.meta.changes;
|
||||||
|
if (typeof changes === "number") return changes > 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskForThreadAndClaim(
|
||||||
|
db: D1Database,
|
||||||
|
threadId: string,
|
||||||
|
taskId: string,
|
||||||
|
claimId: string,
|
||||||
|
): Promise<Task | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare("SELECT * FROM tasks WHERE id = ? AND thread_id = ? AND claim_id = ?")
|
||||||
|
.bind(taskId, threadId, claimId)
|
||||||
|
.first<TaskRow>();
|
||||||
|
if (!row) return null;
|
||||||
|
return rowToTask(row);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Cloudflare bindings for the Khala worker. Mirrored in worker-configuration.d.ts (ProvidedEnv).
|
||||||
|
*/
|
||||||
|
export type KhalaBindings = {
|
||||||
|
DB: D1Database;
|
||||||
|
THREAD: DurableObjectNamespace;
|
||||||
|
ADMIN_SECRET: string;
|
||||||
|
TEST_MIGRATIONS: { id: string; name: string; queries: { sql: string }[] }[] | undefined;
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { agentAuth } from "./auth.js";
|
||||||
|
import {
|
||||||
|
claimTask,
|
||||||
|
createThread,
|
||||||
|
expireTimedOutTasks,
|
||||||
|
getOpenTasks,
|
||||||
|
getTaskById,
|
||||||
|
getThread,
|
||||||
|
releaseTask,
|
||||||
|
} from "./db.js";
|
||||||
|
import type { KhalaBindings } from "./env.js";
|
||||||
|
import { admin } from "./routes/admin.js";
|
||||||
|
import { ThreadDO } from "./thread-do.js";
|
||||||
|
import { getWorkflow } from "./workflows.js";
|
||||||
|
|
||||||
|
// Register built-in workflows
|
||||||
|
import "./workflows/ping-pong.js";
|
||||||
|
|
||||||
|
type HonoEnv = { Bindings: KhalaBindings; Variables: { agentId: string } };
|
||||||
|
|
||||||
|
const app = new Hono<HonoEnv>();
|
||||||
|
|
||||||
|
// Health
|
||||||
|
app.get("/health", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
app.route("/admin", admin);
|
||||||
|
|
||||||
|
// --- Agent-authenticated routes ---
|
||||||
|
|
||||||
|
// Create workflow thread
|
||||||
|
app.post("/workflows/:name/threads", agentAuth, async (c) => {
|
||||||
|
const name = c.req.param("name");
|
||||||
|
const def = getWorkflow(name);
|
||||||
|
if (def === null) {
|
||||||
|
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||||
|
}
|
||||||
|
const thread = await createThread(c.env.DB, name, c.get("agentId"));
|
||||||
|
const doId = c.env.THREAD.idFromName(thread.id);
|
||||||
|
const stub = c.env.THREAD.get(doId);
|
||||||
|
const doReq = new Request("https://do/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Thread-Id": thread.id },
|
||||||
|
});
|
||||||
|
const doRes = await stub.fetch(doReq);
|
||||||
|
if (!doRes.ok) {
|
||||||
|
const body = await doRes.text();
|
||||||
|
return c.json({ error: `failed to start thread: ${body}` }, 500);
|
||||||
|
}
|
||||||
|
return c.json({ threadId: thread.id, workflow: name, status: "active" }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get thread status
|
||||||
|
app.get("/threads/:id", agentAuth, async (c) => {
|
||||||
|
const thread = await getThread(c.env.DB, c.req.param("id"));
|
||||||
|
if (thread === null) {
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
return c.json(thread);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get thread messages
|
||||||
|
app.get("/threads/:id/messages", agentAuth, async (c) => {
|
||||||
|
const doId = c.env.THREAD.idFromName(c.req.param("id"));
|
||||||
|
const stub = c.env.THREAD.get(doId);
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const doReq = new Request(`https://do/messages${url.search}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { "X-Thread-Id": c.req.param("id") },
|
||||||
|
});
|
||||||
|
return stub.fetch(doReq);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Post response to thread
|
||||||
|
app.post("/threads/:id/response", agentAuth, async (c) => {
|
||||||
|
const threadId = c.req.param("id");
|
||||||
|
const body = await c.req.text();
|
||||||
|
const doId = c.env.THREAD.idFromName(threadId);
|
||||||
|
const stub = c.env.THREAD.get(doId);
|
||||||
|
const doReq = new Request("https://do/response", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Thread-Id": threadId, "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return stub.fetch(doReq);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Task Queue ---
|
||||||
|
|
||||||
|
// List open tasks
|
||||||
|
app.get("/tasks", agentAuth, async (c) => {
|
||||||
|
const workflow = c.req.query("workflow") ?? null;
|
||||||
|
const limit = Number.parseInt(c.req.query("limit") ?? "50", 10);
|
||||||
|
const tasks = await getOpenTasks(c.env.DB, Number.isFinite(limit) ? limit : 50, workflow);
|
||||||
|
return c.json({ tasks });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claim a task
|
||||||
|
// TODO(#132): Add max_concurrent_claims per agent to prevent greedy claiming
|
||||||
|
app.post("/tasks/:id/claim", agentAuth, async (c) => {
|
||||||
|
const taskId = c.req.param("id");
|
||||||
|
const agentId = c.get("agentId");
|
||||||
|
const result = await claimTask(c.env.DB, taskId, agentId);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: "task not available" }, 409);
|
||||||
|
}
|
||||||
|
const task = await getTaskById(c.env.DB, taskId);
|
||||||
|
if (task === null) {
|
||||||
|
return c.json({ error: "task not found" }, 404);
|
||||||
|
}
|
||||||
|
return c.json({
|
||||||
|
claimId: result.claimId,
|
||||||
|
taskId: task.id,
|
||||||
|
threadId: task.thread_id,
|
||||||
|
role: task.role,
|
||||||
|
instruction: task.instruction,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Release a task
|
||||||
|
app.post("/tasks/:id/release", agentAuth, async (c) => {
|
||||||
|
const taskId = c.req.param("id");
|
||||||
|
const raw: unknown = await c.req.json();
|
||||||
|
const body = raw as Readonly<Record<string, unknown>>;
|
||||||
|
const claimId = body.claimId;
|
||||||
|
if (typeof claimId !== "string") {
|
||||||
|
return c.json({ error: "claimId required" }, 400);
|
||||||
|
}
|
||||||
|
const ok = await releaseTask(c.env.DB, taskId, claimId);
|
||||||
|
if (!ok) {
|
||||||
|
return c.json({ error: "release failed" }, 409);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Cron: expire timed-out tasks ---
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetch: app.fetch,
|
||||||
|
async scheduled(_event: ScheduledEvent, env: KhalaBindings, _ctx: ExecutionContext) {
|
||||||
|
const expired = await expireTimedOutTasks(env.DB);
|
||||||
|
if (expired > 0) {
|
||||||
|
console.log(`khala: expired ${expired} timed-out task(s)`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ThreadDO };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Result } from "@uncaged/nerve-core";
|
||||||
|
import { err, ok } from "@uncaged/nerve-core";
|
||||||
|
import jsonata from "jsonata";
|
||||||
|
|
||||||
|
export type ModeratorDecision = { role: string };
|
||||||
|
|
||||||
|
export async function evaluateModerator(
|
||||||
|
expression: string,
|
||||||
|
context: Readonly<Record<string, unknown>>,
|
||||||
|
): Promise<Result<ModeratorDecision, string>> {
|
||||||
|
let expr: ReturnType<typeof jsonata>;
|
||||||
|
try {
|
||||||
|
expr = jsonata(expression);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return err(`jsonata compile: ${msg}`);
|
||||||
|
}
|
||||||
|
let out: unknown;
|
||||||
|
try {
|
||||||
|
out = await expr.evaluate(context);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return err(`jsonata eval: ${msg}`);
|
||||||
|
}
|
||||||
|
if (out === null || out === undefined) {
|
||||||
|
return err("moderator returned empty");
|
||||||
|
}
|
||||||
|
if (typeof out !== "object" || out === null || Array.isArray(out)) {
|
||||||
|
return err("moderator must return an object");
|
||||||
|
}
|
||||||
|
const rec = out as Readonly<Record<string, unknown>>;
|
||||||
|
const role = rec.role;
|
||||||
|
if (typeof role === "string") {
|
||||||
|
return ok({ role });
|
||||||
|
}
|
||||||
|
return err("moderator result missing string role");
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Result } from "@uncaged/nerve-core";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { requireAdmin, sha256Hex } from "../auth.js";
|
||||||
|
import { deleteAgent, listAgents, registerAgent } from "../db.js";
|
||||||
|
import type { KhalaBindings } from "../env.js";
|
||||||
|
|
||||||
|
const admin = new Hono<{ Bindings: KhalaBindings }>();
|
||||||
|
|
||||||
|
function jsonErr(res: Result<unknown, string>, status: number) {
|
||||||
|
if (res.ok) {
|
||||||
|
return new Response("unexpected", { status: 500 });
|
||||||
|
}
|
||||||
|
return Response.json({ error: String(res.error) }, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
admin.get("/agents", async (c) => {
|
||||||
|
if (!requireAdmin(c)) {
|
||||||
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
const agents = await listAgents(c.env.DB);
|
||||||
|
return c.json({ agents: [...agents] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO(#132): Add rate limiting for agent registration endpoint
|
||||||
|
admin.post("/agents", async (c) => {
|
||||||
|
if (!requireAdmin(c)) {
|
||||||
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
const raw: unknown = await c.req.json();
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return c.json({ error: "invalid body" }, 400);
|
||||||
|
}
|
||||||
|
const body = raw as Readonly<Record<string, unknown>>;
|
||||||
|
const id = body.id;
|
||||||
|
const token = body.token;
|
||||||
|
if (
|
||||||
|
typeof id !== "string" ||
|
||||||
|
id.length === 0 ||
|
||||||
|
typeof token !== "string" ||
|
||||||
|
token.length === 0
|
||||||
|
) {
|
||||||
|
return c.json({ error: "id and token required" }, 400);
|
||||||
|
}
|
||||||
|
const hash = await sha256Hex(token);
|
||||||
|
const res = await registerAgent(c.env.DB, id, hash);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.error.includes("already exists")) {
|
||||||
|
return jsonErr(res, 409);
|
||||||
|
}
|
||||||
|
return jsonErr(res, 400);
|
||||||
|
}
|
||||||
|
return c.json({ id: res.value.id, created_at: res.value.created_at });
|
||||||
|
});
|
||||||
|
|
||||||
|
admin.delete("/agents/:id", async (c) => {
|
||||||
|
if (!requireAdmin(c)) {
|
||||||
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const ok = await deleteAgent(c.env.DB, id);
|
||||||
|
if (!ok) {
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export { admin };
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import {
|
||||||
|
appendMessage,
|
||||||
|
completeTask,
|
||||||
|
createTask,
|
||||||
|
getMaxMessageStep,
|
||||||
|
getTaskForThreadAndClaim,
|
||||||
|
getThread,
|
||||||
|
getThreadMessages,
|
||||||
|
setThreadResult,
|
||||||
|
} from "./db.js";
|
||||||
|
import type { KhalaBindings } from "./env.js";
|
||||||
|
import { evaluateModerator } from "./moderator.js";
|
||||||
|
import type { CloudWorkflowDef } from "./workflows.js";
|
||||||
|
import { getWorkflow } from "./workflows.js";
|
||||||
|
|
||||||
|
type StepRow = { role: string; content: string; meta: unknown };
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Readonly<Record<string, unknown>> {
|
||||||
|
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as Readonly<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMetaString(value: unknown): string | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(msgs: Awaited<ReturnType<typeof getThreadMessages>>): StepRow[] {
|
||||||
|
return msgs
|
||||||
|
.filter((m) => m.role !== "__moderator__")
|
||||||
|
.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
meta: m.meta
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(m.meta) as unknown;
|
||||||
|
} catch {
|
||||||
|
return m.meta;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable object for one workflow thread. Relies on D1 (messages, tasks, threads) as source of truth.
|
||||||
|
*/
|
||||||
|
export class ThreadDO {
|
||||||
|
private readonly env: KhalaBindings;
|
||||||
|
|
||||||
|
constructor(_state: DurableObjectState, env: KhalaBindings) {
|
||||||
|
this.env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runModeratorAndSchedule(
|
||||||
|
db: D1Database,
|
||||||
|
def: CloudWorkflowDef,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
const messages = await getThreadMessages(db, threadId, null);
|
||||||
|
const steps = buildSteps(messages);
|
||||||
|
const mod = await evaluateModerator(def.moderator, { steps: [...steps] });
|
||||||
|
if (!mod.ok) {
|
||||||
|
return { ok: false, error: mod.error };
|
||||||
|
}
|
||||||
|
if (mod.value.role === "__end__") {
|
||||||
|
await setThreadResult(
|
||||||
|
db,
|
||||||
|
threadId,
|
||||||
|
JSON.stringify({ completed: true, stepCount: steps.length }),
|
||||||
|
"completed",
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
const role = mod.value.role;
|
||||||
|
const r = def.roles[role] ?? null;
|
||||||
|
if (r === null) {
|
||||||
|
await setThreadResult(
|
||||||
|
db,
|
||||||
|
threadId,
|
||||||
|
JSON.stringify({ error: `unknown role: ${role}` }),
|
||||||
|
"failed",
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
await createTask(db, threadId, role, r.prompt, r.timeoutSeconds ?? 300);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startThread(threadId: string): Promise<Response> {
|
||||||
|
const db = this.env.DB;
|
||||||
|
const thread = await getThread(db, threadId);
|
||||||
|
if (thread === null) {
|
||||||
|
return Response.json({ error: "thread not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (thread.status !== "active") {
|
||||||
|
return Response.json({ error: "thread not active" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const def = getWorkflow(thread.workflow);
|
||||||
|
if (def === null) {
|
||||||
|
return Response.json({ error: "workflow not registered" }, { status: 500 });
|
||||||
|
}
|
||||||
|
const r = await this.runModeratorAndSchedule(db, def, threadId);
|
||||||
|
if (!r.ok) {
|
||||||
|
await setThreadResult(db, threadId, JSON.stringify({ error: r.error }), "failed");
|
||||||
|
return Response.json({ error: r.error }, { status: 500 });
|
||||||
|
}
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async postResponse(
|
||||||
|
threadId: string,
|
||||||
|
body: Readonly<Record<string, unknown>>,
|
||||||
|
): Promise<Response> {
|
||||||
|
const taskId = body.taskId;
|
||||||
|
const content = body.content;
|
||||||
|
const claimId = body.claimId;
|
||||||
|
const agentId = body.agentId;
|
||||||
|
if (
|
||||||
|
typeof taskId !== "string" ||
|
||||||
|
typeof content !== "string" ||
|
||||||
|
typeof claimId !== "string" ||
|
||||||
|
typeof agentId !== "string"
|
||||||
|
) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "taskId, content, claimId, agentId required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const db = this.env.DB;
|
||||||
|
const task = await getTaskForThreadAndClaim(db, threadId, taskId, claimId);
|
||||||
|
if (task === null) {
|
||||||
|
return Response.json({ error: "task or claim not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (task.claimed_by !== agentId) {
|
||||||
|
return Response.json({ error: "agent mismatch" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (task.status !== "claimed") {
|
||||||
|
return Response.json({ error: "task not claimable" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const thread = await getThread(db, threadId);
|
||||||
|
if (thread === null) {
|
||||||
|
return Response.json({ error: "thread not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const def = getWorkflow(thread.workflow);
|
||||||
|
if (def === null) {
|
||||||
|
return Response.json({ error: "workflow not registered" }, { status: 500 });
|
||||||
|
}
|
||||||
|
const nextStep = (await getMaxMessageStep(db, threadId)) + 1;
|
||||||
|
const meta = Object.hasOwn(body, "meta")
|
||||||
|
? normalizeMetaString((body as Readonly<Record<string, unknown>>).meta)
|
||||||
|
: null;
|
||||||
|
await appendMessage(db, threadId, task.role, content, meta, nextStep, agentId);
|
||||||
|
const done = await completeTask(db, taskId, claimId);
|
||||||
|
if (!done) {
|
||||||
|
return Response.json({ error: "could not complete task" }, { status: 409 });
|
||||||
|
}
|
||||||
|
const r = await this.runModeratorAndSchedule(db, def, threadId);
|
||||||
|
if (!r.ok) {
|
||||||
|
await setThreadResult(db, threadId, JSON.stringify({ error: r.error }), "failed");
|
||||||
|
return Response.json({ error: r.error }, { status: 500 });
|
||||||
|
}
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listMessages(url: URL, threadId: string): Promise<Response> {
|
||||||
|
const roleP = url.searchParams.get("role");
|
||||||
|
const minStepRaw = url.searchParams.get("minStep");
|
||||||
|
const maxStepRaw = url.searchParams.get("maxStep");
|
||||||
|
const limitRaw = url.searchParams.get("limit");
|
||||||
|
const toInt = (s: string | null): number | null => {
|
||||||
|
if (s === null) return null;
|
||||||
|
const n = Number.parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
const list = await getThreadMessages(this.env.DB, threadId, {
|
||||||
|
role: roleP,
|
||||||
|
minStep: toInt(minStepRaw),
|
||||||
|
maxStep: toInt(maxStepRaw),
|
||||||
|
limit: toInt(limitRaw),
|
||||||
|
});
|
||||||
|
return Response.json({ messages: list });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
const threadId = request.headers.get("X-Thread-Id");
|
||||||
|
if (threadId === null || threadId.length === 0) {
|
||||||
|
return Response.json({ error: "X-Thread-Id required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (path === "/start" && request.method === "POST") {
|
||||||
|
return this.startThread(threadId);
|
||||||
|
}
|
||||||
|
if (path === "/response" && request.method === "POST") {
|
||||||
|
const text = await request.text();
|
||||||
|
let body: Readonly<Record<string, unknown>> = {};
|
||||||
|
if (text.length > 0) {
|
||||||
|
const parsed: unknown = JSON.parse(text) as unknown;
|
||||||
|
body = asRecord(parsed);
|
||||||
|
}
|
||||||
|
return this.postResponse(threadId, body);
|
||||||
|
}
|
||||||
|
if (path === "/messages" && request.method === "GET") {
|
||||||
|
return this.listMessages(url, threadId);
|
||||||
|
}
|
||||||
|
return Response.json({ error: "not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetThreadMessagesOpts = {
|
||||||
|
role: string | null;
|
||||||
|
minStep: number | null;
|
||||||
|
maxStep: number | null;
|
||||||
|
limit: number | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type CloudRole = {
|
||||||
|
prompt: string;
|
||||||
|
/** Turn timeout in seconds. Defaults to 300. */
|
||||||
|
timeoutSeconds: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CloudWorkflowDef = {
|
||||||
|
name: string;
|
||||||
|
roles: Readonly<Record<string, CloudRole>>;
|
||||||
|
/** JSONata expression: context includes `steps` (role turn messages). */
|
||||||
|
moderator: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { type CloudWorkflowDef, registerWorkflow } from "../workflows.js";
|
||||||
|
|
||||||
|
const pingPong: CloudWorkflowDef = {
|
||||||
|
name: "ping-pong",
|
||||||
|
roles: {
|
||||||
|
pinger: { prompt: "Send the literal word ping", timeoutSeconds: null },
|
||||||
|
ponger: { prompt: "Send the literal word pong", timeoutSeconds: null },
|
||||||
|
},
|
||||||
|
moderator: `$count(steps) >= 6 ? { "role": "__end__" } : $count(steps) % 2 = 0 ? { "role": "pinger" } : { "role": "ponger" }`,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerWorkflow(pingPong);
|
||||||
|
|
||||||
|
export { pingPong };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "WebWorker"],
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["@cloudflare/workers-types", "./worker-configuration.d.ts"]
|
||||||
|
},
|
||||||
|
"include": ["src", "worker-configuration.d.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import type { D1Migration } from "@cloudflare/workers-types";
|
||||||
|
|
||||||
|
declare module "cloudflare:workers" {
|
||||||
|
type KhalaEnv = {
|
||||||
|
DB: D1Database;
|
||||||
|
THREAD: DurableObjectNamespace;
|
||||||
|
/** Used by Vitest: migrations to apply in setup (see d1-apply.ts). */
|
||||||
|
TEST_MIGRATIONS: D1Migration[] | undefined;
|
||||||
|
};
|
||||||
|
interface Env extends KhalaEnv {
|
||||||
|
/** Plain-text admin bearer value (set via [vars] or wrangler secret). */
|
||||||
|
ADMIN_SECRET: string;
|
||||||
|
}
|
||||||
|
interface ProvidedEnv extends Env {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name = "khala"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-04-01"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
ADMIN_SECRET = "dev-admin-replace-in-production"
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "khala"
|
||||||
|
database_id = "00000000-0000-0000-0000-000000000000"
|
||||||
|
migrations_dir = "migrations"
|
||||||
|
|
||||||
|
[[migrations]]
|
||||||
|
tag = "v1"
|
||||||
|
new_classes = [ "ThreadDO" ]
|
||||||
|
|
||||||
|
[durable_objects]
|
||||||
|
bindings = [
|
||||||
|
{ name = "THREAD", class_name = "ThreadDO" }
|
||||||
|
]
|
||||||
|
|
||||||
|
[triggers]
|
||||||
|
crons = [ "* * * * *" ]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# @uncaged/nerve-store
|
||||||
|
|
||||||
|
Persistent storage for the [nerve](../../README.md) daemon — append-only structured logs, optional JSONL cold archive, and content-addressable blobs.
|
||||||
|
|
||||||
|
## LogStore (`createLogStore`, `log-store.ts`)
|
||||||
|
|
||||||
|
- **Append-only log table** — rows with `source`, `type`, `refId`, `payload`, `ts` (string payloads for ad hoc fields)
|
||||||
|
- **SQLite WAL** — `DatabaseSync` from `node:sqlite`
|
||||||
|
- **Workflow run tracking** — materialized `workflow_runs` table plus helpers to list active runs, upsert status transitions, and read **thread messages** / **role rounds** for CLI and crash recovery
|
||||||
|
- **Meta key-value** — small `meta` table (e.g. archive watermarks)
|
||||||
|
|
||||||
|
Public exports include `LogStore`, `LogEntry`, `LogQuery`, `WorkflowRun`, `WorkflowRunStatus`, `ThreadRoundRow`, `GetThreadRoundsParams`, and archive-related types re-exported from `log-archive`.
|
||||||
|
|
||||||
|
## WorkflowRunStatus
|
||||||
|
|
||||||
|
Runs progress through a small state machine. Typical paths:
|
||||||
|
|
||||||
|
1. **`queued`** → **`started`** when a worker picks up the thread
|
||||||
|
2. **`started`** → **`completed`** | **`failed`** | **`crashed`** | **`interrupted`** | **`dropped`**
|
||||||
|
|
||||||
|
Semantics in the daemon/store layer:
|
||||||
|
|
||||||
|
- **`completed` / `failed`** — normal terminal outcomes from the workflow worker
|
||||||
|
- **`crashed`** — worker exited unexpectedly; manager may respawn and **`resume-thread`** eligible **`started`** runs
|
||||||
|
- **`interrupted`** — e.g. hot-reload drain killed an in-flight thread after timeout
|
||||||
|
- **`dropped`** — concurrency **`overflow: drop`** rejected a new run, or **`overflow: queue`** evicted an queued item when the queue was full
|
||||||
|
|
||||||
|
## LogArchive (`log-archive.ts`)
|
||||||
|
|
||||||
|
- **`archiveLogs`** / helpers — export eligible UTC days of old rows to **`data/archive/logs/YYYY-MM-DD.jsonl`**, delete archived rows from SQLite, optional **`VACUUM`**
|
||||||
|
- Used by **`nerve store archive`** in `@uncaged/nerve-cli`
|
||||||
|
|
||||||
|
## BlobStore (`createBlobStore`, `blob-store.ts`)
|
||||||
|
|
||||||
|
- **Content-addressable storage** — `write` returns lowercase **sha256** hex; files live under **`data/blobs/<2-hex>/<62-hex>`**
|
||||||
|
- **`read` / `exists`** — path must match digest on disk (tamper detection)
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @uncaged/nerve-store
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Node.js ≥ 22.5 (same as the rest of the stack).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/nerve-store",
|
"name": "@uncaged/nerve-store",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ describe("LogStore — cold archive (RFC-001 §5.4)", () => {
|
|||||||
|
|
||||||
it("exports one UTC day to JSONL, deletes rows, advances archived_up_to", () => {
|
it("exports one UTC day to JSONL, deletes rows, advances archived_up_to", () => {
|
||||||
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
||||||
store.append({ source: "system", type: "x", refId: null, payload: '{"a":1}', ts });
|
store.append({ source: "system", type: "x", refId: null, payload: '{"a":1}', timestamp: ts });
|
||||||
store.append({ source: "reflex", type: "y", refId: "z", payload: null, ts: ts + 1 });
|
store.append({ source: "reflex", type: "y", refId: "z", payload: null, timestamp: ts + 1 });
|
||||||
|
|
||||||
const now = nowForLastArchivableFeb1();
|
const now = nowForLastArchivableFeb1();
|
||||||
const result = store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
const result = store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
||||||
@@ -61,7 +61,7 @@ describe("LogStore — cold archive (RFC-001 §5.4)", () => {
|
|||||||
it("does nothing when all logs are inside the hot window", () => {
|
it("does nothing when all logs are inside the hot window", () => {
|
||||||
const now = Date.UTC(2026, 3, 23, 12, 0, 0);
|
const now = Date.UTC(2026, 3, 23, 12, 0, 0);
|
||||||
const ts = now - 5 * DAY_MS;
|
const ts = now - 5 * DAY_MS;
|
||||||
store.append({ source: "system", type: "warm", refId: null, payload: null, ts });
|
store.append({ source: "system", type: "warm", refId: null, payload: null, timestamp: ts });
|
||||||
const r = store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
const r = store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
||||||
expect(r.days).toHaveLength(0);
|
expect(r.days).toHaveLength(0);
|
||||||
expect(store.query()).toHaveLength(1);
|
expect(store.query()).toHaveLength(1);
|
||||||
@@ -69,7 +69,7 @@ describe("LogStore — cold archive (RFC-001 §5.4)", () => {
|
|||||||
|
|
||||||
it("second archive with same clock is a no-op (watermark already caught up)", () => {
|
it("second archive with same clock is a no-op (watermark already caught up)", () => {
|
||||||
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
||||||
store.append({ source: "system", type: "x", refId: null, payload: null, ts });
|
store.append({ source: "system", type: "x", refId: null, payload: null, timestamp: ts });
|
||||||
const now = nowForLastArchivableFeb1();
|
const now = nowForLastArchivableFeb1();
|
||||||
store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
||||||
const path = join(tmpDir, "data", "archive", "logs", "2026-02-01.jsonl");
|
const path = join(tmpDir, "data", "archive", "logs", "2026-02-01.jsonl");
|
||||||
@@ -82,11 +82,11 @@ describe("LogStore — cold archive (RFC-001 §5.4)", () => {
|
|||||||
|
|
||||||
it("overwrites JSONL when the same UTC day is archived again after watermark rewind", () => {
|
it("overwrites JSONL when the same UTC day is archived again after watermark rewind", () => {
|
||||||
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
||||||
store.append({ source: "a", type: "1", refId: null, payload: null, ts });
|
store.append({ source: "a", type: "1", refId: null, payload: null, timestamp: ts });
|
||||||
const now = nowForLastArchivableFeb1();
|
const now = nowForLastArchivableFeb1();
|
||||||
store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
||||||
store.setMeta(LOG_ARCHIVE_META_KEY, "2026-01-31");
|
store.setMeta(LOG_ARCHIVE_META_KEY, "2026-01-31");
|
||||||
store.append({ source: "b", type: "2", refId: null, payload: null, ts: ts + 100 });
|
store.append({ source: "b", type: "2", refId: null, payload: null, timestamp: ts + 100 });
|
||||||
store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
|
||||||
|
|
||||||
const path = join(tmpDir, "data", "archive", "logs", "2026-02-01.jsonl");
|
const path = join(tmpDir, "data", "archive", "logs", "2026-02-01.jsonl");
|
||||||
@@ -98,8 +98,8 @@ describe("LogStore — cold archive (RFC-001 §5.4)", () => {
|
|||||||
it("respects maxDays across invocations", () => {
|
it("respects maxDays across invocations", () => {
|
||||||
const t1 = Date.UTC(2026, 1, 1, 10, 0, 0);
|
const t1 = Date.UTC(2026, 1, 1, 10, 0, 0);
|
||||||
const t2 = Date.UTC(2026, 1, 2, 10, 0, 0);
|
const t2 = Date.UTC(2026, 1, 2, 10, 0, 0);
|
||||||
store.append({ source: "system", type: "a", refId: null, payload: null, ts: t1 });
|
store.append({ source: "system", type: "a", refId: null, payload: null, timestamp: t1 });
|
||||||
store.append({ source: "system", type: "b", refId: null, payload: null, ts: t2 });
|
store.append({ source: "system", type: "b", refId: null, payload: null, timestamp: t2 });
|
||||||
|
|
||||||
const now = Date.UTC(2027, 0, 1, 12, 0, 0);
|
const now = Date.UTC(2027, 0, 1, 12, 0, 0);
|
||||||
const r1 = store.archiveLogs({ now, retentionMs: 30 * DAY_MS, maxDays: 1 });
|
const r1 = store.archiveLogs({ now, retentionMs: 30 * DAY_MS, maxDays: 1 });
|
||||||
@@ -116,7 +116,7 @@ describe("LogStore — cold archive (RFC-001 §5.4)", () => {
|
|||||||
it("starts from earliest log day when it is before watermark+1", () => {
|
it("starts from earliest log day when it is before watermark+1", () => {
|
||||||
store.setMeta(LOG_ARCHIVE_META_KEY, "2026-01-10");
|
store.setMeta(LOG_ARCHIVE_META_KEY, "2026-01-10");
|
||||||
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
||||||
store.append({ source: "x", type: "p", refId: null, payload: null, ts });
|
store.append({ source: "x", type: "p", refId: null, payload: null, timestamp: ts });
|
||||||
const result = store.archiveLogs({ now: nowForLastArchivableFeb1(), retentionMs: 30 * DAY_MS });
|
const result = store.archiveLogs({ now: nowForLastArchivableFeb1(), retentionMs: 30 * DAY_MS });
|
||||||
expect(result.days.map((d) => d.day)).toContain("2026-02-01");
|
expect(result.days.map((d) => d.day)).toContain("2026-02-01");
|
||||||
});
|
});
|
||||||
@@ -128,7 +128,7 @@ describe("LogStore — cold archive (RFC-001 §5.4)", () => {
|
|||||||
|
|
||||||
it("runs VACUUM when vacuum: true", () => {
|
it("runs VACUUM when vacuum: true", () => {
|
||||||
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
const ts = Date.UTC(2026, 1, 1, 10, 0, 0);
|
||||||
store.append({ source: "system", type: "x", refId: null, payload: null, ts });
|
store.append({ source: "system", type: "x", refId: null, payload: null, timestamp: ts });
|
||||||
const r = store.archiveLogs({
|
const r = store.archiveLogs({
|
||||||
now: nowForLastArchivableFeb1(),
|
now: nowForLastArchivableFeb1(),
|
||||||
retentionMs: 30 * DAY_MS,
|
retentionMs: 30 * DAY_MS,
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
type: "started",
|
type: "started",
|
||||||
refId: "run-1",
|
refId: "run-1",
|
||||||
payload: JSON.stringify({ triggerPayload: payload }),
|
payload: JSON.stringify({ triggerPayload: payload }),
|
||||||
ts: 1000,
|
timestamp: 1000,
|
||||||
},
|
},
|
||||||
{ runId: "run-1", workflow: "my-wf", status: "started", ts: 1000 },
|
{ runId: "run-1", workflow: "my-wf", status: "started", timestamp: 1000, exitCode: null },
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = store.getTriggerPayload("run-1");
|
const result = store.getTriggerPayload("run-1");
|
||||||
@@ -55,9 +55,9 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
type: "started",
|
type: "started",
|
||||||
refId: "run-2",
|
refId: "run-2",
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: 1000,
|
timestamp: 1000,
|
||||||
},
|
},
|
||||||
{ runId: "run-2", workflow: "my-wf", status: "started", ts: 1000 },
|
{ runId: "run-2", workflow: "my-wf", status: "started", timestamp: 1000, exitCode: null },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(store.getTriggerPayload("run-2")).toBeNull();
|
expect(store.getTriggerPayload("run-2")).toBeNull();
|
||||||
@@ -72,14 +72,14 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
type: "started",
|
type: "started",
|
||||||
refId: "run-3",
|
refId: "run-3",
|
||||||
payload: JSON.stringify({ triggerPayload: payloadA }),
|
payload: JSON.stringify({ triggerPayload: payloadA }),
|
||||||
ts: 100,
|
timestamp: 100,
|
||||||
});
|
});
|
||||||
store.append({
|
store.append({
|
||||||
source: "workflow",
|
source: "workflow",
|
||||||
type: "started",
|
type: "started",
|
||||||
refId: "run-3",
|
refId: "run-3",
|
||||||
payload: JSON.stringify({ triggerPayload: payloadB }),
|
payload: JSON.stringify({ triggerPayload: payloadB }),
|
||||||
ts: 200,
|
timestamp: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = store.getTriggerPayload("run-3");
|
const result = store.getTriggerPayload("run-3");
|
||||||
@@ -106,7 +106,7 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
type: "thread_command_event",
|
type: "thread_command_event",
|
||||||
refId: "run-4",
|
refId: "run-4",
|
||||||
payload: JSON.stringify(event),
|
payload: JSON.stringify(event),
|
||||||
ts: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,14 +123,14 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
type: "thread_command_event",
|
type: "thread_command_event",
|
||||||
refId: "run-5",
|
refId: "run-5",
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: 1000,
|
timestamp: 1000,
|
||||||
});
|
});
|
||||||
store.append({
|
store.append({
|
||||||
source: "workflow",
|
source: "workflow",
|
||||||
type: "thread_command_event",
|
type: "thread_command_event",
|
||||||
refId: "run-5",
|
refId: "run-5",
|
||||||
payload: JSON.stringify({ type: "valid_event" }),
|
payload: JSON.stringify({ type: "valid_event" }),
|
||||||
ts: 1001,
|
timestamp: 1001,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = store.getThreadEvents("run-5");
|
const result = store.getThreadEvents("run-5");
|
||||||
@@ -146,23 +146,23 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
type: "started",
|
type: "started",
|
||||||
refId: "run-6",
|
refId: "run-6",
|
||||||
payload: JSON.stringify({ triggerPayload: {} }),
|
payload: JSON.stringify({ triggerPayload: {} }),
|
||||||
ts: 1000,
|
timestamp: 1000,
|
||||||
},
|
},
|
||||||
{ runId: "run-6", workflow: "my-wf", status: "started", ts: 1000 },
|
{ runId: "run-6", workflow: "my-wf", status: "started", timestamp: 1000, exitCode: null },
|
||||||
);
|
);
|
||||||
store.append({
|
store.append({
|
||||||
source: "workflow",
|
source: "workflow",
|
||||||
type: "thread_command_event",
|
type: "thread_command_event",
|
||||||
refId: "run-6",
|
refId: "run-6",
|
||||||
payload: JSON.stringify({ type: "step_one" }),
|
payload: JSON.stringify({ type: "step_one" }),
|
||||||
ts: 1001,
|
timestamp: 1001,
|
||||||
});
|
});
|
||||||
store.append({
|
store.append({
|
||||||
source: "workflow",
|
source: "workflow",
|
||||||
type: "step_complete",
|
type: "step_complete",
|
||||||
refId: "run-6",
|
refId: "run-6",
|
||||||
payload: JSON.stringify({ message: "done step" }),
|
payload: JSON.stringify({ message: "done step" }),
|
||||||
ts: 1002,
|
timestamp: 1002,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = store.getThreadEvents("run-6");
|
const result = store.getThreadEvents("run-6");
|
||||||
@@ -176,14 +176,14 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
type: "thread_command_event",
|
type: "thread_command_event",
|
||||||
refId: "run-7",
|
refId: "run-7",
|
||||||
payload: JSON.stringify({ type: "event_for_7" }),
|
payload: JSON.stringify({ type: "event_for_7" }),
|
||||||
ts: 1000,
|
timestamp: 1000,
|
||||||
});
|
});
|
||||||
store.append({
|
store.append({
|
||||||
source: "workflow",
|
source: "workflow",
|
||||||
type: "thread_command_event",
|
type: "thread_command_event",
|
||||||
refId: "run-8",
|
refId: "run-8",
|
||||||
payload: JSON.stringify({ type: "event_for_8" }),
|
payload: JSON.stringify({ type: "event_for_8" }),
|
||||||
ts: 1001,
|
timestamp: 1001,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result7 = store.getThreadEvents("run-7");
|
const result7 = store.getThreadEvents("run-7");
|
||||||
@@ -203,7 +203,7 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
type: "thread_command_event",
|
type: "thread_command_event",
|
||||||
refId: "run-tr",
|
refId: "run-tr",
|
||||||
payload: JSON.stringify({ type: "thread_start", triggerPayload: { x: 1 } }),
|
payload: JSON.stringify({ type: "thread_start", triggerPayload: { x: 1 } }),
|
||||||
ts: 100,
|
timestamp: 100,
|
||||||
});
|
});
|
||||||
store.append({
|
store.append({
|
||||||
source: "workflow",
|
source: "workflow",
|
||||||
@@ -215,14 +215,14 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
content: "hello",
|
content: "hello",
|
||||||
meta: 1,
|
meta: 1,
|
||||||
}),
|
}),
|
||||||
ts: 101,
|
timestamp: 101,
|
||||||
});
|
});
|
||||||
store.append({
|
store.append({
|
||||||
source: "workflow",
|
source: "workflow",
|
||||||
type: "thread_command_event",
|
type: "thread_command_event",
|
||||||
refId: "run-tr",
|
refId: "run-tr",
|
||||||
payload: JSON.stringify({ type: "step_b", role: "beta", content: "world" }),
|
payload: JSON.stringify({ type: "step_b", role: "beta", content: "world" }),
|
||||||
ts: 102,
|
timestamp: 102,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.getThreadRoundCount("run-tr")).toBe(2);
|
expect(store.getThreadRoundCount("run-tr")).toBe(2);
|
||||||
@@ -241,7 +241,7 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
|||||||
type: "thread_command_event",
|
type: "thread_command_event",
|
||||||
refId: "run-b4",
|
refId: "run-b4",
|
||||||
payload: JSON.stringify({ type: `ev_${i}`, role: "r", content: String(i) }),
|
payload: JSON.stringify({ type: `ev_${i}`, role: "r", content: String(i) }),
|
||||||
ts: 200 + i,
|
timestamp: 200 + i,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ describe("LogStore — workflow_runs", () => {
|
|||||||
runId: "run-1",
|
runId: "run-1",
|
||||||
workflow: "cleanup",
|
workflow: "cleanup",
|
||||||
status: "started",
|
status: "started",
|
||||||
ts: 1000,
|
timestamp: 1000,
|
||||||
|
exitCode: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const entry = store.upsertWorkflowRun(
|
const entry = store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: "started", refId: "run-1", payload: null, ts: 1000 },
|
{ source: "workflow", type: "started", refId: "run-1", payload: null, timestamp: 1000 },
|
||||||
run,
|
run,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -47,23 +48,29 @@ describe("LogStore — workflow_runs", () => {
|
|||||||
expect(stored?.runId).toBe("run-1");
|
expect(stored?.runId).toBe("run-1");
|
||||||
expect(stored?.workflow).toBe("cleanup");
|
expect(stored?.workflow).toBe("cleanup");
|
||||||
expect(stored?.status).toBe("started");
|
expect(stored?.status).toBe("started");
|
||||||
expect(stored?.ts).toBe(1000);
|
expect(stored?.timestamp).toBe(1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates existing workflow_runs row on upsert (status transition)", () => {
|
it("updates existing workflow_runs row on upsert (status transition)", () => {
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: "started", refId: "run-2", payload: null, ts: 1000 },
|
{ source: "workflow", type: "started", refId: "run-2", payload: null, timestamp: 1000 },
|
||||||
{ runId: "run-2", workflow: "cleanup", status: "started", ts: 1000 },
|
{ runId: "run-2", workflow: "cleanup", status: "started", timestamp: 1000, exitCode: null },
|
||||||
);
|
);
|
||||||
|
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: "completed", refId: "run-2", payload: null, ts: 2000 },
|
{ source: "workflow", type: "completed", refId: "run-2", payload: null, timestamp: 2000 },
|
||||||
{ runId: "run-2", workflow: "cleanup", status: "completed", ts: 2000 },
|
{
|
||||||
|
runId: "run-2",
|
||||||
|
workflow: "cleanup",
|
||||||
|
status: "completed",
|
||||||
|
timestamp: 2000,
|
||||||
|
exitCode: null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const stored = store.getWorkflowRun("run-2");
|
const stored = store.getWorkflowRun("run-2");
|
||||||
expect(stored?.status).toBe("completed");
|
expect(stored?.status).toBe("completed");
|
||||||
expect(stored?.ts).toBe(2000);
|
expect(stored?.timestamp).toBe(2000);
|
||||||
|
|
||||||
// Both log entries should be present (event sourcing)
|
// Both log entries should be present (event sourcing)
|
||||||
const logs = store.query({ refId: "run-2" });
|
const logs = store.query({ refId: "run-2" });
|
||||||
@@ -71,15 +78,15 @@ describe("LogStore — workflow_runs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("the log entries act as source of truth for event history", () => {
|
it("the log entries act as source of truth for event history", () => {
|
||||||
for (const [type, status, ts] of [
|
for (const [type, status, timestamp] of [
|
||||||
["queued", "queued", 1000],
|
["queued", "queued", 1000],
|
||||||
["started", "started", 1001],
|
["started", "started", 1001],
|
||||||
["step_complete", "started", 1002],
|
["step_complete", "started", 1002],
|
||||||
["completed", "completed", 1005],
|
["completed", "completed", 1005],
|
||||||
] as const) {
|
] as const) {
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type, refId: "run-3", payload: null, ts },
|
{ source: "workflow", type, refId: "run-3", payload: null, timestamp },
|
||||||
{ runId: "run-3", workflow: "cleanup", status, ts },
|
{ runId: "run-3", workflow: "cleanup", status, timestamp, exitCode: null },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,37 +104,49 @@ describe("LogStore — workflow_runs", () => {
|
|||||||
|
|
||||||
it("returns the latest state after multiple upserts", () => {
|
it("returns the latest state after multiple upserts", () => {
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: "queued", refId: "run-4", payload: null, ts: 100 },
|
{ source: "workflow", type: "queued", refId: "run-4", payload: null, timestamp: 100 },
|
||||||
{ runId: "run-4", workflow: "code-review", status: "queued", ts: 100 },
|
{
|
||||||
|
runId: "run-4",
|
||||||
|
workflow: "code-review",
|
||||||
|
status: "queued",
|
||||||
|
timestamp: 100,
|
||||||
|
exitCode: null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: "started", refId: "run-4", payload: null, ts: 200 },
|
{ source: "workflow", type: "started", refId: "run-4", payload: null, timestamp: 200 },
|
||||||
{ runId: "run-4", workflow: "code-review", status: "started", ts: 200 },
|
{
|
||||||
|
runId: "run-4",
|
||||||
|
workflow: "code-review",
|
||||||
|
status: "started",
|
||||||
|
timestamp: 200,
|
||||||
|
exitCode: null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const run = store.getWorkflowRun("run-4");
|
const run = store.getWorkflowRun("run-4");
|
||||||
expect(run?.status).toBe("started");
|
expect(run?.status).toBe("started");
|
||||||
expect(run?.ts).toBe(200);
|
expect(run?.timestamp).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getActiveWorkflowRuns", () => {
|
describe("getActiveWorkflowRuns", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: "queued", refId: "r1", payload: null, ts: 100 },
|
{ source: "workflow", type: "queued", refId: "r1", payload: null, timestamp: 100 },
|
||||||
{ runId: "r1", workflow: "cleanup", status: "queued", ts: 100 },
|
{ runId: "r1", workflow: "cleanup", status: "queued", timestamp: 100, exitCode: null },
|
||||||
);
|
);
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: "started", refId: "r2", payload: null, ts: 200 },
|
{ source: "workflow", type: "started", refId: "r2", payload: null, timestamp: 200 },
|
||||||
{ runId: "r2", workflow: "cleanup", status: "started", ts: 200 },
|
{ runId: "r2", workflow: "cleanup", status: "started", timestamp: 200, exitCode: null },
|
||||||
);
|
);
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: "completed", refId: "r3", payload: null, ts: 300 },
|
{ source: "workflow", type: "completed", refId: "r3", payload: null, timestamp: 300 },
|
||||||
{ runId: "r3", workflow: "cleanup", status: "completed", ts: 300 },
|
{ runId: "r3", workflow: "cleanup", status: "completed", timestamp: 300, exitCode: null },
|
||||||
);
|
);
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: "failed", refId: "r4", payload: null, ts: 400 },
|
{ source: "workflow", type: "failed", refId: "r4", payload: null, timestamp: 400 },
|
||||||
{ runId: "r4", workflow: "deploy", status: "queued", ts: 400 },
|
{ runId: "r4", workflow: "deploy", status: "queued", timestamp: 400, exitCode: null },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,20 +183,20 @@ describe("LogStore — workflow_runs", () => {
|
|||||||
expect(store.getActiveWorkflowRuns("nonexistent")).toHaveLength(0);
|
expect(store.getActiveWorkflowRuns("nonexistent")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns runs ordered by ts ascending", () => {
|
it("returns runs ordered by timestamp ascending", () => {
|
||||||
const active = store.getActiveWorkflowRuns();
|
const active = store.getActiveWorkflowRuns();
|
||||||
expect(active[0].ts).toBeLessThan(active[1].ts);
|
expect(active[0].timestamp).toBeLessThan(active[1].timestamp);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("all statuses are storable", () => {
|
describe("all statuses are storable", () => {
|
||||||
it.each(["queued", "started", "completed", "failed", "crashed", "dropped"] as const)(
|
it.each(["queued", "started", "completed", "failed", "crashed", "dropped", "killed"] as const)(
|
||||||
"stores status=%s",
|
"stores status=%s",
|
||||||
(status) => {
|
(status) => {
|
||||||
const runId = `run-${status}`;
|
const runId = `run-${status}`;
|
||||||
store.upsertWorkflowRun(
|
store.upsertWorkflowRun(
|
||||||
{ source: "workflow", type: status, refId: runId, payload: null, ts: 1 },
|
{ source: "workflow", type: status, refId: runId, payload: null, timestamp: 1 },
|
||||||
{ runId, workflow: "test", status, ts: 1 },
|
{ runId, workflow: "test", status, timestamp: 1, exitCode: null },
|
||||||
);
|
);
|
||||||
expect(store.getWorkflowRun(runId)?.status).toBe(status);
|
expect(store.getWorkflowRun(runId)?.status).toBe(status);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("LogStore", () => {
|
|||||||
type: "start",
|
type: "start",
|
||||||
refId: null,
|
refId: null,
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: 1000,
|
timestamp: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(entry.id).toBe(1);
|
expect(entry.id).toBe(1);
|
||||||
@@ -41,28 +41,40 @@ describe("LogStore", () => {
|
|||||||
type: "start",
|
type: "start",
|
||||||
refId: null,
|
refId: null,
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: 1000,
|
timestamp: 1000,
|
||||||
});
|
});
|
||||||
const e2 = store.append({
|
const e2 = store.append({
|
||||||
source: "system",
|
source: "system",
|
||||||
type: "stop",
|
type: "stop",
|
||||||
refId: null,
|
refId: null,
|
||||||
payload: null,
|
payload: null,
|
||||||
ts: 2000,
|
timestamp: 2000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(e2.id).toBe((e1.id ?? 0) + 1);
|
expect(e2.id).toBe((e1.id ?? 0) + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns all entries when queried with no filter", () => {
|
it("returns all entries when queried with no filter", () => {
|
||||||
store.append({ source: "system", type: "start", refId: null, payload: null, ts: 1000 });
|
store.append({
|
||||||
store.append({ source: "reflex", type: "run_start", refId: "cpu", payload: null, ts: 2000 });
|
source: "system",
|
||||||
|
type: "start",
|
||||||
|
refId: null,
|
||||||
|
payload: null,
|
||||||
|
timestamp: 1000,
|
||||||
|
});
|
||||||
|
store.append({
|
||||||
|
source: "reflex",
|
||||||
|
type: "run_start",
|
||||||
|
refId: "cpu",
|
||||||
|
payload: null,
|
||||||
|
timestamp: 2000,
|
||||||
|
});
|
||||||
store.append({
|
store.append({
|
||||||
source: "reflex",
|
source: "reflex",
|
||||||
type: "run_complete",
|
type: "run_complete",
|
||||||
refId: "cpu",
|
refId: "cpu",
|
||||||
payload: '{"v":42}',
|
payload: '{"v":42}',
|
||||||
ts: 3000,
|
timestamp: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const all = store.query();
|
const all = store.query();
|
||||||
@@ -72,23 +84,35 @@ describe("LogStore", () => {
|
|||||||
|
|
||||||
describe("query filters", () => {
|
describe("query filters", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store.append({ source: "system", type: "start", refId: null, payload: null, ts: 1000 });
|
store.append({
|
||||||
store.append({ source: "reflex", type: "run_start", refId: "cpu", payload: null, ts: 2000 });
|
source: "system",
|
||||||
|
type: "start",
|
||||||
|
refId: null,
|
||||||
|
payload: null,
|
||||||
|
timestamp: 1000,
|
||||||
|
});
|
||||||
|
store.append({
|
||||||
|
source: "reflex",
|
||||||
|
type: "run_start",
|
||||||
|
refId: "cpu",
|
||||||
|
payload: null,
|
||||||
|
timestamp: 2000,
|
||||||
|
});
|
||||||
store.append({
|
store.append({
|
||||||
source: "reflex",
|
source: "reflex",
|
||||||
type: "run_complete",
|
type: "run_complete",
|
||||||
refId: "cpu",
|
refId: "cpu",
|
||||||
payload: '{"v":42}',
|
payload: '{"v":42}',
|
||||||
ts: 3000,
|
timestamp: 3000,
|
||||||
});
|
});
|
||||||
store.append({
|
store.append({
|
||||||
source: "system",
|
source: "system",
|
||||||
type: "error",
|
type: "error",
|
||||||
refId: "disk",
|
refId: "disk",
|
||||||
payload: '{"error":"fail"}',
|
payload: '{"error":"fail"}',
|
||||||
ts: 4000,
|
timestamp: 4000,
|
||||||
});
|
});
|
||||||
store.append({ source: "system", type: "stop", refId: null, payload: null, ts: 5000 });
|
store.append({ source: "system", type: "stop", refId: null, payload: null, timestamp: 5000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters by source", () => {
|
it("filters by source", () => {
|
||||||
@@ -111,7 +135,7 @@ describe("LogStore", () => {
|
|||||||
it("filters by since (inclusive)", () => {
|
it("filters by since (inclusive)", () => {
|
||||||
const results = store.query({ since: 3000 });
|
const results = store.query({ since: 3000 });
|
||||||
expect(results).toHaveLength(3);
|
expect(results).toHaveLength(3);
|
||||||
expect(results[0].ts).toBe(3000);
|
expect(results[0].timestamp).toBe(3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters by until (inclusive)", () => {
|
it("filters by until (inclusive)", () => {
|
||||||
@@ -146,12 +170,24 @@ describe("LogStore", () => {
|
|||||||
|
|
||||||
describe("query ordering", () => {
|
describe("query ordering", () => {
|
||||||
it("returns entries in insertion order (ascending id)", () => {
|
it("returns entries in insertion order (ascending id)", () => {
|
||||||
store.append({ source: "system", type: "start", refId: null, payload: null, ts: 5000 });
|
store.append({
|
||||||
store.append({ source: "reflex", type: "run_start", refId: "a", payload: null, ts: 1000 });
|
source: "system",
|
||||||
|
type: "start",
|
||||||
|
refId: null,
|
||||||
|
payload: null,
|
||||||
|
timestamp: 5000,
|
||||||
|
});
|
||||||
|
store.append({
|
||||||
|
source: "reflex",
|
||||||
|
type: "run_start",
|
||||||
|
refId: "a",
|
||||||
|
payload: null,
|
||||||
|
timestamp: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const all = store.query();
|
const all = store.query();
|
||||||
expect(all[0].ts).toBe(5000);
|
expect(all[0].timestamp).toBe(5000);
|
||||||
expect(all[1].ts).toBe(1000);
|
expect(all[1].timestamp).toBe(1000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,7 +218,7 @@ describe("LogStore", () => {
|
|||||||
describe("append-only semantics", () => {
|
describe("append-only semantics", () => {
|
||||||
it("ids are always increasing", () => {
|
it("ids are always increasing", () => {
|
||||||
const entries = Array.from({ length: 10 }, (_, i) =>
|
const entries = Array.from({ length: 10 }, (_, i) =>
|
||||||
store.append({ source: "system", type: "test", refId: null, payload: null, ts: i }),
|
store.append({ source: "system", type: "test", refId: null, payload: null, timestamp: i }),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 1; i < entries.length; i++) {
|
for (let i = 1; i < entries.length; i++) {
|
||||||
@@ -194,7 +230,13 @@ describe("LogStore", () => {
|
|||||||
describe("payload JSON round-trip", () => {
|
describe("payload JSON round-trip", () => {
|
||||||
it("preserves JSON payload", () => {
|
it("preserves JSON payload", () => {
|
||||||
const payload = JSON.stringify({ cpu: 95, host: "node-1" });
|
const payload = JSON.stringify({ cpu: 95, host: "node-1" });
|
||||||
store.append({ source: "reflex", type: "run_complete", refId: "cpu", payload, ts: 1000 });
|
store.append({
|
||||||
|
source: "reflex",
|
||||||
|
type: "run_complete",
|
||||||
|
refId: "cpu",
|
||||||
|
payload,
|
||||||
|
timestamp: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
const results = store.query({ refId: "cpu" });
|
const results = store.query({ refId: "cpu" });
|
||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1);
|
||||||
@@ -206,7 +248,13 @@ describe("LogStore", () => {
|
|||||||
it("creates nested directory structure for db path", () => {
|
it("creates nested directory structure for db path", () => {
|
||||||
const deepPath = join(tmpDir, "a", "b", "c", "test.db");
|
const deepPath = join(tmpDir, "a", "b", "c", "test.db");
|
||||||
const deepStore = createLogStore(deepPath);
|
const deepStore = createLogStore(deepPath);
|
||||||
deepStore.append({ source: "system", type: "start", refId: null, payload: null, ts: 1000 });
|
deepStore.append({
|
||||||
|
source: "system",
|
||||||
|
type: "start",
|
||||||
|
refId: null,
|
||||||
|
payload: null,
|
||||||
|
timestamp: 1000,
|
||||||
|
});
|
||||||
expect(deepStore.query()).toHaveLength(1);
|
expect(deepStore.query()).toHaveLength(1);
|
||||||
deepStore.close();
|
deepStore.close();
|
||||||
});
|
});
|
||||||
|
|||||||
+152
-105
@@ -11,6 +11,8 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
||||||
|
|
||||||
|
import { isPlainRecord } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOG_RETENTION_MS,
|
DEFAULT_LOG_RETENTION_MS,
|
||||||
LOG_ARCHIVE_META_KEY,
|
LOG_ARCHIVE_META_KEY,
|
||||||
@@ -33,7 +35,7 @@ export type LogEntry = {
|
|||||||
type: string;
|
type: string;
|
||||||
refId: string | null;
|
refId: string | null;
|
||||||
payload: string | null;
|
payload: string | null;
|
||||||
ts: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LogQuery = {
|
export type LogQuery = {
|
||||||
@@ -56,7 +58,8 @@ export type WorkflowRunStatus =
|
|||||||
| "failed"
|
| "failed"
|
||||||
| "crashed"
|
| "crashed"
|
||||||
| "dropped"
|
| "dropped"
|
||||||
| "interrupted";
|
| "interrupted"
|
||||||
|
| "killed";
|
||||||
|
|
||||||
const VALID_WORKFLOW_STATUSES = new Set<string>([
|
const VALID_WORKFLOW_STATUSES = new Set<string>([
|
||||||
"queued",
|
"queued",
|
||||||
@@ -66,13 +69,18 @@ const VALID_WORKFLOW_STATUSES = new Set<string>([
|
|||||||
"crashed",
|
"crashed",
|
||||||
"dropped",
|
"dropped",
|
||||||
"interrupted",
|
"interrupted",
|
||||||
|
"killed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function isWorkflowRunStatus(value: string): value is WorkflowRunStatus {
|
||||||
|
return VALID_WORKFLOW_STATUSES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
function validateWorkflowRunStatus(status: string): WorkflowRunStatus {
|
function validateWorkflowRunStatus(status: string): WorkflowRunStatus {
|
||||||
if (!VALID_WORKFLOW_STATUSES.has(status)) {
|
if (!isWorkflowRunStatus(status)) {
|
||||||
throw new Error(`Invalid workflow run status from DB: "${status}"`);
|
throw new Error(`Invalid workflow run status from DB: "${status}"`);
|
||||||
}
|
}
|
||||||
return status as WorkflowRunStatus;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row in the workflow_runs materialized table. */
|
/** One row in the workflow_runs materialized table. */
|
||||||
@@ -80,14 +88,15 @@ export type WorkflowRun = {
|
|||||||
runId: string;
|
runId: string;
|
||||||
workflow: string;
|
workflow: string;
|
||||||
status: WorkflowRunStatus;
|
status: WorkflowRunStatus;
|
||||||
ts: number;
|
timestamp: number;
|
||||||
|
exitCode: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One role-produced workflow-message row with 1-based round index (ROW_NUMBER over role messages only). */
|
/** One role-produced workflow-message row with 1-based round index (ROW_NUMBER over role messages only). */
|
||||||
export type ThreadRoundRow = {
|
export type ThreadRoundRow = {
|
||||||
round: number;
|
round: number;
|
||||||
logId: number;
|
logId: number;
|
||||||
ts: number;
|
timestamp: number;
|
||||||
message: { role: string; content: string; meta: unknown; timestamp: number };
|
message: { role: string; content: string; meta: unknown; timestamp: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +134,7 @@ export type LogStore = {
|
|||||||
*/
|
*/
|
||||||
getActiveWorkflowRuns: (workflowName?: string) => WorkflowRun[];
|
getActiveWorkflowRuns: (workflowName?: string) => WorkflowRun[];
|
||||||
/**
|
/**
|
||||||
* Get all workflow runs regardless of status, sorted by ts descending.
|
* Get all workflow runs regardless of status, sorted by timestamp descending.
|
||||||
* Optionally filter by workflow name.
|
* Optionally filter by workflow name.
|
||||||
*/
|
*/
|
||||||
getAllWorkflowRuns: (workflowName: string | null) => WorkflowRun[];
|
getAllWorkflowRuns: (workflowName: string | null) => WorkflowRun[];
|
||||||
@@ -168,16 +177,16 @@ export type LogStore = {
|
|||||||
|
|
||||||
const SCHEMA_SQL = `
|
const SCHEMA_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS logs (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source TEXT NOT NULL,
|
source TEXT NOT NULL,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
ref_id TEXT,
|
ref_id TEXT,
|
||||||
payload TEXT,
|
payload TEXT,
|
||||||
ts INTEGER NOT NULL
|
timestamp INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_logs_source_type ON logs(source, type);
|
CREATE INDEX IF NOT EXISTS idx_logs_source_type ON logs(source, type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts);
|
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp);
|
||||||
CREATE INDEX IF NOT EXISTS idx_logs_ref_id ON logs(ref_id);
|
CREATE INDEX IF NOT EXISTS idx_logs_ref_id ON logs(ref_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS meta (
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
@@ -186,10 +195,11 @@ CREATE TABLE IF NOT EXISTS meta (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS workflow_runs (
|
CREATE TABLE IF NOT EXISTS workflow_runs (
|
||||||
run_id TEXT PRIMARY KEY,
|
run_id TEXT PRIMARY KEY,
|
||||||
workflow TEXT NOT NULL,
|
workflow TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
ts INTEGER NOT NULL
|
timestamp INTEGER NOT NULL,
|
||||||
|
exit_code INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
||||||
@@ -202,7 +212,7 @@ type SqlLogRow = {
|
|||||||
type: string;
|
type: string;
|
||||||
ref_id: string | null;
|
ref_id: string | null;
|
||||||
payload: string | null;
|
payload: string | null;
|
||||||
ts: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildJsonlBody(rows: SqlLogRow[]): string {
|
function buildJsonlBody(rows: SqlLogRow[]): string {
|
||||||
@@ -214,7 +224,7 @@ function buildJsonlBody(rows: SqlLogRow[]): string {
|
|||||||
type: r.type,
|
type: r.type,
|
||||||
refId: r.ref_id,
|
refId: r.ref_id,
|
||||||
payload: r.payload,
|
payload: r.payload,
|
||||||
ts: r.ts,
|
timestamp: r.timestamp,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return `${lines.join("\n")}\n`;
|
return `${lines.join("\n")}\n`;
|
||||||
@@ -236,6 +246,41 @@ function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function launchShapeFromRecord(rec: Record<string, unknown>): {
|
||||||
|
prompt: string;
|
||||||
|
maxRounds: number;
|
||||||
|
dryRun: boolean;
|
||||||
|
} | null {
|
||||||
|
if (typeof rec.prompt !== "string" || typeof rec.maxRounds !== "number") return null;
|
||||||
|
return {
|
||||||
|
prompt: rec.prompt,
|
||||||
|
maxRounds: rec.maxRounds,
|
||||||
|
dryRun: typeof rec.dryRun === "boolean" ? rec.dryRun : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse JSON from a workflow `started` log row into a trigger / launch payload for crash recovery. */
|
||||||
|
function triggerPayloadFromStartedLogJson(payload: string): unknown | null {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isPlainRecord(parsed)) return null;
|
||||||
|
|
||||||
|
const direct = launchShapeFromRecord(parsed);
|
||||||
|
if (direct !== null) return direct;
|
||||||
|
|
||||||
|
const inner = parsed.triggerPayload;
|
||||||
|
if (inner !== null && isPlainRecord(inner)) {
|
||||||
|
const fromInner = launchShapeFromRecord(inner);
|
||||||
|
if (fromInner !== null) return fromInner;
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function runOptionalVacuum(sqlite: DatabaseSync, vacuum?: boolean): boolean {
|
function runOptionalVacuum(sqlite: DatabaseSync, vacuum?: boolean): boolean {
|
||||||
if (vacuum !== true) return false;
|
if (vacuum !== true) return false;
|
||||||
sqlite.exec("VACUUM");
|
sqlite.exec("VACUUM");
|
||||||
@@ -291,8 +336,15 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
sqlite.exec("PRAGMA journal_mode=WAL");
|
sqlite.exec("PRAGMA journal_mode=WAL");
|
||||||
sqlite.exec(SCHEMA_SQL);
|
sqlite.exec(SCHEMA_SQL);
|
||||||
|
|
||||||
|
// Migration: add exit_code column for existing databases
|
||||||
|
try {
|
||||||
|
sqlite.exec("ALTER TABLE workflow_runs ADD COLUMN exit_code INTEGER");
|
||||||
|
} catch {
|
||||||
|
// Column already exists — safe to ignore
|
||||||
|
}
|
||||||
|
|
||||||
const insertStmt = sqlite.prepare(
|
const insertStmt = sqlite.prepare(
|
||||||
"INSERT INTO logs (source, type, ref_id, payload, ts) VALUES (@source, @type, @refId, @payload, @ts)",
|
"INSERT INTO logs (source, type, ref_id, payload, timestamp) VALUES (@source, @type, @refId, @payload, @timestamp)",
|
||||||
);
|
);
|
||||||
|
|
||||||
const getMetaStmt = sqlite.prepare("SELECT value FROM meta WHERE key = ?");
|
const getMetaStmt = sqlite.prepare("SELECT value FROM meta WHERE key = ?");
|
||||||
@@ -301,11 +353,11 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const upsertWorkflowRunStmt = sqlite.prepare(
|
const upsertWorkflowRunStmt = sqlite.prepare(
|
||||||
"INSERT OR REPLACE INTO workflow_runs (run_id, workflow, status, ts) VALUES (@runId, @workflow, @status, @ts)",
|
"INSERT OR REPLACE INTO workflow_runs (run_id, workflow, status, timestamp, exit_code) VALUES (@runId, @workflow, @status, @timestamp, @exitCode)",
|
||||||
);
|
);
|
||||||
|
|
||||||
const getWorkflowRunStmt = sqlite.prepare(
|
const getWorkflowRunStmt = sqlite.prepare(
|
||||||
"SELECT run_id, workflow, status, ts FROM workflow_runs WHERE run_id = ?",
|
"SELECT run_id, workflow, status, timestamp, exit_code FROM workflow_runs WHERE run_id = ?",
|
||||||
);
|
);
|
||||||
|
|
||||||
const getTriggerPayloadStmt = sqlite.prepare(
|
const getTriggerPayloadStmt = sqlite.prepare(
|
||||||
@@ -330,7 +382,7 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
|
|
||||||
const getThreadRoundsStmt = sqlite.prepare(
|
const getThreadRoundsStmt = sqlite.prepare(
|
||||||
`WITH numbered AS (
|
`WITH numbered AS (
|
||||||
SELECT id, ts, payload,
|
SELECT id, timestamp, payload,
|
||||||
ROW_NUMBER() OVER (ORDER BY id ASC) AS rn
|
ROW_NUMBER() OVER (ORDER BY id ASC) AS rn
|
||||||
FROM logs
|
FROM logs
|
||||||
WHERE source = 'workflow' AND type IN ('thread_command_event', 'thread_workflow_message') AND ref_id = @runId
|
WHERE source = 'workflow' AND type IN ('thread_command_event', 'thread_workflow_message') AND ref_id = @runId
|
||||||
@@ -338,34 +390,34 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_start'
|
AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_start'
|
||||||
AND COALESCE(json_extract(payload, '$.role'), '') != '__start__'
|
AND COALESCE(json_extract(payload, '$.role'), '') != '__start__'
|
||||||
)
|
)
|
||||||
SELECT id, ts, payload, rn FROM numbered
|
SELECT id, timestamp, payload, rn FROM numbered
|
||||||
WHERE (@before = 0 OR rn < @before)
|
WHERE (@before = 0 OR rn < @before)
|
||||||
ORDER BY rn DESC
|
ORDER BY rn DESC
|
||||||
LIMIT @lim`,
|
LIMIT @lim`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getActiveWorkflowRunsStmt = sqlite.prepare(
|
const getActiveWorkflowRunsStmt = sqlite.prepare(
|
||||||
"SELECT run_id, workflow, status, ts FROM workflow_runs WHERE status IN ('queued', 'started') ORDER BY ts ASC",
|
"SELECT run_id, workflow, status, timestamp, exit_code FROM workflow_runs WHERE status IN ('queued', 'started') ORDER BY timestamp ASC",
|
||||||
);
|
);
|
||||||
|
|
||||||
const getActiveWorkflowRunsByNameStmt = sqlite.prepare(
|
const getActiveWorkflowRunsByNameStmt = sqlite.prepare(
|
||||||
"SELECT run_id, workflow, status, ts FROM workflow_runs WHERE status IN ('queued', 'started') AND workflow = ? ORDER BY ts ASC",
|
"SELECT run_id, workflow, status, timestamp, exit_code FROM workflow_runs WHERE status IN ('queued', 'started') AND workflow = ? ORDER BY timestamp ASC",
|
||||||
);
|
);
|
||||||
|
|
||||||
const getAllWorkflowRunsStmt = sqlite.prepare(
|
const getAllWorkflowRunsStmt = sqlite.prepare(
|
||||||
"SELECT run_id, workflow, status, ts FROM workflow_runs ORDER BY ts DESC",
|
"SELECT run_id, workflow, status, timestamp, exit_code FROM workflow_runs ORDER BY timestamp DESC",
|
||||||
);
|
);
|
||||||
|
|
||||||
const getAllWorkflowRunsByNameStmt = sqlite.prepare(
|
const getAllWorkflowRunsByNameStmt = sqlite.prepare(
|
||||||
"SELECT run_id, workflow, status, ts FROM workflow_runs WHERE workflow = ? ORDER BY ts DESC",
|
"SELECT run_id, workflow, status, timestamp, exit_code FROM workflow_runs WHERE workflow = ? ORDER BY timestamp DESC",
|
||||||
);
|
);
|
||||||
|
|
||||||
const minLogTsStmt = sqlite.prepare("SELECT MIN(ts) AS m FROM logs");
|
const minLogTsStmt = sqlite.prepare("SELECT MIN(timestamp) AS m FROM logs");
|
||||||
const selectLogsForDayStmt = sqlite.prepare(
|
const selectLogsForDayStmt = sqlite.prepare(
|
||||||
"SELECT id, source, type, ref_id, payload, ts FROM logs WHERE ts >= @start AND ts < @endExclusive ORDER BY id ASC",
|
"SELECT id, source, type, ref_id, payload, timestamp FROM logs WHERE timestamp >= @start AND timestamp < @endExclusive ORDER BY id ASC",
|
||||||
);
|
);
|
||||||
const deleteLogsForDayStmt = sqlite.prepare(
|
const deleteLogsForDayStmt = sqlite.prepare(
|
||||||
"DELETE FROM logs WHERE ts >= @start AND ts < @endExclusive",
|
"DELETE FROM logs WHERE timestamp >= @start AND timestamp < @endExclusive",
|
||||||
);
|
);
|
||||||
|
|
||||||
function upsertWorkflowRunTx(entry: Omit<LogEntry, "id">, run: WorkflowRun): LogEntry {
|
function upsertWorkflowRunTx(entry: Omit<LogEntry, "id">, run: WorkflowRun): LogEntry {
|
||||||
@@ -375,13 +427,14 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
type: entry.type,
|
type: entry.type,
|
||||||
refId: entry.refId,
|
refId: entry.refId,
|
||||||
payload: entry.payload,
|
payload: entry.payload,
|
||||||
ts: entry.ts,
|
timestamp: entry.timestamp,
|
||||||
});
|
});
|
||||||
upsertWorkflowRunStmt.run({
|
upsertWorkflowRunStmt.run({
|
||||||
runId: run.runId,
|
runId: run.runId,
|
||||||
workflow: run.workflow,
|
workflow: run.workflow,
|
||||||
status: run.status,
|
status: run.status,
|
||||||
ts: run.ts,
|
timestamp: run.timestamp,
|
||||||
|
exitCode: run.exitCode,
|
||||||
});
|
});
|
||||||
return { ...entry, id: Number(info.lastInsertRowid) };
|
return { ...entry, id: Number(info.lastInsertRowid) };
|
||||||
});
|
});
|
||||||
@@ -393,7 +446,7 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
type: entry.type,
|
type: entry.type,
|
||||||
refId: entry.refId,
|
refId: entry.refId,
|
||||||
payload: entry.payload,
|
payload: entry.payload,
|
||||||
ts: entry.ts,
|
timestamp: entry.timestamp,
|
||||||
});
|
});
|
||||||
return { ...entry, id: Number(info.lastInsertRowid) };
|
return { ...entry, id: Number(info.lastInsertRowid) };
|
||||||
}
|
}
|
||||||
@@ -415,17 +468,17 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
params.refId = filter.refId;
|
params.refId = filter.refId;
|
||||||
}
|
}
|
||||||
if (filter.since !== undefined) {
|
if (filter.since !== undefined) {
|
||||||
conditions.push("ts >= @since");
|
conditions.push("timestamp >= @since");
|
||||||
params.since = filter.since;
|
params.since = filter.since;
|
||||||
}
|
}
|
||||||
if (filter.until !== undefined) {
|
if (filter.until !== undefined) {
|
||||||
conditions.push("ts <= @until");
|
conditions.push("timestamp <= @until");
|
||||||
params.until = filter.until;
|
params.until = filter.until;
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
const limit = filter.limit !== undefined ? `LIMIT ${filter.limit}` : "";
|
const limit = filter.limit !== undefined ? `LIMIT ${filter.limit}` : "";
|
||||||
const sql = `SELECT id, source, type, ref_id, payload, ts FROM logs ${where} ORDER BY id ASC ${limit}`;
|
const sql = `SELECT id, source, type, ref_id, payload, timestamp FROM logs ${where} ORDER BY id ASC ${limit}`;
|
||||||
|
|
||||||
const rows = sqlite.prepare(sql).all(params) as Array<{
|
const rows = sqlite.prepare(sql).all(params) as Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -433,7 +486,7 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
type: string;
|
type: string;
|
||||||
ref_id: string | null;
|
ref_id: string | null;
|
||||||
payload: string | null;
|
payload: string | null;
|
||||||
ts: number;
|
timestamp: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
@@ -442,7 +495,7 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
type: r.type,
|
type: r.type,
|
||||||
refId: r.ref_id,
|
refId: r.ref_id,
|
||||||
payload: r.payload,
|
payload: r.payload,
|
||||||
ts: r.ts,
|
timestamp: r.timestamp,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,31 +516,37 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
return upsertWorkflowRunTx(entry, run);
|
return upsertWorkflowRunTx(entry, run);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWorkflowRun(runId: string): WorkflowRun | null {
|
type SqlWorkflowRunRow = {
|
||||||
const row = getWorkflowRunStmt.get(runId) as
|
run_id: string;
|
||||||
| { run_id: string; workflow: string; status: string; ts: number }
|
workflow: string;
|
||||||
| undefined;
|
status: string;
|
||||||
if (row === undefined) return null;
|
timestamp: number;
|
||||||
|
exit_code: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapWorkflowRunRow(r: SqlWorkflowRunRow): WorkflowRun {
|
||||||
return {
|
return {
|
||||||
runId: row.run_id,
|
runId: r.run_id,
|
||||||
workflow: row.workflow,
|
workflow: r.workflow,
|
||||||
status: validateWorkflowRunStatus(row.status),
|
status: validateWorkflowRunStatus(r.status),
|
||||||
ts: row.ts,
|
timestamp: r.timestamp,
|
||||||
|
exitCode: r.exit_code ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWorkflowRun(runId: string): WorkflowRun | null {
|
||||||
|
const row = getWorkflowRunStmt.get(runId) as SqlWorkflowRunRow | undefined;
|
||||||
|
if (row === undefined) return null;
|
||||||
|
return mapWorkflowRunRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
function getActiveWorkflowRuns(workflowName?: string): WorkflowRun[] {
|
function getActiveWorkflowRuns(workflowName?: string): WorkflowRun[] {
|
||||||
const rows = (
|
const rows = (
|
||||||
workflowName !== undefined
|
workflowName !== undefined
|
||||||
? getActiveWorkflowRunsByNameStmt.all(workflowName)
|
? getActiveWorkflowRunsByNameStmt.all(workflowName)
|
||||||
: getActiveWorkflowRunsStmt.all()
|
: getActiveWorkflowRunsStmt.all()
|
||||||
) as Array<{ run_id: string; workflow: string; status: string; ts: number }>;
|
) as SqlWorkflowRunRow[];
|
||||||
return rows.map((r) => ({
|
return rows.map(mapWorkflowRunRow);
|
||||||
runId: r.run_id,
|
|
||||||
workflow: r.workflow,
|
|
||||||
status: validateWorkflowRunStatus(r.status),
|
|
||||||
ts: r.ts,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllWorkflowRuns(workflowName: string | null): WorkflowRun[] {
|
function getAllWorkflowRuns(workflowName: string | null): WorkflowRun[] {
|
||||||
@@ -495,28 +554,14 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
workflowName !== null
|
workflowName !== null
|
||||||
? getAllWorkflowRunsByNameStmt.all(workflowName)
|
? getAllWorkflowRunsByNameStmt.all(workflowName)
|
||||||
: getAllWorkflowRunsStmt.all()
|
: getAllWorkflowRunsStmt.all()
|
||||||
) as Array<{ run_id: string; workflow: string; status: string; ts: number }>;
|
) as SqlWorkflowRunRow[];
|
||||||
return rows.map((r) => ({
|
return rows.map(mapWorkflowRunRow);
|
||||||
runId: r.run_id,
|
|
||||||
workflow: r.workflow,
|
|
||||||
status: validateWorkflowRunStatus(r.status),
|
|
||||||
ts: r.ts,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTriggerPayload(runId: string): unknown {
|
function getTriggerPayload(runId: string): unknown {
|
||||||
const row = getTriggerPayloadStmt.get(runId) as { payload: string | null } | undefined;
|
const row = getTriggerPayloadStmt.get(runId) as { payload: string | null } | undefined;
|
||||||
if (row === undefined || row.payload === null) return null;
|
if (row === undefined || row.payload === null) return null;
|
||||||
try {
|
return triggerPayloadFromStartedLogJson(row.payload);
|
||||||
const parsed = JSON.parse(row.payload) as unknown;
|
|
||||||
if (parsed !== null && typeof parsed === "object") {
|
|
||||||
const obj = parsed as Record<string, unknown>;
|
|
||||||
return obj.triggerPayload ?? null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// malformed
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThreadEvents(runId: string): Array<{ type: string; [key: string]: unknown }> {
|
function getThreadEvents(runId: string): Array<{ type: string; [key: string]: unknown }> {
|
||||||
@@ -525,12 +570,8 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.payload === null) continue;
|
if (row.payload === null) continue;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(row.payload) as unknown;
|
const parsed: unknown = JSON.parse(row.payload);
|
||||||
if (
|
if (isPlainRecord(parsed) && typeof parsed.type === "string") {
|
||||||
parsed !== null &&
|
|
||||||
typeof parsed === "object" &&
|
|
||||||
typeof (parsed as Record<string, unknown>).type === "string"
|
|
||||||
) {
|
|
||||||
result.push(parsed as { type: string; [key: string]: unknown });
|
result.push(parsed as { type: string; [key: string]: unknown });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -544,9 +585,9 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
payload: string,
|
payload: string,
|
||||||
): { role: string; content: string; meta: unknown; timestamp: number } | null {
|
): { role: string; content: string; meta: unknown; timestamp: number } | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(payload) as unknown;
|
const parsed: unknown = JSON.parse(payload);
|
||||||
if (parsed === null || typeof parsed !== "object") return null;
|
if (!isPlainRecord(parsed)) return null;
|
||||||
const obj = parsed as Record<string, unknown>;
|
const obj = parsed;
|
||||||
if (typeof obj.role !== "string" || typeof obj.content !== "string") return null;
|
if (typeof obj.role !== "string" || typeof obj.content !== "string") return null;
|
||||||
return {
|
return {
|
||||||
role: obj.role,
|
role: obj.role,
|
||||||
@@ -579,31 +620,37 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
return Number(c);
|
return Number(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordToRoundMessage(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
fallbackTs: number,
|
||||||
|
): { role: string; content: string; meta: unknown; timestamp: number } | null {
|
||||||
|
if (typeof obj.role === "string" && typeof obj.content === "string") {
|
||||||
|
return {
|
||||||
|
role: obj.role,
|
||||||
|
content: obj.content,
|
||||||
|
meta: obj.meta,
|
||||||
|
timestamp: typeof obj.timestamp === "number" ? obj.timestamp : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof obj.type === "string") {
|
||||||
|
return {
|
||||||
|
role: typeof obj.role === "string" ? obj.role : obj.type,
|
||||||
|
content: typeof obj.content === "string" ? obj.content : JSON.stringify(obj),
|
||||||
|
meta: obj,
|
||||||
|
timestamp: fallbackTs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseRoundPayload(
|
function parseRoundPayload(
|
||||||
payload: string,
|
payload: string,
|
||||||
fallbackTs: number,
|
fallbackTs: number,
|
||||||
): { role: string; content: string; meta: unknown; timestamp: number } | null {
|
): { role: string; content: string; meta: unknown; timestamp: number } | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(payload) as unknown;
|
const parsed: unknown = JSON.parse(payload);
|
||||||
if (parsed === null || typeof parsed !== "object") return null;
|
if (!isPlainRecord(parsed)) return null;
|
||||||
const obj = parsed as Record<string, unknown>;
|
return recordToRoundMessage(parsed, fallbackTs);
|
||||||
if (typeof obj.role === "string" && typeof obj.content === "string") {
|
|
||||||
return {
|
|
||||||
role: obj.role,
|
|
||||||
content: obj.content,
|
|
||||||
meta: obj.meta,
|
|
||||||
timestamp: typeof obj.timestamp === "number" ? obj.timestamp : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (typeof obj.type === "string") {
|
|
||||||
return {
|
|
||||||
role: typeof obj.role === "string" ? obj.role : obj.type,
|
|
||||||
content: typeof obj.content === "string" ? obj.content : JSON.stringify(obj),
|
|
||||||
meta: obj,
|
|
||||||
timestamp: fallbackTs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -616,14 +663,14 @@ export function createLogStore(dbPath: string): LogStore {
|
|||||||
runId,
|
runId,
|
||||||
before: params.before,
|
before: params.before,
|
||||||
lim: params.limit,
|
lim: params.limit,
|
||||||
}) as Array<{ id: number; ts: number; payload: string | null; rn: number }>;
|
}) as Array<{ id: number; timestamp: number; payload: string | null; rn: number }>;
|
||||||
|
|
||||||
const out: ThreadRoundRow[] = [];
|
const out: ThreadRoundRow[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.payload === null) continue;
|
if (row.payload === null) continue;
|
||||||
const message = parseRoundPayload(row.payload, row.ts);
|
const message = parseRoundPayload(row.payload, row.timestamp);
|
||||||
if (message !== null) {
|
if (message !== null) {
|
||||||
out.push({ round: row.rn, logId: row.id, ts: row.ts, message });
|
out.push({ round: row.rn, logId: row.id, timestamp: row.timestamp, message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/nerve-workflow-utils",
|
||||||
|
"version": "0.4.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"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@uncaged/nerve-core": "workspace:*",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"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,130 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { llmExtract } from "../llm-extract.js";
|
||||||
|
|
||||||
|
describe("llmExtract", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses tool call arguments and validates with the zod schema", async () => {
|
||||||
|
const schema = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
})
|
||||||
|
.describe("Extract sense metadata from plan");
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
function: {
|
||||||
|
name: "extract",
|
||||||
|
arguments: JSON.stringify({ name: "cpu-usage", description: "CPU load" }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await llmExtract({
|
||||||
|
text: "some plan",
|
||||||
|
schema,
|
||||||
|
provider: {
|
||||||
|
baseUrl: "https://example.com/v1",
|
||||||
|
apiKey: "k",
|
||||||
|
model: "m",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.value).toEqual({ name: "cpu-usage", description: "CPU load" });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(init.headers).toMatchObject({
|
||||||
|
Authorization: "Bearer k",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
const body = JSON.parse(init.body as string) as {
|
||||||
|
model: string;
|
||||||
|
tool_choice: { function: { name: string } };
|
||||||
|
};
|
||||||
|
expect(body.model).toBe("m");
|
||||||
|
expect(body.tool_choice.function.name).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns schema_validation_failed when arguments do not match the schema", async () => {
|
||||||
|
const schema = z.object({ n: z.number() });
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () =>
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{ function: { name: "extract", arguments: JSON.stringify({ n: "oops" }) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await llmExtract({
|
||||||
|
text: "x",
|
||||||
|
schema,
|
||||||
|
provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.error.kind).toBe("schema_validation_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dryRun skips fetch and returns schema-shaped stub values", async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const schema = z.object({ n: z.number() });
|
||||||
|
const result = await llmExtract({
|
||||||
|
text: "ignored",
|
||||||
|
schema,
|
||||||
|
provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" },
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.value).toEqual({ n: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { schemaDefaults } from "../schema-defaults.js";
|
||||||
|
|
||||||
|
describe("schemaDefaults", () => {
|
||||||
|
it("fills nested objects with primitive placeholders", () => {
|
||||||
|
const schema = z.object({
|
||||||
|
meta: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
count: z.number(),
|
||||||
|
flag: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(schemaDefaults(schema)).toEqual({
|
||||||
|
meta: { id: "", count: 0, flag: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses empty arrays for array fields", () => {
|
||||||
|
const schema = z.object({
|
||||||
|
roles: z.array(z.object({ name: z.string(), level: z.number() })),
|
||||||
|
});
|
||||||
|
const out = schemaDefaults(schema) as { roles: { name: string; level: number }[] };
|
||||||
|
expect(out.roles).toEqual([]);
|
||||||
|
expect(out.roles.map((r) => r.name)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the first enum value", () => {
|
||||||
|
const schema = z.object({
|
||||||
|
status: z.enum(["pending", "done", "failed"]),
|
||||||
|
code: z.nativeEnum({ A: 1, B: 2 }),
|
||||||
|
});
|
||||||
|
expect(schemaDefaults(schema)).toEqual({
|
||||||
|
status: "pending",
|
||||||
|
code: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets optional fields to undefined and omits exactOptional keys", () => {
|
||||||
|
const schema = z.object({
|
||||||
|
req: z.string(),
|
||||||
|
maybe: z.string().optional(),
|
||||||
|
exact: z.string().exactOptional(),
|
||||||
|
});
|
||||||
|
expect(schemaDefaults(schema)).toEqual({
|
||||||
|
req: "",
|
||||||
|
maybe: undefined,
|
||||||
|
});
|
||||||
|
expect(Object.keys(schemaDefaults(schema) as object).includes("exact")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects .default()", () => {
|
||||||
|
const schema = z.object({
|
||||||
|
n: z.number().default(42),
|
||||||
|
});
|
||||||
|
expect(schemaDefaults(schema)).toEqual({ n: 42 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { spawnSafe } from "../spawn-safe.js";
|
||||||
|
|
||||||
|
describe("spawnSafe", () => {
|
||||||
|
it("passes argv literally without shell interpretation (injection-safe)", async () => {
|
||||||
|
const injection = "$(echo BAD)";
|
||||||
|
const result = await spawnSafe(
|
||||||
|
process.execPath,
|
||||||
|
["-e", "process.stdout.write(process.argv[1] ?? '')", injection],
|
||||||
|
{ cwd: null, env: null, timeoutMs: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.value.stdout).toBe(injection);
|
||||||
|
expect(result.value.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns err on non-zero exit", async () => {
|
||||||
|
const result = await spawnSafe(process.execPath, ["-e", "process.exit(7)"], {
|
||||||
|
cwd: null,
|
||||||
|
env: null,
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.error.kind).toBe("non_zero_exit");
|
||||||
|
if (result.error.kind !== "non_zero_exit") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.error.exitCode).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dryRun skips spawn and returns a zero-exit stub", async () => {
|
||||||
|
const result = await spawnSafe(process.execPath, ["-e", "process.exit(1)"], {
|
||||||
|
cwd: null,
|
||||||
|
env: null,
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.value).toEqual({
|
||||||
|
stdout: "[dryRun] skipped",
|
||||||
|
stderr: "",
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { type Result, err, ok } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
|
export type ReadNerveYamlOptions = {
|
||||||
|
nerveRoot: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NerveYamlError = {
|
||||||
|
code: "PATH_TRAVERSAL" | "READ_FAILED";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `nerve.yaml` from a Nerve data directory (typically `~/.uncaged-nerve`).
|
||||||
|
* Returns Result to avoid throwing on expected failures (missing file, bad perms).
|
||||||
|
* Validates that the resolved path stays within nerveRoot to prevent path traversal.
|
||||||
|
*/
|
||||||
|
export function readNerveYaml(options: ReadNerveYamlOptions): Result<string, NerveYamlError> {
|
||||||
|
const root = resolve(options.nerveRoot);
|
||||||
|
const target = resolve(root, "nerve.yaml");
|
||||||
|
|
||||||
|
if (!target.startsWith(root)) {
|
||||||
|
return err({
|
||||||
|
code: "PATH_TRAVERSAL",
|
||||||
|
message: `Resolved path "${target}" escapes nerveRoot "${root}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return ok(readFileSync(target, "utf-8"));
|
||||||
|
} catch (e) {
|
||||||
|
return err({
|
||||||
|
code: "READ_FAILED",
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared context for workflow agents: how Nerve fits together and common CLI verbs.
|
||||||
|
*/
|
||||||
|
export const nerveAgentContext = `
|
||||||
|
Nerve observes the world through **Senses** (each has its own SQLite DB and a \`compute()\` function).
|
||||||
|
**Reflexes** (YAML) schedule sense runs or start **Workflows** on intervals or signals.
|
||||||
|
The \`nerve\` CLI manages config, triggers, and queries; keep paths and commands aligned with the host nerve.yaml and senses directory.
|
||||||
|
`.trim();
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { type Result, ok } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
|
import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js";
|
||||||
|
|
||||||
|
export type CursorAgentMode = "plan" | "ask" | "default";
|
||||||
|
|
||||||
|
export type CursorAgentOptions = {
|
||||||
|
prompt: string;
|
||||||
|
mode: CursorAgentMode;
|
||||||
|
cwd: string;
|
||||||
|
env: SpawnEnv | null;
|
||||||
|
timeoutMs: number | null;
|
||||||
|
dryRun: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CursorAgentOptionsInput = CursorAgentOptions | Omit<CursorAgentOptions, "dryRun">;
|
||||||
|
|
||||||
|
function resolveCursorAgentDryRun(options: CursorAgentOptionsInput): boolean {
|
||||||
|
return "dryRun" in options ? options.dryRun : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
"auto",
|
||||||
|
"--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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!run.ok) {
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(run.value.stdout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export { cursorAgent, type CursorAgentMode, type CursorAgentOptions } from "./cursor-agent.js";
|
||||||
|
export {
|
||||||
|
nerveAgentContext,
|
||||||
|
readNerveYaml,
|
||||||
|
type NerveYamlError,
|
||||||
|
type ReadNerveYamlOptions,
|
||||||
|
} from "./context.js";
|
||||||
|
export {
|
||||||
|
llmExtract,
|
||||||
|
type LlmError,
|
||||||
|
type LlmExtractOptions,
|
||||||
|
type LlmProvider,
|
||||||
|
} from "./llm-extract.js";
|
||||||
|
export { schemaDefaults } from "./schema-defaults.js";
|
||||||
|
export {
|
||||||
|
nerveCommandEnv,
|
||||||
|
spawnSafe,
|
||||||
|
type SpawnEnv,
|
||||||
|
type SpawnError,
|
||||||
|
type SpawnResult,
|
||||||
|
type SpawnSafeOptions,
|
||||||
|
} from "./spawn-safe.js";
|
||||||
|
export { isDryRun } from "./start-step.js";
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { type Result, err, ok } from "@uncaged/nerve-core";
|
||||||
|
import { toJSONSchema, type z } from "zod";
|
||||||
|
|
||||||
|
import { schemaDefaults } from "./schema-defaults.js";
|
||||||
|
|
||||||
|
export type LlmProvider = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LlmExtractOptions<T> = {
|
||||||
|
text: string;
|
||||||
|
schema: z.ZodType<T>;
|
||||||
|
provider: LlmProvider;
|
||||||
|
dryRun: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LlmExtractOptionsInput<T> = LlmExtractOptions<T> | Omit<LlmExtractOptions<T>, "dryRun">;
|
||||||
|
|
||||||
|
function resolveLlmExtractDryRun<T>(options: LlmExtractOptionsInput<T>): boolean {
|
||||||
|
return "dryRun" in options ? options.dryRun : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LlmError =
|
||||||
|
| { kind: "http_error"; status: number; body: string }
|
||||||
|
| { kind: "invalid_response_json"; message: string }
|
||||||
|
| { kind: "no_tool_call"; preview: string }
|
||||||
|
| { kind: "tool_arguments_invalid_json"; message: string }
|
||||||
|
| { kind: "schema_validation_failed"; message: string }
|
||||||
|
| { kind: "network_error"; message: string };
|
||||||
|
|
||||||
|
function chatCompletionsUrl(baseUrl: string): string {
|
||||||
|
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||||
|
return `${trimmed}/chat/completions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripJsonSchemaMeta(json: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const { $schema: _drop, ...rest } = json;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readToolName(parametersSchema: Record<string, unknown>): string {
|
||||||
|
const title = parametersSchema.title;
|
||||||
|
if (typeof title === "string" && title.trim().length > 0) {
|
||||||
|
return title.trim();
|
||||||
|
}
|
||||||
|
return "extract";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readToolArgumentsJson(parsed: unknown, previewSource: string): Result<string, LlmError> {
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
return err({ kind: "invalid_response_json", message: "Top-level JSON is not an object" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const choices = parsed.choices;
|
||||||
|
if (!Array.isArray(choices) || choices.length === 0) {
|
||||||
|
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = choices[0];
|
||||||
|
if (!isRecord(first)) {
|
||||||
|
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageObj = first.message;
|
||||||
|
if (!isRecord(messageObj)) {
|
||||||
|
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCalls = messageObj.tool_calls;
|
||||||
|
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
|
||||||
|
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const call0 = toolCalls[0];
|
||||||
|
if (!isRecord(call0)) {
|
||||||
|
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = call0.function;
|
||||||
|
if (!isRecord(fn)) {
|
||||||
|
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsRaw = fn.arguments;
|
||||||
|
if (typeof argsRaw !== "string") {
|
||||||
|
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(argsRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls an OpenAI-compatible chat completions API with `tool_choice` forced to a single function
|
||||||
|
* derived from a Zod v4 schema (`toJSONSchema`). Uses `fetch()` only (no shell).
|
||||||
|
*/
|
||||||
|
export async function llmExtract<T>(
|
||||||
|
options: LlmExtractOptionsInput<T>,
|
||||||
|
): Promise<Result<T, LlmError>> {
|
||||||
|
const dryRun = resolveLlmExtractDryRun(options);
|
||||||
|
if (dryRun) {
|
||||||
|
return ok(schemaDefaults(options.schema) as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawJsonSchema = toJSONSchema(options.schema) as Record<string, unknown>;
|
||||||
|
const parameters = stripJsonSchemaMeta(rawJsonSchema);
|
||||||
|
const toolName = readToolName(parameters);
|
||||||
|
const toolDescription =
|
||||||
|
typeof options.schema.description === "string" && options.schema.description.trim().length > 0
|
||||||
|
? options.schema.description.trim()
|
||||||
|
: "Extract structured data from the input text.";
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model: options.provider.model,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system" as const,
|
||||||
|
content: "Extract the requested information from the provided text. Be precise.",
|
||||||
|
},
|
||||||
|
{ role: "user" as const, content: options.text },
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: toolName,
|
||||||
|
description: toolDescription,
|
||||||
|
parameters,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool_choice: { type: "function" as const, function: { name: toolName } },
|
||||||
|
};
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(chatCompletionsUrl(options.provider.baseUrl), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${options.provider.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
return err({ kind: "network_error", message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
return err({ kind: "http_error", status: response.status, body: responseText.slice(0, 4000) });
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(responseText) as unknown;
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
return err({ kind: "invalid_response_json", message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const argsJson = readToolArgumentsJson(parsed, responseText);
|
||||||
|
if (!argsJson.ok) {
|
||||||
|
return argsJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
let argsParsed: unknown;
|
||||||
|
try {
|
||||||
|
argsParsed = JSON.parse(argsJson.value) as unknown;
|
||||||
|
} catch (cause) {
|
||||||
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
return err({ kind: "tool_arguments_invalid_json", message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = options.schema.safeParse(argsParsed);
|
||||||
|
if (!validated.success) {
|
||||||
|
return err({
|
||||||
|
kind: "schema_validation_failed",
|
||||||
|
message: validated.error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(validated.data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
type ZodTypeAny = z.ZodType;
|
||||||
|
|
||||||
|
type Def = Record<string, unknown> & { type: string };
|
||||||
|
type TypeHandler = (schema: ZodTypeAny, def: Def) => unknown;
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZodExactOptional(s: ZodTypeAny): boolean {
|
||||||
|
return s.constructor.name === "ZodExactOptional";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultValue(defaultValue: unknown | (() => unknown)): unknown {
|
||||||
|
if (typeof defaultValue === "function") {
|
||||||
|
return (defaultValue as () => unknown)();
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeIntersection(left: unknown, right: unknown): unknown {
|
||||||
|
if (isPlainObject(left) && isPlainObject(right)) {
|
||||||
|
return { ...left, ...right };
|
||||||
|
}
|
||||||
|
return right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultsForObject(_schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const shape = def.shape as Record<string, ZodTypeAny> | undefined;
|
||||||
|
if (shape === undefined) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(shape)) {
|
||||||
|
const child = shape[key];
|
||||||
|
const cdef = child.def as { type: string };
|
||||||
|
if (cdef.type === "optional") {
|
||||||
|
if (isZodExactOptional(child)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[key] = undefined;
|
||||||
|
} else {
|
||||||
|
out[key] = schemaDefaultsInner(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstUnionOption(_schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const options = def.options as readonly ZodTypeAny[] | undefined;
|
||||||
|
if (options === undefined || options.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return schemaDefaultsInner(options[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultsFromNullable(_schema: ZodTypeAny, _def: Def): unknown {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultsFromInner(_schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const inner = def.innerType as ZodTypeAny | undefined;
|
||||||
|
if (inner === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return schemaDefaultsInner(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultsForPipe(_schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const out = def.out as ZodTypeAny | undefined;
|
||||||
|
if (out === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return schemaDefaultsInner(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultsForIntersection(_schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const left = def.left as ZodTypeAny | undefined;
|
||||||
|
const right = def.right as ZodTypeAny | undefined;
|
||||||
|
if (left === undefined || right === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mergeIntersection(schemaDefaultsInner(left), schemaDefaultsInner(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultsForTuple(_schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const items = def.items as readonly ZodTypeAny[] | undefined;
|
||||||
|
if (items === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return items.map((item) => schemaDefaultsInner(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultsForLazy(schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const inner =
|
||||||
|
(schema as { _zod?: { innerType?: ZodTypeAny } })._zod?.innerType ??
|
||||||
|
(def.getter as (() => ZodTypeAny) | undefined)?.();
|
||||||
|
if (inner === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return schemaDefaultsInner(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultsForPromise(_schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const inner = def.innerType as ZodTypeAny | undefined;
|
||||||
|
if (inner === undefined) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return Promise.resolve(schemaDefaultsInner(inner));
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstEnumValue(_schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const entries = def.entries as Record<string, string | number> | undefined;
|
||||||
|
if (entries === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const values = Object.values(entries);
|
||||||
|
return values[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLiteralValue(_schema: ZodTypeAny, def: Def): unknown {
|
||||||
|
const values = def.values as unknown[] | undefined;
|
||||||
|
if (values === undefined || values.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return values[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_HANDLERS: Record<string, TypeHandler> = {
|
||||||
|
string: () => "",
|
||||||
|
number: () => 0,
|
||||||
|
boolean: () => false,
|
||||||
|
bigint: () => 0n,
|
||||||
|
date: () => new Date(0),
|
||||||
|
symbol: () => Symbol(),
|
||||||
|
undefined: () => undefined,
|
||||||
|
null: () => null,
|
||||||
|
void: () => undefined,
|
||||||
|
any: () => null,
|
||||||
|
unknown: () => null,
|
||||||
|
never: () => undefined,
|
||||||
|
nan: () => Number.NaN,
|
||||||
|
array: () => [],
|
||||||
|
object: defaultsForObject,
|
||||||
|
record: () => ({}),
|
||||||
|
map: () => new Map(),
|
||||||
|
set: () => new Set(),
|
||||||
|
enum: firstEnumValue,
|
||||||
|
literal: firstLiteralValue,
|
||||||
|
optional: () => undefined,
|
||||||
|
nullable: defaultsFromNullable,
|
||||||
|
default: (_s, def) => resolveDefaultValue(def.defaultValue as unknown | (() => unknown)),
|
||||||
|
prefault: (_s, def) => resolveDefaultValue(def.defaultValue as unknown | (() => unknown)),
|
||||||
|
nonoptional: defaultsFromInner,
|
||||||
|
catch: defaultsFromInner,
|
||||||
|
success: () => false,
|
||||||
|
readonly: defaultsFromInner,
|
||||||
|
union: firstUnionOption,
|
||||||
|
xor: firstUnionOption,
|
||||||
|
intersection: defaultsForIntersection,
|
||||||
|
pipe: defaultsForPipe,
|
||||||
|
transform: () => null,
|
||||||
|
tuple: defaultsForTuple,
|
||||||
|
lazy: defaultsForLazy,
|
||||||
|
promise: defaultsForPromise,
|
||||||
|
file: () => new File([], ""),
|
||||||
|
function: () => null,
|
||||||
|
custom: () => null,
|
||||||
|
template_literal: () => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a structurally valid placeholder that mirrors primitive/array/object
|
||||||
|
* shape for a Zod schema. Used for `llmExtract` dry runs so downstream code
|
||||||
|
* (e.g. `.roles.map`) does not throw on `undefined` fields.
|
||||||
|
*/
|
||||||
|
export function schemaDefaults(schema: z.ZodType): unknown {
|
||||||
|
return schemaDefaultsInner(schema as ZodTypeAny);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaDefaultsInner(schema: ZodTypeAny): unknown {
|
||||||
|
const def = schema.def as Def;
|
||||||
|
const run = TYPE_HANDLERS[def.type];
|
||||||
|
if (run === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return run(schema, def);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { type Result, err, ok } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
|
/** Compatible with `process.env` for `child_process.spawn`. */
|
||||||
|
export type SpawnEnv = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export type SpawnResult = {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
/** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */
|
||||||
|
signal: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpawnError =
|
||||||
|
| {
|
||||||
|
kind: "non_zero_exit";
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
exitCode: number;
|
||||||
|
signal: string | null;
|
||||||
|
}
|
||||||
|
| { kind: "timeout"; stdout: string; stderr: string }
|
||||||
|
| { kind: "spawn_failed"; message: string };
|
||||||
|
|
||||||
|
export type SpawnSafeOptions = {
|
||||||
|
cwd: string | null;
|
||||||
|
/** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */
|
||||||
|
env: SpawnEnv | null;
|
||||||
|
timeoutMs: number | null;
|
||||||
|
dryRun: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpawnSafeOptionsInput = SpawnSafeOptions | Omit<SpawnSafeOptions, "dryRun">;
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 300_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATH and PNPM_HOME for running `pnpm` and `nerve` from workflow roles.
|
||||||
|
* Uses the pnpm store home only (no npm user bin); binaries must resolve via PATH.
|
||||||
|
*/
|
||||||
|
export function nerveCommandEnv(): SpawnEnv {
|
||||||
|
const home = homedir();
|
||||||
|
const pnpmHome = join(home, ".local/share/pnpm");
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
PNPM_HOME: pnpmHome,
|
||||||
|
PATH: `${pnpmHome}:${process.env.PATH ?? ""}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEnv(user: SpawnEnv | null): SpawnEnv {
|
||||||
|
const base = nerveCommandEnv();
|
||||||
|
if (user === null) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return { ...base, ...user };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeout(timeoutMs: number | null): number {
|
||||||
|
if (timeoutMs === null) {
|
||||||
|
return DEFAULT_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
return timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDryRun(options: SpawnSafeOptionsInput): boolean {
|
||||||
|
return "dryRun" in options ? options.dryRun : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a process with `shell: false` (argv only), default {@link nerveCommandEnv}, and optional timeout.
|
||||||
|
* Returns `ok` only when the process exits with code 0.
|
||||||
|
*/
|
||||||
|
export function spawnSafe(
|
||||||
|
command: string,
|
||||||
|
args: ReadonlyArray<string>,
|
||||||
|
options: SpawnSafeOptionsInput,
|
||||||
|
): Promise<Result<SpawnResult, SpawnError>> {
|
||||||
|
const dryRun = resolveDryRun(options);
|
||||||
|
if (dryRun) {
|
||||||
|
return Promise.resolve(
|
||||||
|
ok({
|
||||||
|
stdout: "[dryRun] skipped",
|
||||||
|
stderr: "",
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const cwd = options.cwd === null ? process.cwd() : options.cwd;
|
||||||
|
const env = mergeEnv(options.env);
|
||||||
|
const timeoutMs = resolveTimeout(options.timeoutMs);
|
||||||
|
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
shell: false,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (outcome: Result<SpawnResult, SpawnError>) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(outcome);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
finish(err({ kind: "timeout", stdout, stderr }));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||||
|
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||||
|
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (cause: Error) => {
|
||||||
|
finish(err({ kind: "spawn_failed", message: cause.message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
const exitCode = code ?? 1;
|
||||||
|
const sig = signal === undefined || signal === null ? null : String(signal);
|
||||||
|
const result: SpawnResult = {
|
||||||
|
stdout: stdout.trimEnd(),
|
||||||
|
stderr: stderr.trimEnd(),
|
||||||
|
exitCode,
|
||||||
|
signal: sig,
|
||||||
|
};
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
finish(
|
||||||
|
err({
|
||||||
|
kind: "non_zero_exit",
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
exitCode,
|
||||||
|
signal: sig,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish(ok(result));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { StartStep } from "@uncaged/nerve-core";
|
||||||
|
|
||||||
|
/** Returns the thread-level dry-run flag from the workflow start frame. */
|
||||||
|
export function isDryRun(start: StartStep): boolean {
|
||||||
|
return start.meta.dryRun;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"composite": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+1057
-4
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user