Compare commits

..

30 Commits

Author SHA1 Message Date
xiaoju 21be4997df feat(workflow-utils): add dryRunDefaults option to llmExtract
Lets workflow authors provide semantically meaningful mock data for dryRun mode. Falls back to schemaDefaults() when not provided.

Fixes #130

小橘 🍊(NEKO Team)

Made-with: Cursor
2026-04-25 04:40:32 +00:00
xiaomo ce20d73ab6 Merge pull request 'fix(workflow-utils): llmExtract dryRun returns schema-shaped defaults' (#126) from fix/123-llmextract-dryrun-defaults into main 2026-04-25 04:35:45 +00:00
xiaoju 7c999a0689 fix(workflow-utils): dryRun llmExtract returns schema-shaped defaults
Add schemaDefaults() from Zod def types; export from package; tests for nested/array/enum/optional.

Made-with: Cursor
2026-04-25 04:31:46 +00:00
xiaomo 111b7e2734 Merge pull request 'feat: workflow exit codes & kill mechanism' (#122) from feat/121-workflow-exit-codes into main 2026-04-25 04:03:29 +00:00
xiaoju 01d7435c4a feat: workflow exit codes & kill mechanism
- Add exit_code to workflow_runs (0=success, 1=role error, 2=maxRounds, 137=killed, 255=crash)
- Expand status enum: started/completed/failed/killed
- Add kill-thread IPC message for graceful workflow termination
- Add 'nerve workflow kill <runId>' CLI command
- Show exit_code in 'nerve workflow list' output

Fixes #121
2026-04-25 03:57:26 +00:00
xiaoju 889bbbb474 Merge pull request 'refactor(core): SenseResult<T> generic + split types.ts' (#118) from refactor/111-split-types-generify-sense-result into main 2026-04-25 02:59:48 +00:00
xiaoju 418ae6a073 refactor(core): SenseResult generic + split types.ts into config/sense/workflow
- SenseResult<T = unknown> with payload: T
- types.ts split into config.ts (types), sense.ts, workflow.ts
- Original config.ts (parseNerveConfig) moved to parse-nerve-config.ts
- index.ts re-exports from new modules, external API unchanged
- daemon-ipc-protocol.ts imports SenseInfo from sense.ts

Fixes #111
2026-04-25 02:56:55 +00:00
xiaoju c6f56155c8 Merge pull request 'refactor(core): restructure ModeratorContext to { start, steps }' (#117) from refactor/110-moderator-context-restructure into main 2026-04-25 02:51:50 +00:00
xiaoju 3ce9e3a846 refactor(core): restructure ModeratorContext to { start, steps }
- ModeratorContext: discriminated union → { start: StartStep; steps: RoleStep<M>[] }
- Moderator signature: (context, round, maxRounds) → (context)
- round derivable from steps.length, maxRounds from start.meta.maxRounds
- workflow-worker.ts: build steps array, pass full context to moderator
- Remove unused ModeratorContext import from workflow-worker
- Update README.md

Refs #110
2026-04-25 02:48:28 +00:00
xiaoju 0fff8ef954 Merge pull request 'refactor(core): rename RoleSignal → RoleStep, StartSignal → StartStep' (#116) from refactor/109-role-step into main 2026-04-25 02:37:03 +00:00
xiaoju beada2ae09 refactor(core): rename RoleSignal → RoleStep, StartSignal → StartStep
- RoleStep now includes content and timestamp fields (aligned with StartStep)
- ModeratorContext.signal → ModeratorContext.step
- workflow-utils: start-signal.ts → start-step.ts, isDryRun updated

Fixes #109
2026-04-25 02:34:33 +00:00
xiaoju 47d23bc1a7 Merge pull request 'refactor(store): rename LogEntry.ts → LogEntry.timestamp' (#114) from refactor/113-logentry-timestamp into main 2026-04-25 02:28:38 +00:00
xiaoju 3dc835e1de refactor(store): rename LogEntry/WorkflowRun/ThreadRoundRow ts → timestamp
- Rename logs & workflow_runs table column ts → timestamp (breaking, no migration)
- Update all SQL, types, mocks, CLI output, and tests
- Integration tests use mkdtempSync to avoid stale DB conflicts

Fixes #113
2026-04-25 02:24:39 +00:00
xiaoju 4da2c87a77 refactor(store): rename LogEntry.ts → LogEntry.timestamp
- Rename logs table column ts → timestamp (no migration, breaking)
- Update all SQL, type definitions, and consumers
- Integration tests use mkdtempSync to avoid stale DB conflicts

Fixes #113
2026-04-25 02:08:57 +00:00
xiaoju 529cceba06 Merge pull request 'refactor(core): remove unnecessary | null, unify timestamp naming' (#112) from refactor/108-remove-null-unify-ts into main 2026-04-25 01:57:48 +00:00
xiaoju 020a1bfe85 refactor(core): remove unnecessary | null, unify timestamp naming
- SenseReflexConfig.on: string[] | null → string[] (empty = no conditions)
- NerveConfig.workflows: Record | null → Record (empty = no workflows)
- Signal.ts → Signal.timestamp
- SenseInfo.lastSignalTs → SenseInfo.lastSignalTimestamp
- All consumers across daemon/cli/store updated
- parseNerveConfig: on defaults to [], workflows defaults to {}

Fixes #108
2026-04-25 01:52:58 +00:00
xiaomo 7ce3970027 Merge pull request 'feat(cli): workspace biome.json with noConsole, remove dryRun console.log' (#107) from feat/106-workspace-biome into main 2026-04-25 01:15:53 +00:00
xiaoju fcde29ed1c feat(cli): add biome.json to workspace init, remove dryRun console.log
- init.ts: scaffold biome.json with noConsole: error for workflows
- package.json template: add @biomejs/biome to devDependencies
- workflow-utils: remove console.log from dryRun paths (stub returns
  are captured by log-store via role results)

Fixes #106
2026-04-25 01:00:41 +00:00
xiaomo 611bc48751 Merge pull request 'feat(workflow-utils): dryRun support for spawnSafe, cursorAgent, llmExtract' (#105) from feat/104-dryrun-utils into main 2026-04-25 00:26:27 +00:00
xiaoju 70bea92133 feat(workflow-utils): dryRun support for spawnSafe, cursorAgent, llmExtract
When dryRun=true, each function logs its parameters and returns a stub
result without executing any subprocess or network call. Log output is
captured by log-store for analysis.

- spawnSafe: returns { exitCode: 0, stdout: '[dryRun] skipped' }
- cursorAgent: short-circuits before spawnSafe, returns ok('[dryRun] skipped')
- llmExtract: skips fetch, returns ok({} as T)
- Tests added for spawnSafe and llmExtract dryRun paths

Fixes #104
2026-04-25 00:23:43 +00:00
xiaomo 6f2cddd695 Merge pull request 'feat(core,daemon,cli): add dryRun thread-level parameter to StartSignal' (#103) from feat/101-dry-run into main 2026-04-24 23:50:55 +00:00
xiaoju c4dc707eb0 feat(core,daemon,cli): add dryRun thread-level parameter to StartSignal
- StartSignal.meta gains dryRun: boolean (alongside maxRounds)
- DaemonIpcTriggerWorkflowRequest includes dryRun, parsed with default false
- CLI parses dryRun from --payload JSON, passes through daemon client
- workflow-worker/workflow-manager propagate dryRun through full IPC chain
- Sense-triggered workflows default to dryRun: false
- workflow-utils exports isDryRun(start) helper
- All tests updated, 376 pass

Fixes #101
2026-04-24 23:45:29 +00:00
xiaomo a7ce8401ce Merge pull request 'refactor(core,daemon): extract StartSignal as independent Role parameter' (#102) from refactor/100-extract-start-signal into main 2026-04-24 23:35:09 +00:00
xiaoju e9e6df2f5a refactor(core,daemon): extract StartSignal as independent Role parameter
- Role<Meta> now takes (start: StartSignal, messages: WorkflowMessage[])
- messages no longer contains the __start__ frame
- Add ModeratorContext<M> discriminated union (kind: start | step)
- Moderator receives typed context instead of raw StartSignal | RoleSignal union
- workflow-worker separates start from role messages throughout

Refs #100
2026-04-24 23:14:45 +00:00
xingyue b3b0dad2bb Merge pull request 'feat: add workflow-utils package' (#98) from feat/97-workflow-utils into main 2026-04-24 22:43:07 +00:00
xiaoju e0ce1d995c fix: readNerveYaml returns Result + path traversal guard
Address review feedback:
- Return Result<string, NerveYamlError> instead of throwing
- Add path traversal protection via resolve + startsWith check
- Export NerveYamlError type
- Update sense-generator to handle Result
2026-04-24 22:41:27 +00:00
xiaoju 0a4a2330dc feat: add workflow-utils package
Closes #97
2026-04-24 22:32:29 +00:00
xiaomo d3088c623b Merge pull request 'docs: update all README files to match actual code' (#96) from docs/95-update-readme-to-match-code into main 2026-04-24 21:49:33 +00:00
xiaoju a7e6caf6e7 docs: update all README files to match actual code
Rewrite documentation across all packages to reflect current
architecture, APIs, and CLI commands.

- README.md: fix reflex examples, add store package, update config
- core/README.md: add Sense→workflow routing, IPC types
- daemon/README.md: complete module table, crash recovery, createKernel
- cli/README.md: add workflow/sense/store subcommands
- store/README.md: new file documenting LogStore/BlobStore

Fixes #95
2026-04-24 21:47:37 +00:00
xiaomo d4dcd9722f Merge pull request 'refactor: share IPC message types between CLI and daemon' (#94) from refactor/93-shared-ipc-types into main 2026-04-24 15:14:50 +00:00
61 changed files with 2730 additions and 959 deletions
+68 -44
View File
@@ -7,28 +7,31 @@ Nerve is a lightweight daemon that continuously observes external state through
## Core Concepts
```
External World → Sense → Signal → Reflex → Workflow → Log
↑ ↑
"what to observe" "what to do"
External World → Sense ─┬→ Signal → Reflex → Sense (scheduled compute)
└→ Workflow (Sense return with workflow directive) → Log
```
| Concept | Metaphor | Role |
|---------|----------|------|
| **Sense** | 👁️ Perception | A `compute()` function that samples or derives data. Each sense has its own SQLite database. |
| **Reflex** | ⚡ Reaction | Declarative trigger — interval-based, event-driven, or both. Connects senses to actions. |
| **Signal** | 📡 Notification | Emitted when a sense returns non-null. Other reflexes can listen for signals. |
| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles (actors) and a Moderator (coordinator). |
| **Reflex** | ⚡ Reaction | Declarative rules that **only schedule Sense computes** (interval and/or `on` signal names). Reflex YAML cannot reference workflows. |
| **Signal** | 📡 Notification | Emitted when a sense returns a non-null value that is routed as a normal signal (see Sense → Workflow below). Other reflexes can listen via `on`. |
| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started from a Sense return value or from CLI/daemon IPC—not from reflex YAML. |
| **Log** | 📝 Record | Immutable audit trail. Queryable by senses, but **cannot** trigger reflexes (prevents feedback loops). |
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
| Package | Description |
|---------|-------------|
| [`@uncaged/nerve-core`](./packages/core) | Shared types and config parser |
| [`@uncaged/nerve-daemon`](./packages/daemon) | The observation engine — kernel, sense runtime, reflex scheduler, workflow manager |
| [`@uncaged/nerve-cli`](./packages/cli) | CLI tool (`nerve`) — init, start, stop, logs, query |
| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, Sense→workflow routing, daemon IPC protocol |
| [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows |
| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, workers, signal bus, reflex scheduler, workflow manager, file watcher, IPC |
| [`@uncaged/nerve-cli`](./packages/cli) | CLI (`nerve`) — init, validate, daemon, dev, logs, sense, store, workflow |
## Quick Start
@@ -70,15 +73,17 @@ nerve logs # view logs
## Configuration
`nerve.yaml` declares senses, reflexes, and workflows:
`nerve.yaml` declares senses, reflexes (sense-only), optional workflows (concurrency), and optional engine `max_rounds`:
```yaml
max_rounds: 100 # default moderator cap (e.g. CLI workflow trigger)
senses:
cpu-usage:
group: system # senses in the same group share a worker process
throttle: 10s # min interval between computes
timeout: 30s # max compute duration
gracePeriod: 5s # wait before first compute after startup
grace_period: 5s # wait before first compute after startup
reflexes:
- kind: sense
@@ -86,10 +91,6 @@ reflexes:
interval: 30s # periodic trigger
on: [disk-pressure] # also trigger on signals from other senses
- kind: workflow
workflow: cleanup
on: [disk-pressure] # start a workflow when signal fires
workflows:
cleanup:
concurrency: 1
@@ -97,43 +98,66 @@ workflows:
code-review:
concurrency: 3
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
```
┌─────────────────────────────────────────────────────────┐
│ Kernel │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ Worker │ │ Worker │ │ Worker (1 per
│ (group A)│ │ (group B)│ │ (group C)│ group)
│ sense-1 │ │ sense-3 │ │ sense-5 │ │
│ │ sense-2 │ │ sense-4 │ │ │ │
│ └─────────┘ └────┬─────┘ └────┬─────┘
└──────────────┼──────────────┘
┌──────────────┐
│ Signal Bus │
│ └─────────────┘
──────────────────
│ Reflex Scheduler │
└────────┬─────────
┌───────────────────
Workflow Manager │──→ Log Store (SQLite)
└───────────────────
└─────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────
│ Kernel
│ ┌──────────────┐ watches nerve.yaml / senses / workflows
│ │ File Watcher ├──────────────────────────────────────────┐
└──────────────┘ │
┌──────────────┐ CLI ↔ newline JSON (trigger-workflow, │ │
│ │ Daemon IPC │ trigger-sense, list-senses) │ │
│ └──────┬───────┘
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ │ Worker │ │ Worker │ │ Worker │ (1 per
│ (group A)│ │ (group B)│ │ (group C)│ group)
│ sense-1 │ sense-3 │ sense-5 │
│ │ sense-2 │ │ sense-4 │ │
└────┬─────┘ └─────────└────┬─────┘
│ │
└────────────────────────────┘
│ ┌──────────────┐
│ Signal Bus │
│ └─────────────
│ │
│ ┌──────────────────
│ │ │ Reflex Scheduler│ │
│ │ └────────┬─────────┘ │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │
│ └───────────────────┘ (logs.db, …) │
└────────────────────────────────────────────────────────────────────────┘
```
- **Worker 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.
- **Reflex Scheduler** — interval timers + signal subscriptions, with throttle/coalesce.
- **Workflow Manager** — concurrency control (drop/queue), thread lifecycle tracking.
- **Log Store** — WAL-mode SQLite via `node:sqlite`, with archival and retention policies.
- **Workflow Manager** — concurrency (drop/queue), per-workflow workers, crash recovery.
- **File watcher** — hot reload for config, sense modules, and workflow modules.
- **Daemon IPC** — Unix domain socket; used by the CLI when the daemon is running.
- **Log / blob storage** — implemented in `@uncaged/nerve-store` (WAL SQLite, JSONL archive, CAS blobs).
## Tech Stack
+32 -14
View File
@@ -21,41 +21,59 @@ nerve init # Initialize a nerve workspace (installs deps, scaff
nerve validate # Validate nerve.yaml configuration
```
### Daemon Management
### Daemon management
```bash
nerve daemon start # Start the daemon (background)
nerve daemon stop # Stop the daemon
nerve daemon status # Check daemon health
nerve daemon restart # Restart the daemon
nerve daemon logs # Tail daemon logs
nerve daemon status # Show pid, uptime, sense names from nerve.yaml (process must exist)
nerve daemon restart # Stop then start
nerve daemon logs # Tail daemon process logs (file under workspace logs/)
```
### Development
```bash
nerve dev # 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
nerve logs # View structured logs
nerve sense query <name> # Query a sense's SQLite database
nerve sense schema <name> # Show a sense's database schema
nerve status # Daemon health summary
nerve logs # Tail or page the daemon text log file (path in footer; default ~/.uncaged-nerve/logs/nerve.log)
nerve status # Short daemon health summary (aliases daemon status)
```
Structured rows in `data/logs.db` are surfaced via **`nerve workflow inspect`** / **`nerve workflow list`** (and `LogStore` in code), not via `nerve logs`.
### Sense
```bash
nerve sense list # List senses (live fields from daemon IPC when running)
nerve sense trigger <name> # IPC trigger-sense — queue a compute for that sense
nerve sense query <name> # Read-only SQL on data/senses/<name>.db (optional SQL args)
nerve sense schema <name> # Print CREATE TABLE statements for that sense DB
```
### Store maintenance
```bash
nerve store archive # Move old log rows to JSONL under data/archive/logs/… (optional --vacuum)
```
### Workflows
```bash
nerve workflow list # List workflow runs
nerve workflow show <runId> # Show workflow run details
nerve workflow list # Queued/started runs (add --all for terminal states; --workflow, --limit, --offset)
nerve workflow inspect <runId> # Run metadata + paginated workflow log lines
nerve workflow thread <runId> # Role rounds from persisted messages (--before, --budget)
nerve workflow trigger <name> # IPC trigger-workflow (daemon must be running)
# Optional JSON: --payload '{"prompt":"…","maxRounds":50}'
```
### 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
nerve start → nerve daemon start
+37 -11
View File
@@ -29,10 +29,22 @@ const SAMPLE_SENSES: SenseInfo[] = [
group: "system",
throttle: 5000,
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("—");
});
it("shows '(never)' when lastSignalTs is null", () => {
it("shows '(never)' when lastSignalTimestamp is null", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("(never)");
});
it("shows ISO timestamp when lastSignalTs is set", () => {
it("shows ISO timestamp when lastSignalTimestamp is set", () => {
const output = formatSenseList(SAMPLE_SENSES);
// cpu-usage has lastSignalTs = 1_700_000_000_000
// cpu-usage has lastSignalTimestamp = 1_700_000_000_000
expect(output).toContain(new Date(1_700_000_000_000).toISOString());
});
});
@@ -157,11 +169,19 @@ reflexes: []
);
const result = sensesFromConfig(path);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ name: "cpu-usage", group: "system", lastSignalTs: null });
expect(result[1]).toMatchObject({ name: "disk-usage", group: "system", lastSignalTs: null });
expect(result[0]).toMatchObject({
name: "cpu-usage",
group: "system",
lastSignalTimestamp: null,
});
expect(result[1]).toMatchObject({
name: "disk-usage",
group: "system",
lastSignalTimestamp: null,
});
});
it("always sets lastSignalTs to null (static fallback)", () => {
it("always sets lastSignalTimestamp to null (static fallback)", () => {
const path = join(tmpDir, "nerve.yaml");
writeFileSync(
path,
@@ -173,7 +193,7 @@ reflexes: []
`.trim(),
);
const result = sensesFromConfig(path);
expect(result[0].lastSignalTs).toBeNull();
expect(result[0].lastSignalTimestamp).toBeNull();
});
it("populates throttle and timeout from config", () => {
@@ -238,7 +258,13 @@ describe("listSensesViaDaemon", () => {
it("resolves with populated senses array", async () => {
const senses: SenseInfo[] = [
{ name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, lastSignalTs: 12345 },
{
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
lastSignalTimestamp: 12345,
},
];
const server = createServer((s) => {
s.on("data", () => {
+24 -23
View File
@@ -41,11 +41,11 @@ function upsertRun(
runId: string,
workflow: string,
status: WorkflowRun["status"],
ts: number,
timestampMs: number,
): void {
store.upsertWorkflowRun(
{ source: "workflow", type: status, refId: runId, payload: null, ts },
{ runId, workflow, status, ts },
{ source: "workflow", type: status, refId: runId, payload: null, timestamp: timestampMs },
{ runId, workflow, status, timestamp: timestampMs, exitCode: null },
);
}
@@ -65,8 +65,8 @@ afterEach(() => {
describe("formatTs", () => {
it("returns ISO 8601 string", () => {
const ts = new Date("2026-01-01T00:00:00.000Z").getTime();
expect(formatTs(ts)).toBe("2026-01-01T00:00:00.000Z");
const timestampMs = new Date("2026-01-01T00:00:00.000Z").getTime();
expect(formatTs(timestampMs)).toBe("2026-01-01T00:00:00.000Z");
});
});
@@ -83,6 +83,7 @@ describe("statusIcon", () => {
["crashed", "💥"],
["dropped", "🗑"],
["interrupted", "⚠️"],
["killed", "🛑"],
] as const)("maps status=%s to icon=%s", (status, 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("r2", "cleanup", "started", 3000);
upsertRun("r3", "cleanup", "failed", 2000);
const runs = getAllWorkflowRuns(store, null);
expect(runs[0].ts).toBeGreaterThan(runs[1].ts);
expect(runs[1].ts).toBeGreaterThan(runs[2].ts);
expect(runs[0].timestamp).toBeGreaterThan(runs[1].timestamp);
expect(runs[1].timestamp).toBeGreaterThan(runs[2].timestamp);
});
});
@@ -147,9 +148,9 @@ describe("buildListOutput", () => {
runId: string,
workflow: string,
status: WorkflowRun["status"],
ts: number,
timestampMs: number,
): WorkflowRun {
return { runId, workflow, status, ts };
return { runId, workflow, status, timestamp: timestampMs };
}
it("returns empty message when no runs and --all=false", () => {
@@ -235,7 +236,7 @@ describe("buildInspectOutput", () => {
runId: "run-xyz",
workflow: "cleanup",
status: "completed",
ts: 1_700_000_000_000,
timestamp: 1_700_000_000_000,
};
it("shows header with run details", () => {
@@ -251,8 +252,8 @@ describe("buildInspectOutput", () => {
expect(eventLines.join("")).toContain("no events recorded");
});
it("shows event lines with type and ts", () => {
const logs = [{ ts: 1_700_000_001_000, type: "started", payload: null }];
it("shows event lines with type and timestamp", () => {
const logs = [{ timestamp: 1_700_000_001_000, type: "started", payload: null }];
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
const text = eventLines.join("");
expect(text).toContain("type=started");
@@ -260,7 +261,7 @@ describe("buildInspectOutput", () => {
it("truncates long payloads to 200 chars with ellipsis", () => {
const longPayload = "x".repeat(250);
const logs = [{ ts: 1000, type: "step_complete", payload: longPayload }];
const logs = [{ timestamp: 1000, type: "step_complete", payload: longPayload }];
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
const text = eventLines.join("");
expect(text).toContain("…");
@@ -268,14 +269,14 @@ describe("buildInspectOutput", () => {
});
it("shows short payloads in full", () => {
const logs = [{ ts: 1000, type: "step_complete", payload: '{"count":5}' }];
const logs = [{ timestamp: 1000, type: "step_complete", payload: '{"count":5}' }];
const { eventLines } = buildInspectOutput(baseRun, logs, 0, 20);
expect(eventLines.join("")).toContain('{"count":5}');
});
it("paginates events with a hint", () => {
const logs = Array.from({ length: 5 }, (_, i) => ({
ts: 1000 + i,
timestamp: 1000 + i,
type: "step_complete",
payload: null,
}));
@@ -287,7 +288,7 @@ describe("buildInspectOutput", () => {
});
it("no pagination hint when all events fit on one page", () => {
const logs = [{ ts: 1000, type: "started", payload: null }];
const logs = [{ timestamp: 1000, type: "started", payload: null }];
const { paginationHint } = buildInspectOutput(baseRun, logs, 0, 20);
expect(paginationHint).toBeNull();
});
@@ -358,7 +359,7 @@ describe("formatThreadRoundBlock", () => {
const row: ThreadRoundRow = {
round: 2,
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 },
};
@@ -376,7 +377,7 @@ describe("buildThreadCommandOutput", () => {
return {
round: n,
logId: 10 + n,
ts: 1000 + n,
timestamp: 1000 + n,
message: { role: "r", content, meta: { extra: n }, timestamp: 1000 + n },
};
}
@@ -462,15 +463,15 @@ describe("getAllWorkflowRuns — uses store.getAllWorkflowRuns SQL path", () =>
expect(runs).toHaveLength(7);
});
it("returns runs sorted by ts descending (newest first)", () => {
it("returns runs sorted by timestamp descending (newest first)", () => {
upsertRun("r1", "deploy", "completed", 1000);
upsertRun("r2", "deploy", "completed", 3000);
upsertRun("r3", "deploy", "completed", 2000);
const runs = getAllWorkflowRuns(store, null);
expect(runs[0].ts).toBe(3000);
expect(runs[1].ts).toBe(2000);
expect(runs[2].ts).toBe(1000);
expect(runs[0].timestamp).toBe(3000);
expect(runs[1].timestamp).toBe(2000);
expect(runs[2].timestamp).toBe(1000);
});
it("filters by workflow name", () => {
+27
View File
@@ -21,6 +21,31 @@ reflexes:
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 = `{
"name": "my-nerve-workspace",
"version": "0.0.1",
@@ -32,6 +57,7 @@ const PACKAGE_JSON = `{
"drizzle-orm": "latest"
},
"devDependencies": {
"@biomejs/biome": "latest",
"drizzle-kit": "latest"
},
"pnpm": {
@@ -320,6 +346,7 @@ async function runInitWorkspace(force: boolean): Promise<void> {
writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML);
writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON);
writeFile(join(nerveRoot, "biome.json"), BIOME_JSON);
writeFile(join(nerveRoot, ".gitignore"), GITIGNORE);
writeFile(join(nerveRoot, "senses", "cpu-usage", "schema.ts"), CPU_SCHEMA_TS);
writeFile(join(nerveRoot, "senses", "cpu-usage", "index.js"), CPU_INDEX_JS);
+3 -2
View File
@@ -43,7 +43,8 @@ export function formatSenseList(senses: SenseInfo[]): string {
lines.push(` group: ${s.group}\n`);
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
const lastSignal = s.lastSignalTs !== null ? new Date(s.lastSignalTs).toISOString() : "(never)";
const lastSignal =
s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)";
lines.push(` last signal: ${lastSignal}\n`);
}
return lines.join("");
@@ -64,7 +65,7 @@ export function sensesFromConfig(configPath: string): SenseInfo[] {
group: cfg.group,
throttle: cfg.throttle,
timeout: cfg.timeout,
lastSignalTs: null,
lastSignalTimestamp: null,
}));
}
+1 -1
View File
@@ -31,7 +31,7 @@ export const validateCommand = defineCommand({
const config = result.value;
const senseCount = Object.keys(config.senses).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(
`✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`,
+58 -12
View File
@@ -7,7 +7,7 @@ import { defineCommand } from "citty";
import { stringify } from "yaml";
import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store";
import { triggerWorkflowViaDaemon } from "../daemon-client.js";
import { killWorkflowViaDaemon, triggerWorkflowViaDaemon } from "../daemon-client.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
@@ -28,8 +28,8 @@ export function getDbPath(): string {
return join(getNerveRoot(), "data", "logs.db");
}
export function formatTs(ts: number): string {
return new Date(ts).toISOString();
export function formatTs(timestampMs: number): string {
return new Date(timestampMs).toISOString();
}
async function openStore(): Promise<LogStore> {
@@ -59,6 +59,8 @@ export function statusIcon(status: WorkflowRun["status"]): string {
return "🗑";
case "interrupted":
return "⚠️";
case "killed":
return "🛑";
default: {
const _exhaustive: never = status;
return `?(${_exhaustive})`;
@@ -67,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.
*/
export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | null): WorkflowRun[] {
@@ -79,7 +81,8 @@ export function getAllWorkflowRuns(store: LogStore, filterWorkflow: string | nul
*/
export function formatRunLine(run: WorkflowRun): string {
const icon = statusIcon(run.status);
return ` ${icon} ${run.runId} workflow=${run.workflow} status=${run.status} ts=${formatTs(run.ts)}\n`;
const exitCodeStr = run.exitCode !== null ? ` exit_code=${run.exitCode}` : "";
return ` ${icon} ${run.runId} workflow=${run.workflow} status=${run.status}${exitCodeStr} timestamp=${formatTs(run.timestamp)}\n`;
}
/**
@@ -139,7 +142,7 @@ export type InspectOutput = {
export function buildInspectOutput(
run: WorkflowRun,
allLogs: Array<{ ts: number; type: string; payload: string | null }>,
allLogs: Array<{ timestamp: number; type: string; payload: string | null }>,
offset: number,
limit: number,
): InspectOutput {
@@ -152,7 +155,7 @@ export function buildInspectOutput(
`🔍 Workflow run: ${run.runId}\n`,
` workflow: ${run.workflow}\n`,
` status: ${run.status}\n`,
` ts: ${formatTs(run.ts)}\n`,
` timestamp: ${formatTs(run.timestamp)}\n`,
`\n📜 Thread events (${shown} of ${total}):\n`,
];
@@ -167,7 +170,7 @@ export function buildInspectOutput(
: entry.payload.length <= 200
? ` payload=${entry.payload}`
: ` payload=${entry.payload.slice(0, 200)}`;
eventLines.push(` [${formatTs(entry.ts)}] type=${entry.type}${payloadStr}\n`);
eventLines.push(` [${formatTs(entry.timestamp)}] type=${entry.type}${payloadStr}\n`);
}
}
@@ -219,7 +222,7 @@ export function formatThreadRoundBlock(row: ThreadRoundRow): string {
const { roleStr, contentBody, meta } = partitionWorkflowMessage(row.message);
const yamlBlock =
Object.keys(meta).length === 0 ? "{}\n" : `${stringify(meta, { lineWidth: 100 })}\n`;
return `[#${row.round} ${roleStr}] ${formatTs(row.ts)}\n---\n${yamlBlock}---\n${contentBody}\n\n`;
return `[#${row.round} ${roleStr}] ${formatTs(row.timestamp)}\n---\n${yamlBlock}---\n${contentBody}\n\n`;
}
export type ThreadCommandOutput = {
@@ -237,7 +240,7 @@ function buildTruncatedSingleRound(
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.ts)}\n---\n${yamlBlock}---\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
@@ -515,7 +518,7 @@ const workflowTriggerCommand = defineCommand({
payload: {
type: "string",
description:
'JSON with optional "prompt" (string) and "maxRounds" (number) for the workflow run (default: {})',
'JSON with optional "prompt" (string), "maxRounds" (number), and "dryRun" (boolean) for the workflow run (default: {})',
default: "{}",
},
},
@@ -530,10 +533,12 @@ const workflowTriggerCommand = defineCommand({
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()) {
@@ -544,7 +549,7 @@ const workflowTriggerCommand = defineCommand({
const socketPath = getSocketPath();
let response: DaemonIpcTriggerResponse;
try {
response = await triggerWorkflowViaDaemon(socketPath, args.name, prompt, maxRounds);
response = await triggerWorkflowViaDaemon(socketPath, args.name, prompt, maxRounds, dryRun);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to contact daemon: ${msg}\n`);
@@ -561,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)
// ---------------------------------------------------------------------------
@@ -575,5 +620,6 @@ export const workflowCommand = defineCommand({
inspect: workflowInspectCommand,
thread: workflowThreadCommand,
trigger: workflowTriggerCommand,
kill: workflowKillCommand,
},
});
+15 -1
View File
@@ -28,7 +28,7 @@ function isSenseInfo(value: unknown): value is SenseInfo {
typeof value.group === "string" &&
(value.throttle === null || typeof value.throttle === "number") &&
(value.timeout === null || typeof value.timeout === "number") &&
(value.lastSignalTs === null || typeof value.lastSignalTs === "number")
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
);
}
@@ -135,12 +135,14 @@ export function triggerWorkflowViaDaemon(
workflow: string,
prompt: string,
maxRounds: number,
dryRun = false,
): Promise<DaemonIpcTriggerResponse> {
const message: DaemonIpcRequest = {
type: "trigger-workflow",
workflow,
prompt,
maxRounds,
dryRun,
};
return sendAndReceive(socketPath, message, parseDaemonResponse);
}
@@ -165,3 +167,15 @@ export function listSensesViaDaemon(socketPath: string): Promise<DaemonIpcListSe
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);
}
+29 -3
View File
@@ -4,9 +4,12 @@ Shared types and configuration parser for the [nerve](../../README.md) observati
## What's Inside
- **Type definitions** — `Signal`, `SenseConfig`, `ReflexConfig`, `WorkflowConfig`, `NerveConfig`, and all related types
- **Config parser** — `parseNerveConfig(yaml)` validates and parses `nerve.yaml` into a typed `NerveConfig`
- **Result type** — `Result<T>` with `ok()` / `err()` helpers for explicit error handling (no thrown exceptions)
- **Type definitions** — `Signal`, `SenseConfig`, `SenseInfo`, `SenseReflexConfig`, `ReflexConfig` (sense-only), `WorkflowConfig`, `NerveConfig`, and related types
- **Config parser** — `parseNerveConfig(yaml)` validates and parses `nerve.yaml` into `NerveConfig` (rejects reflex entries that declare a `workflow` key; reflexes only schedule senses)
- **Sense → workflow routing** — `parseSenseWorkflowDirective`, `routeSenseComputeOutput`, and types `ParsedSenseWorkflowDirective`, `SenseComputeRoute`
- **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
@@ -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
Config fields like `throttle`, `timeout`, and `interval` accept human-readable durations:
+8 -7
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseNerveConfig } from "../config.js";
import { parseNerveConfig } from "../parse-nerve-config.js";
const VALID_CONFIG = `
senses:
@@ -50,7 +50,7 @@ describe("parseNerveConfig", () => {
kind: "sense",
sense: "cpu",
interval: 30_000,
on: null,
on: [],
});
expect(result.value.reflexes[1]).toEqual({
kind: "sense",
@@ -58,7 +58,7 @@ describe("parseNerveConfig", () => {
interval: null,
on: ["high_usage"],
});
expect(result.value.workflows?.alert).toEqual({
expect(result.value.workflows.alert).toEqual({
concurrency: 2,
overflow: "queue",
maxQueue: 10,
@@ -85,11 +85,12 @@ senses:
group: system
reflexes:
- sense: cpu
interval: 1s
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.workflows).toBeNull();
expect(result.value.workflows).toEqual({});
});
it("sense config has null for omitted throttle/timeout/gracePeriod", () => {
@@ -142,11 +143,11 @@ workflows:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.workflows?.alert).toEqual({
expect(result.value.workflows.alert).toEqual({
concurrency: 1,
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", () => {
@@ -163,7 +164,7 @@ workflows:
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.workflows?.alert).toEqual({
expect(result.value.workflows.alert).toEqual({
concurrency: 1,
overflow: "queue",
maxQueue: 100,
@@ -18,6 +18,27 @@ describe("parseDaemonIpcRequest", () => {
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,
});
});
+30 -297
View File
@@ -1,304 +1,37 @@
import { parse } from "yaml";
import { isPlainRecord } from "./is-plain-record.js";
import type { Result } from "./result.js";
import { err, ok } from "./result.js";
import type { NerveConfig, ReflexConfig, SenseConfig, WorkflowConfig } from "./types.js";
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,
export type SenseConfig = {
group: string;
throttle: number | null;
timeout: number | null;
gracePeriod: number | null;
};
function parseDurationToMs(value: string): number | null {
const match = DURATION_RE.exec(value);
if (!match) return null;
return Number(match[1]) * DURATION_MULTIPLIERS[match[2]];
}
export type SenseReflexConfig = {
kind: "sense";
sense: string;
interval: number | null;
on: string[];
};
function isValidGroupName(value: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(value);
}
/** Reflexes only schedule Senses; workflow launches come from Sense return values. */
export type ReflexConfig = SenseReflexConfig;
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);
}
export type DropOverflowConfig = {
concurrency: number;
overflow: "drop";
};
function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
if (!isPlainRecord(raw)) {
return err(new Error(`senses.${name}: must be an object`));
}
export type QueueOverflowConfig = {
concurrency: number;
overflow: "queue";
maxQueue: number;
};
const obj = raw;
export type WorkflowConfig = DropOverflowConfig | QueueOverflowConfig;
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[] | null> {
if (obj.on === undefined || obj.on === null) return ok(null);
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[] | 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 (!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> | null> {
if (obj.workflows === undefined || obj.workflows === null) return ok(null);
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,
});
}
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>;
};
+31 -11
View File
@@ -5,7 +5,7 @@
*/
import { isPlainRecord } from "./is-plain-record.js";
import type { SenseInfo } from "./types.js";
import type { SenseInfo } from "./sense.js";
/** Client → daemon: start a workflow run. */
export type DaemonIpcTriggerWorkflowRequest = {
@@ -13,6 +13,7 @@ export type DaemonIpcTriggerWorkflowRequest = {
workflow: string;
prompt: string;
maxRounds: number;
dryRun: boolean;
};
/** Client → daemon: run a sense compute on demand. */
@@ -26,11 +27,18 @@ 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;
| DaemonIpcListSensesRequest
| DaemonIpcKillWorkflowRequest;
/** Successful trigger / trigger-sense reply (no body). */
export type DaemonIpcTriggerOkResponse = { ok: true };
@@ -51,6 +59,22 @@ export type DaemonIpcResponse =
| 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.
@@ -61,15 +85,7 @@ export function parseDaemonIpcRequest(line: string): DaemonIpcRequest | null {
if (!isPlainRecord(obj)) return null;
const req = obj;
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,
};
return parseTriggerWorkflowFields(req);
}
if (req.type === "trigger-sense") {
if (typeof req.sense !== "string" || req.sense.length === 0) return null;
@@ -78,6 +94,10 @@ export function parseDaemonIpcRequest(line: string): DaemonIpcRequest | null {
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;
+10 -8
View File
@@ -1,27 +1,28 @@
export type {
Signal,
SenseConfig,
SenseInfo,
SenseReflexConfig,
ReflexConfig,
DropOverflowConfig,
QueueOverflowConfig,
WorkflowConfig,
NerveConfig,
} from "./config.js";
export type { Signal, SenseInfo, SenseResult } from "./sense.js";
export type {
WorkflowMessage,
RoleResult,
Role,
RoleMeta,
StartSignal,
RoleSignal,
StartStep,
RoleStep,
ModeratorContext,
Moderator,
WorkflowDefinition,
SenseResult,
} from "./types.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./types.js";
} from "./workflow.js";
export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js";
export type { Result } 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 {
@@ -37,6 +38,7 @@ export type {
DaemonIpcTriggerWorkflowRequest,
DaemonIpcTriggerSenseRequest,
DaemonIpcListSensesRequest,
DaemonIpcKillWorkflowRequest,
DaemonIpcRequest,
DaemonIpcTriggerOkResponse,
DaemonIpcErrorResponse,
+302
View File
@@ -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,
});
}
+21
View File
@@ -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;
};
-122
View File
@@ -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;
};
+75
View File
@@ -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
View File
@@ -4,18 +4,33 @@ The observation engine runtime for [nerve](../../README.md) — runs senses, rou
## Architecture
| Module | Responsibility |
|--------|---------------|
| **Kernel** | Top-level orchestrator — spawns workers, wires up signal bus, scheduler, and workflow manager. Supports hot reload and graceful shutdown. |
| **Sense Runtime** | Per-sense SQLite database (via `node:sqlite` + Drizzle ORM), migration runner, peer DB read access. |
| **Sense Worker** | Forked child process — one per sense group. Runs compute functions in isolation. |
| **Signal Bus** | In-memory pub/sub. Sense computes emit signals; reflexes and workflows subscribe. |
| **Reflex Scheduler** | Drives compute triggers — interval timers, signal-based events, throttle/coalesce logic. |
| **Workflow Manager** | Concurrency control (drop/queue), thread lifecycle, worker process management (RFC-002). |
| **Log Store** | Structured log storage in WAL-mode SQLite. Supports retention policies, archival to JSONL, and workflow run tracking. |
| **Blob Store** | Binary artifact storage for workflow outputs. |
| **File Watcher** | Watches `nerve.yaml` and sense files for hot reload. |
| **Daemon IPC** | Unix socket server for CLI ↔ daemon communication. |
| Module | Source (indicative) | Responsibility |
|--------|---------------------|----------------|
| **Kernel** | `kernel.ts` | Orchestrator — worker pool, signal bus, reflex scheduler, workflow manager, optional file watcher and daemon IPC, config reload hooks |
| **Worker pool** | `worker-pool.ts` | Fork and supervise one child process per sense group; restart/shutdown; crash cleanup hooks for scheduler state |
| **Kernel sense groups** | `kernel-sense-groups.ts` | Derive sense groups from config; list senses per group for scheduling |
| **Sense runtime** | sense worker + Drizzle | Per-sense SQLite (`node:sqlite`), migrations, peer DB reads |
| **Sense worker** | `sense-worker.ts` (fork target) | Child process entry — runs `compute()` per sense in a group |
| **Signal bus** | `signal-bus.ts` | In-memory pub/sub for sense signals |
| **Reflex scheduler** | `reflex-scheduler.ts` | Interval + `on` subscriptions, throttle/coalesce |
| **Workflow manager** | `workflow-manager.ts` | One worker per workflow name, concurrency (drop/queue), queue caps |
| **Workflow worker** | `workflow-worker.ts` | Child process — runs RFC-002 threads (`start-thread`, `resume-thread` IPC) |
| **IPC (parent ↔ workers)** | `ipc.ts` | Typed messages for sense and workflow workers (includes `resume-thread` for recovery) |
| **Log / workflow persistence** | via `@uncaged/nerve-store` | Structured logs, `workflow_runs`, thread messages (used for recovery) |
| **Blob store** | `@uncaged/nerve-store` | CAS under `data/blobs/` — sense workers construct `createBlobStore(join(nerveRoot, "data", "blobs"))` for artifact writes |
| **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
@@ -26,24 +41,44 @@ The observation engine runtime for [nerve](../../README.md) — runs senses, rou
## 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
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parseNerveConfig } from "@uncaged/nerve-core";
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;
// Trigger a sense manually
kernel.triggerSense("cpu-usage");
// Check health
const health = kernel.getHealth();
// Graceful shutdown
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
```bash
@@ -73,9 +73,10 @@ function makeLogStore(
runId: string;
workflow: string;
status: "queued" | "started";
ts: number;
timestamp: number;
}> = [],
) {
const runsWithExitCode = activeRuns.map((r) => ({ ...r, exitCode: null }));
const store = {
append: vi.fn(),
query: vi.fn(() => []),
@@ -86,9 +87,9 @@ function makeLogStore(
getWorkflowRun: vi.fn(() => null),
getActiveWorkflowRuns: vi.fn((_workflowName?: string) => {
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 })),
getThreadEvents: vi.fn(
@@ -127,8 +128,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10 });
mgr.startWorkflow("my-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("my-wf")).toBe(2);
// Simulate unexpected exit (not shutdown)
@@ -154,8 +155,8 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
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, dryRun: false });
expect(mgr.activeCount("my-wf")).toBe(2);
const child = mockChildren[0];
@@ -179,7 +180,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
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);
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 () => {
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);
logStore.getThreadMessages.mockReturnValue([
@@ -212,7 +213,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
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];
firstChild.exitCode = 1;
firstChild.connected = false;
@@ -245,7 +246,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
it("re-queues 'queued' runs from DB after respawn", async () => {
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);
logStore.getTriggerPayload.mockReturnValue({ queued: "payload" });
@@ -256,7 +257,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// 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];
firstChild.exitCode = 1;
firstChild.connected = false;
@@ -281,7 +282,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
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 startCall = (child.send as ReturnType<typeof vi.fn>).mock.calls[0];
@@ -317,7 +318,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
const launch = { prompt: "build-docker for myrepo", maxRounds: 10 };
const launch = { prompt: "build-docker for myrepo", maxRounds: 10, dryRun: false };
mgr.startWorkflow("my-wf", launch);
const startedCall = logStore.upsertWorkflowRun.mock.calls.find(
@@ -327,7 +328,11 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const logEntry = startedCall?.[0] as { payload: string | null };
expect(logEntry.payload).not.toBeNull();
const parsed = JSON.parse(logEntry.payload as string) as Record<string, unknown>;
expect(parsed).toMatchObject({ prompt: "build-docker for myrepo", maxRounds: 10 });
expect(parsed).toMatchObject({
prompt: "build-docker for myrepo",
maxRounds: 10,
dryRun: false,
});
const stopPromise = mgr.stop();
await vi.runAllTimersAsync();
@@ -338,7 +343,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
describe("runId deduplication in crash recovery", () => {
it("does not push duplicate runIds into the queue during crash recovery", async () => {
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);
logStore.getTriggerPayload.mockReturnValue({ q: 1 });
@@ -349,7 +354,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// 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];
// Crash once → respawn → crash again → second respawn
@@ -374,7 +379,12 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
it("does not add duplicate active runIds during crash recovery", async () => {
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);
logStore.getThreadMessages.mockReturnValue([]);
@@ -385,7 +395,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
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];
firstChild.exitCode = 1;
firstChild.connected = false;
@@ -415,7 +425,7 @@ describe("WorkflowManager — crash recovery (Phase 3)", () => {
});
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)
for (let i = 0; i < 6; i++) {
@@ -154,6 +154,7 @@ describe("daemon-ipc — trigger-sense", () => {
workflow: "my-workflow",
prompt: "test prompt",
maxRounds: 10,
dryRun: false,
});
expect(resp).toEqual({ ok: true });
@@ -161,6 +162,7 @@ describe("daemon-ipc — trigger-sense", () => {
expect(wfManager.startWorkflow).toHaveBeenCalledWith("my-workflow", {
prompt: "test prompt",
maxRounds: 10,
dryRun: false,
});
});
@@ -198,8 +200,20 @@ describe("daemon-ipc — list-senses", () => {
it("responds ok:true with senses populated from listSenses", async () => {
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);
server = createDaemonIpcServer(sockPath, makeMockWorkflowManager() as never, {
@@ -10,6 +10,9 @@
*/
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -102,7 +105,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
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);
// Remove workflow from config before drain completes
@@ -121,8 +124,8 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 2, overflow: "drop" } });
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-wf", { prompt: "test", maxRounds: 10 });
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, dryRun: false });
expect(mgr.activeCount("my-wf")).toBe(2);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
@@ -153,7 +156,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
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);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
@@ -169,7 +172,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
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);
const drainPromise = mgr.drainAndRespawn("my-wf", 5000);
@@ -186,7 +189,7 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
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);
await vi.runAllTimersAsync();
@@ -211,14 +214,14 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
const config = makeWfConfig({ "my-wf": { concurrency: 1, overflow: "drop" } });
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);
await vi.runAllTimersAsync();
await drainPromise;
// 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 startCalls = (newChild.send as ReturnType<typeof vi.fn>).mock.calls.filter(
@@ -236,32 +239,36 @@ describe("WorkflowManager — drainAndRespawn (Phase 3 hot reload)", () => {
});
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 () => {
vi.useRealTimers();
vi.clearAllMocks();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("handleWorkflowFileChange logs workflow_reload system event", async () => {
const logStore = makeLogStore();
const config: NerveConfig = {
senses: {},
reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any],
reflexes: [],
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
const kernel = createKernel(config, "/tmp/nerve-hot-reload-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// 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)
const drainPromise = kernel.workflowManager.drainAndRespawn("my-wf", 1000);
@@ -285,25 +292,29 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
const logStore = makeLogStore();
const initialConfig: NerveConfig = {
senses: {},
reflexes: [{ kind: "workflow", workflow: "old-wf", on: null } as any],
reflexes: [],
workflows: { "old-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
const kernel = createKernel(initialConfig, "/tmp/nerve-hot-reload-test", {
const kernel = createKernel(initialConfig, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
// 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);
// Reload config without old-wf
const newConfig: NerveConfig = {
senses: {},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
@@ -324,23 +335,23 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
const logStore = makeLogStore();
const initialConfig: NerveConfig = {
senses: {},
reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any],
reflexes: [],
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
maxRounds: 10,
};
const kernel = createKernel(initialConfig, "/tmp/nerve-hot-reload-test", {
const kernel = createKernel(initialConfig, nerveRoot, {
workerScript: "fake-worker.js",
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;
// Reload with updated concurrency — should NOT spawn a new workflow worker
const newConfig: NerveConfig = {
senses: {},
reflexes: [{ kind: "workflow", workflow: "my-wf", on: null } as any],
reflexes: [],
workflows: { "my-wf": { concurrency: 5, overflow: "queue", maxQueue: 50 } },
maxRounds: 10,
};
@@ -354,8 +365,8 @@ describe("Kernel — workflow hot reload via file-watcher (Phase 3)", () => {
expect(kernel.workflowManager.activeCount("my-wf")).toBe(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 });
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
kernel.workflowManager.startWorkflow("my-wf", { prompt: "test", maxRounds: 10, dryRun: false });
expect(kernel.workflowManager.activeCount("my-wf")).toBe(3);
const stopPromise = kernel.stop();
@@ -6,12 +6,14 @@
* artifacts are required.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Signal } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createKernel } 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 },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -55,12 +57,18 @@ async function pollUntil(
describe("kernel integration — real child processes", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-integration-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
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 },
},
});
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
@@ -83,7 +91,7 @@ describe("kernel integration — real child processes", () => {
it("workers start and respond to compute messages with signals", async () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
@@ -115,7 +123,7 @@ describe("kernel integration — real child processes", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
});
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
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 () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
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 () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-integration-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
@@ -4,6 +4,9 @@
*/
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 { 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 },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -84,13 +87,17 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
// ---------------------------------------------------------------------------
describe("kernel — getHealth", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-p6-health-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("returns correct health shape", async () => {
@@ -101,7 +108,7 @@ describe("kernel — getHealth", () => {
"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();
expect(health.activeSenses).toBe(3);
@@ -115,18 +122,22 @@ describe("kernel — getHealth", () => {
});
describe("kernel — restartGroup", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-p6-restart-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("sends shutdown to old worker and spawns new one", async () => {
const config = makeConfig();
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(1);
const oldChild = mockChildren[0];
@@ -146,7 +157,7 @@ describe("kernel — restartGroup", () => {
it("restartGroup on unknown group does nothing", async () => {
const config = makeConfig();
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(1);
await kernel.restartGroup("nonexistent");
@@ -158,18 +169,22 @@ describe("kernel — restartGroup", () => {
});
describe("kernel — reloadConfig", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-p6-reload-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("adds new group worker when new sense group appears", async () => {
const config = makeConfig();
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(1); // only system group
expect(kernel.groups.has("network")).toBe(false);
@@ -180,7 +195,7 @@ describe("kernel — reloadConfig", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
});
@@ -197,10 +212,10 @@ describe("kernel — reloadConfig", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(2);
expect(kernel.groups.has("network")).toBe(true);
@@ -212,7 +227,7 @@ describe("kernel — reloadConfig", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
});
@@ -225,7 +240,7 @@ describe("kernel — reloadConfig", () => {
it("health reflects updated sense count after reloadConfig", async () => {
const config = makeConfig();
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
expect(kernel.getHealth().activeSenses).toBe(1);
@@ -235,7 +250,7 @@ describe("kernel — reloadConfig", () => {
"disk-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
});
@@ -6,6 +6,9 @@
*/
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 { 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 },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -103,18 +106,22 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
// ---------------------------------------------------------------------------
describe("kernel.triggerSense()", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-trigger-sense-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("throws for an unknown sense name", async () => {
const config = makeConfig();
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: null,
logStore: makeMockLogStore() as never,
});
@@ -132,7 +139,7 @@ describe("kernel.triggerSense()", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: null,
logStore: makeMockLogStore() as never,
});
@@ -162,7 +169,7 @@ describe("kernel.triggerSense()", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: null,
logStore: makeMockLogStore() as never,
});
@@ -185,7 +192,7 @@ describe("kernel.triggerSense()", () => {
vi.useRealTimers();
const config = makeConfig();
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: null,
logStore: makeMockLogStore() as never,
});
@@ -9,6 +9,9 @@
*/
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 { 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 },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -106,14 +109,18 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
// ---------------------------------------------------------------------------
describe("kernel + workflowManager integration", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-wf-"));
});
afterEach(async () => {
vi.useRealTimers();
vi.clearAllMocks();
rmSync(nerveRoot, { recursive: true, force: true });
});
describe("sense compute triggers workflow via return value", () => {
@@ -127,7 +134,7 @@ describe("kernel + workflowManager integration", () => {
workflows: { "my-workflow": { concurrency: 2, overflow: "drop" } },
});
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
@@ -171,7 +178,7 @@ describe("kernel + workflowManager integration", () => {
workflows: { "alert-workflow": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
@@ -202,6 +209,7 @@ describe("kernel + workflowManager integration", () => {
workflow: "alert-workflow",
prompt: "handle critical alert",
maxRounds: 5,
dryRun: false,
});
const stopPromise = kernel.stop();
@@ -220,7 +228,7 @@ describe("kernel + workflowManager integration", () => {
workflows: { "my-workflow": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
@@ -263,7 +271,7 @@ describe("kernel + workflowManager integration", () => {
workflows: { "log-test-workflow": { concurrency: 2, overflow: "drop" } },
});
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
@@ -297,11 +305,11 @@ describe("kernel + workflowManager integration", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
});
const kernel = createKernel(initialConfig, "/tmp/nerve-test", {
const kernel = createKernel(initialConfig, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
@@ -354,7 +362,7 @@ describe("kernel + workflowManager integration", () => {
workflows: { "old-workflow": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(initialConfig, "/tmp/nerve-test", {
const kernel = createKernel(initialConfig, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
@@ -365,7 +373,7 @@ describe("kernel + workflowManager integration", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
@@ -413,7 +421,7 @@ describe("kernel + workflowManager integration", () => {
workflows: { "shutdown-test": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
@@ -439,7 +447,7 @@ describe("kernel + workflowManager integration", () => {
workflows: { "my-wf": { concurrency: 1, overflow: "drop" } },
});
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
@@ -462,7 +470,7 @@ describe("kernel + workflowManager integration", () => {
workflows: { "health-wf": { concurrency: 2, overflow: "drop" } },
});
const kernel = createKernel(config, "/tmp/nerve-test", {
const kernel = createKernel(config, nerveRoot, {
workerScript: "fake-worker.js",
logStore,
});
+19 -9
View File
@@ -59,7 +59,7 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -70,13 +70,17 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
// ---------------------------------------------------------------------------
describe("kernel — message routing", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-msg-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
it("routes signal message to bus without throwing", async () => {
@@ -86,7 +90,7 @@ describe("kernel — message routing", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
expect(mockChildren.length).toBe(1);
const child = mockChildren[0];
@@ -129,7 +133,7 @@ describe("kernel — message routing", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
const child = mockChildren[0];
child.emit("message", { type: "error", sense: "cpu-usage", error: "compute failed" });
@@ -150,7 +154,7 @@ describe("kernel — message routing", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
const child = mockChildren[0];
const callsBefore = stderrSpy.mock.calls.length;
@@ -170,7 +174,7 @@ describe("kernel — message routing", () => {
},
reflexes: [],
});
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
const child = mockChildren[0];
expect(() => child.emit("message", { type: "unknown-type" })).not.toThrow();
@@ -183,13 +187,17 @@ describe("kernel — message routing", () => {
});
describe("kernel — groupForSense mapping", () => {
let nerveRoot: string;
beforeEach(() => {
mockChildren.length = 0;
vi.useFakeTimers({ shouldAdvanceTime: true });
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-kernel-groups-"));
});
afterEach(() => {
vi.useRealTimers();
rmSync(nerveRoot, { recursive: true, force: true });
});
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 },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
const kernel = createKernel(config, "/tmp/nerve-test");
const kernel = createKernel(config, nerveRoot);
// system and network = 2 unique groups
expect(mockChildren.length).toBe(2);
@@ -215,9 +223,9 @@ describe("kernel — groupForSense mapping", () => {
senses: {
"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];
vi.advanceTimersByTime(500);
@@ -225,5 +233,7 @@ describe("kernel — groupForSense mapping", () => {
expect(child.send).toHaveBeenCalledWith(
expect.objectContaining({ type: "compute", sense: "cpu-usage" }),
);
await kernel.stop();
});
});
@@ -29,7 +29,7 @@ describe("LogStore + ReflexScheduler integration", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
workflows: null,
workflows: {},
maxRounds: 10,
};
const bus = createSignalBus();
@@ -38,7 +38,7 @@ describe("LogStore + ReflexScheduler integration", () => {
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);
const logs = logStore.query({ source: "reflex", type: "run_start" });
@@ -56,8 +56,8 @@ describe("LogStore + ReflexScheduler integration", () => {
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: null }],
workflows: null,
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: 1000, on: [] }],
workflows: {},
maxRounds: 10,
};
const bus = createSignalBus();
@@ -88,7 +88,7 @@ describe("LogStore + ReflexScheduler integration", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [{ kind: "sense", sense: "cpu-usage", interval: null, on: ["cpu-usage"] }],
workflows: null,
workflows: {},
maxRounds: 10,
};
const bus = createSignalBus();
@@ -108,7 +108,7 @@ describe("LogStore + ReflexScheduler integration", () => {
type: "run_complete",
refId: "cpu-usage",
payload: '{"v":99}',
ts: Date.now(),
timestamp: Date.now(),
});
// 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.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Signal } from "@uncaged/nerve-core";
import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createKernel } 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 },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
@@ -55,17 +57,23 @@ async function pollUntil(
describe("phase6 — restartGroup", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-restart-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
it("restartGroup stops old worker and spawns a new one", async () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
@@ -97,7 +105,7 @@ describe("phase6 — restartGroup", () => {
it("restartGroup on nonexistent group does nothing", async () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
@@ -113,17 +121,23 @@ describe("phase6 — restartGroup", () => {
describe("phase6 — reloadConfig", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-reload-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
it("adds new group when new sense group is introduced", async () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
@@ -136,7 +150,7 @@ describe("phase6 — reloadConfig", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
@@ -156,10 +170,10 @@ describe("phase6 — reloadConfig", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
@@ -171,7 +185,7 @@ describe("phase6 — reloadConfig", () => {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
@@ -187,12 +201,18 @@ describe("phase6 — reloadConfig", () => {
describe("phase6 — error isolation", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-err-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
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 },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
@@ -238,7 +258,7 @@ describe("phase6 — error isolation", () => {
process.stderr.write = stderrSpy as typeof process.stderr.write;
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: ERROR_WORKER,
});
await kernel.ready;
@@ -261,12 +281,18 @@ describe("phase6 — error isolation", () => {
describe("phase6 — getHealth", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-health-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
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 },
},
});
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
@@ -293,7 +319,7 @@ describe("phase6 — getHealth", () => {
it("health reflects config changes after reloadConfig", async () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
@@ -306,7 +332,7 @@ describe("phase6 — getHealth", () => {
"net-rx": { group: "network", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
};
kernel.reloadConfig(newConfig);
@@ -322,17 +348,23 @@ describe("phase6 — getHealth", () => {
describe("phase6 — auto-respawn on worker crash", () => {
let kernel: Kernel | null = null;
let nerveRoot: string;
beforeEach(() => {
nerveRoot = mkdtempSync(join(tmpdir(), "nerve-phase6-crash-"));
});
afterEach(async () => {
if (kernel !== null) {
await kernel.stop();
kernel = null;
}
rmSync(nerveRoot, { recursive: true, force: true });
});
it("kernel auto-respawns worker and new worker is functional", async () => {
const config = makeConfig();
kernel = createKernel(config, "/tmp/nerve-phase6-test", {
kernel = createKernel(config, nerveRoot, {
workerScript: MOCK_WORKER,
});
await kernel.ready;
@@ -10,14 +10,14 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
}
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", () => {
@@ -16,14 +16,14 @@ function makeConfig(overrides: Partial<NerveConfig> = {}): NerveConfig {
"system-health": { group: "derived", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
workflows: null,
workflows: {},
maxRounds: 10,
...overrides,
};
}
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", () => {
const triggered: string[] = [];
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();
// 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", () => {
const triggered: string[] = [];
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 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", () => {
const triggered: string[] = [];
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 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";
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", () => {
@@ -115,7 +115,7 @@ describe("WorkflowManager", () => {
});
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[0].send).toHaveBeenCalledWith(
@@ -131,8 +131,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10 });
mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10 });
mgr.startWorkflow("my-workflow", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("my-workflow", { prompt: "test 2", maxRounds: 10, dryRun: false });
// Only one forked child — worker is reused
expect(mockChildren).toHaveLength(1);
@@ -147,7 +147,7 @@ describe("WorkflowManager", () => {
});
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.objectContaining({ source: "workflow", type: "started" }),
@@ -164,9 +164,9 @@ describe("WorkflowManager", () => {
});
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
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.queueLength("drop-wf")).toBe(0);
@@ -181,8 +181,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("drop-wf", { prompt: "test", maxRounds: 10 });
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, dryRun: false });
const droppedCall = logStore.upsertWorkflowRun.mock.calls.find(
([entry]) => entry.type === "dropped",
@@ -199,8 +199,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("queue-wf")).toBe(1);
expect(mgr.queueLength("queue-wf")).toBe(1);
@@ -213,8 +213,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "test", maxRounds: 10 });
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, dryRun: false });
const queuedCall = logStore.upsertWorkflowRun.mock.calls.find(
([entry]) => entry.type === "queued",
@@ -233,12 +233,12 @@ describe("WorkflowManager", () => {
const mgr = createWorkflowManager("/nerve-root", config, logStore);
// 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
mgr.startWorkflow("queue-wf", { prompt: "test 1", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "test 2", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "test 1", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "test 2", maxRounds: 10, dryRun: false });
// 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)
expect(mgr.queueLength("queue-wf")).toBe(2);
@@ -259,8 +259,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
expect(mgr.activeCount("queue-wf")).toBe(1);
expect(mgr.queueLength("queue-wf")).toBe(1);
@@ -294,8 +294,8 @@ describe("WorkflowManager", () => {
});
const mgr = createWorkflowManager("/nerve-root", config, logStore);
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10 });
mgr.startWorkflow("queue-wf", { prompt: "first", maxRounds: 10, dryRun: false });
mgr.startWorkflow("queue-wf", { prompt: "second", maxRounds: 10, dryRun: false });
const child = mockChildren[0];
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);
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10 });
mgr.startWorkflow("wf-a", { prompt: "test", maxRounds: 10, dryRun: false });
mgr.startWorkflow("wf-b", { prompt: "test", maxRounds: 10, dryRun: false });
// Two distinct workers should have been forked
expect(mockChildren).toHaveLength(2);
@@ -348,7 +348,7 @@ describe("WorkflowManager", () => {
await vi.runAllTimersAsync();
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
expect(mockChildren).toHaveLength(0);
@@ -361,7 +361,7 @@ describe("WorkflowManager", () => {
const config = makeConfig({});
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(logStore.upsertWorkflowRun).not.toHaveBeenCalled();
+7
View File
@@ -55,6 +55,7 @@ export function createDaemonIpcServer(
workflowManager.startWorkflow(req.workflow, {
prompt: req.prompt,
maxRounds: req.maxRounds,
dryRun: req.dryRun,
});
const resp: DaemonIpcResponse = { ok: true };
socket.write(`${JSON.stringify(resp)}\n`);
@@ -66,6 +67,12 @@ export function createDaemonIpcServer(
const senses = opts.listSenses();
const resp: DaemonIpcResponse = { ok: true, senses };
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;
+35 -2
View File
@@ -34,6 +34,8 @@ export type StartThreadMessage = {
prompt: string;
/** Safety-valve: max moderator rounds for this thread (engine launch parameter). */
maxRounds: number;
/** When true, roles may skip side effects (thread-level hint on the start frame). */
dryRun: boolean;
};
/** Parent → Workflow Worker: resume an existing thread after crash recovery */
@@ -44,6 +46,14 @@ export type ResumeThreadMessage = {
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>;
/** Safety-valve: max moderator rounds for this thread. */
maxRounds: number;
/** Thread-level dry-run hint (aligns with persisted `__start__` meta when replaying). */
dryRun: boolean;
};
/** Parent → Workflow Worker: kill a specific running thread */
export type KillThreadMessage = {
type: "kill-thread";
runId: string;
};
/** Union of all messages the parent sends to a worker */
@@ -52,7 +62,8 @@ export type ParentToWorkerMessage =
| ShutdownMessage
| HealthRequestMessage
| StartThreadMessage
| ResumeThreadMessage;
| ResumeThreadMessage
| KillThreadMessage;
/** Worker → Parent: compute produced a signal */
export type SignalMessage = {
@@ -85,7 +96,13 @@ export type HealthResponseMessage = {
// ---------------------------------------------------------------------------
/** 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.
@@ -102,6 +119,8 @@ export type WorkflowErrorMessage = {
type: "workflow-error";
runId: string;
error: string;
/** Exit code conveying the failure reason (1=role error, 2=maxRounds exhausted). */
exitCode: number;
};
/** Workflow Worker → Parent: a WorkflowMessage produced by a role (for crash recovery). */
@@ -128,6 +147,7 @@ const PARENT_MSG_TYPES = new Set([
"health-request",
"start-thread",
"resume-thread",
"kill-thread",
]);
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.prompt !== "string") return "'start-thread' message missing string 'prompt'";
if (typeof obj.maxRounds !== "number") return "'start-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'start-thread' message missing boolean 'dryRun'";
return null;
}
@@ -143,6 +164,7 @@ function validateResumeThreadMsg(obj: Record<string, unknown>): string | null {
if (!Array.isArray(obj.messages)) return "'resume-thread' message missing 'messages' array";
if (typeof obj.maxRounds !== "number")
return "'resume-thread' message missing number 'maxRounds'";
if (typeof obj.dryRun !== "boolean") return "'resume-thread' message missing boolean 'dryRun'";
return null;
}
@@ -180,6 +202,7 @@ export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage>
workflow: obj.workflow,
prompt: obj.prompt,
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as StartThreadMessage);
}
if (obj.type === "resume-thread") {
@@ -191,8 +214,15 @@ export function parseParentMessage(raw: unknown): Result<ParentToWorkerMessage>
runId: obj.runId,
messages: obj.messages as ResumeThreadMessage["messages"],
maxRounds: obj.maxRounds,
dryRun: obj.dryRun,
} as ResumeThreadMessage);
}
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}"`));
}
@@ -246,6 +276,7 @@ function isThreadEventType(value: string): value is ThreadEventType {
case "step_complete":
case "completed":
case "failed":
case "killed":
return true;
default:
return false;
@@ -279,10 +310,12 @@ function parseWorkflowErrorMsg(obj: Record<string, unknown>): Result<WorkerToPar
if (typeof obj.error !== "string") {
return err(new Error("Worker 'workflow-error' message missing string 'error' field"));
}
const exitCode = typeof obj.exitCode === "number" ? obj.exitCode : 1;
return ok({
type: "workflow-error",
runId: obj.runId,
error: obj.error,
exitCode,
});
}
+3 -3
View File
@@ -38,7 +38,7 @@ export function createKernelFileWatchHandlers(deps: KernelFileWatchDeps): Kernel
type: "sense_reload",
refId: senseName,
payload: null,
ts: Date.now(),
timestamp: Date.now(),
});
deps.restartGroup(sc.group).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
@@ -55,7 +55,7 @@ export function createKernelFileWatchHandlers(deps: KernelFileWatchDeps): Kernel
type: "workflow_reload",
refId: workflowName,
payload: null,
ts: Date.now(),
timestamp: Date.now(),
});
deps.workflowManager.drainAndRespawn(workflowName).catch((e) => {
const msg = e instanceof Error ? e.message : String(e);
@@ -70,7 +70,7 @@ export function createKernelFileWatchHandlers(deps: KernelFileWatchDeps): Kernel
type: "config_reload",
refId: null,
payload: null,
ts: Date.now(),
timestamp: Date.now(),
});
try {
const raw = readFileSync(join(deps.nerveRoot, "nerve.yaml"), "utf8");
+10 -10
View File
@@ -81,7 +81,7 @@ export function createKernel(
type: "start",
refId: null,
payload: null,
ts: startTime,
timestamp: startTime,
});
let config = initialConfig;
@@ -138,7 +138,7 @@ export function createKernel(
type: "error",
refId: msg.sense,
payload: JSON.stringify({ error: msg.error }),
ts: Date.now(),
timestamp: Date.now(),
});
scheduler.onComputeComplete(msg.sense);
return;
@@ -148,27 +148,27 @@ export function createKernel(
const route = routeSenseComputeOutput(msg.payload);
if (route.kind === "launch") {
const { workflowName, maxRounds, prompt } = route.launch;
workflowManager.startWorkflow(workflowName, { prompt, maxRounds });
workflowManager.startWorkflow(workflowName, { prompt, maxRounds, dryRun: false });
logStore.append({
source: "sense",
type: "workflow-launch",
refId: msg.sense,
payload: JSON.stringify(route.launch),
ts: Date.now(),
timestamp: Date.now(),
});
} else {
const signal: Signal = {
id: nextSignalId(),
senseId: msg.sense,
payload: route.payload,
ts: Date.now(),
timestamp: Date.now(),
};
logStore.append({
source: "sense",
type: "signal",
refId: msg.sense,
payload: JSON.stringify(route.payload),
ts: signal.ts,
timestamp: signal.timestamp,
});
bus.emit(signal);
}
@@ -239,7 +239,7 @@ export function createKernel(
function reloadConfig(newConfig: NerveConfig): void {
const oldGroups = collectSenseGroups(config);
const oldConfig = config;
const oldWorkflows = config.workflows ?? {};
const oldWorkflows = config.workflows;
config = newConfig;
scheduler.stop();
scheduler = createReflexScheduler(config, bus, triggerFn, {
@@ -247,7 +247,7 @@ export function createKernel(
});
workflowManager.updateConfig(newConfig);
const newWorkflows = newConfig.workflows ?? {};
const newWorkflows = newConfig.workflows;
for (const workflowName of Object.keys(oldWorkflows)) {
if (!(workflowName in newWorkflows)) {
@@ -327,7 +327,7 @@ export function createKernel(
group: senseConfig.group,
throttle: senseConfig.throttle,
timeout: senseConfig.timeout,
lastSignalTs: lastEntry !== null ? lastEntry.ts : null,
lastSignalTimestamp: lastEntry !== null ? lastEntry.timestamp : null,
};
});
},
@@ -352,7 +352,7 @@ export function createKernel(
type: "stop",
refId: null,
payload: null,
ts: Date.now(),
timestamp: Date.now(),
});
logStore.close();
}
+2 -2
View File
@@ -78,7 +78,7 @@ export function createReflexScheduler(
type: "run_start",
refId: senseName,
payload: null,
ts: Date.now(),
timestamp: Date.now(),
});
triggerFn(senseName);
}
@@ -164,7 +164,7 @@ export function createReflexScheduler(
intervals.push(id);
}
if (senseReflex.on !== null && senseReflex.on.length > 0) {
if (senseReflex.on.length > 0) {
const watchedSenses = new Set(senseReflex.on);
const unsub = bus.subscribe((signal) => {
if (watchedSenses.has(signal.senseId)) {
+91 -24
View File
@@ -16,6 +16,7 @@ import { START, isPlainRecord } from "@uncaged/nerve-core";
import type { LogStore, WorkflowRunStatus } from "@uncaged/nerve-store";
import type {
KillThreadMessage,
ResumeThreadMessage,
ShutdownMessage,
StartThreadMessage,
@@ -31,11 +32,17 @@ import {
export type WorkflowLaunchParams = {
prompt: string;
maxRounds: number;
dryRun: boolean;
};
export type WorkflowManager = {
/** Trigger a new workflow thread (Sense-driven launch or CLI / IPC). */
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. */
activeCount: (workflowName: string) => number;
/** Number of pending queued threads waiting to run for a workflow. */
@@ -58,6 +65,7 @@ type PendingThread = {
runId: string;
prompt: string;
maxRounds: number;
dryRun: boolean;
};
type WorkflowState = {
@@ -90,20 +98,22 @@ const DEFAULT_MAX_QUEUE = 100;
function readLaunchFromTriggerPayload(
raw: unknown,
engineDefaultMaxRounds: number,
): { prompt: string; maxRounds: number } {
): { prompt: string; maxRounds: number; dryRun: boolean } {
if (isPlainRecord(raw)) {
const o = raw;
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(
messages: Array<{ role: string; content: string; meta: unknown; timestamp: number }>,
fallbackPrompt: string,
fallbackMaxRounds: number,
fallbackDryRun: boolean,
): WorkflowMessage[] {
const mapped: WorkflowMessage[] = messages.map((m) => ({
role: m.role,
@@ -117,7 +127,7 @@ function ensureThreadMessagesWithStart(
const start: WorkflowMessage = {
role: START,
content: fallbackPrompt,
meta: { maxRounds: fallbackMaxRounds },
meta: { maxRounds: fallbackMaxRounds, dryRun: fallbackDryRun },
timestamp: Date.now(),
};
return [start, ...mapped];
@@ -177,6 +187,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> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
@@ -213,7 +233,7 @@ export function createWorkflowManager(
}
function workflowConfig(workflowName: string): WorkflowConfig | null {
return config.workflows?.[workflowName] ?? null;
return config.workflows[workflowName] ?? null;
}
function toWorkflowRunStatus(eventType: string): WorkflowRunStatus | null {
@@ -225,24 +245,39 @@ export function createWorkflowManager(
crashed: "crashed",
dropped: "dropped",
interrupted: "interrupted",
killed: "killed",
};
return map[eventType] ?? null;
}
function extractExitCode(payload: unknown): number | null {
if (isPlainRecord(payload) && typeof payload.exitCode === "number") {
return payload.exitCode;
}
return null;
}
function logWorkflowEvent(
workflowName: string,
runId: string,
eventType: string,
payload?: unknown,
exitCode: number | null = null,
): void {
const ts = Date.now();
const timestamp = Date.now();
const serialised = payload !== undefined ? JSON.stringify(payload) : null;
const status = toWorkflowRunStatus(eventType);
if (status !== null) {
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 {
logStore.append({
@@ -250,7 +285,7 @@ export function createWorkflowManager(
type: eventType,
refId: runId,
payload: serialised,
ts,
timestamp,
});
}
}
@@ -260,6 +295,7 @@ export function createWorkflowManager(
runId: string,
prompt: string,
maxRounds: number,
dryRun: boolean,
): void {
const state = getOrCreateState(workflowName);
state.active.add(runId);
@@ -271,9 +307,10 @@ export function createWorkflowManager(
workflow: workflowName,
prompt,
maxRounds,
dryRun,
};
sendStartThread(worker.process, msg);
logWorkflowEvent(workflowName, runId, "started", { prompt, maxRounds });
logWorkflowEvent(workflowName, runId, "started", { prompt, maxRounds, dryRun });
}
function dequeueNext(workflowName: string): void {
@@ -286,7 +323,7 @@ export function createWorkflowManager(
if (state.active.size < concurrency) {
const next = state.queue.shift();
if (next !== undefined) {
dispatchThread(workflowName, next.runId, next.prompt, next.maxRounds);
dispatchThread(workflowName, next.runId, next.prompt, next.maxRounds, next.dryRun);
}
}
}
@@ -295,13 +332,11 @@ export function createWorkflowManager(
const state = states.get(workflowName);
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);
dequeueNext(workflowName);
}
if (msg.eventType === "completed" || msg.eventType === "failed") {
logWorkflowEvent(workflowName, msg.runId, msg.eventType, msg.payload);
const exitCode = extractExitCode(msg.payload);
logWorkflowEvent(workflowName, msg.runId, msg.eventType, msg.payload, exitCode);
}
}
@@ -311,7 +346,12 @@ export function createWorkflowManager(
logStore.getTriggerPayload(runId),
config.maxRounds,
);
state.queue.push({ runId, prompt: launch.prompt, maxRounds: launch.maxRounds });
state.queue.push({
runId,
prompt: launch.prompt,
maxRounds: launch.maxRounds,
dryRun: launch.dryRun,
});
process.stderr.write(
`[workflow-manager] crash-recovery: re-queued thread "${runId}" for "${workflowName}"\n`,
);
@@ -329,13 +369,19 @@ export function createWorkflowManager(
logStore.getTriggerPayload(runId),
config.maxRounds,
);
const messages = ensureThreadMessagesWithStart(rawMessages, launch.prompt, launch.maxRounds);
const messages = ensureThreadMessagesWithStart(
rawMessages,
launch.prompt,
launch.maxRounds,
launch.dryRun,
);
state.active.add(runId);
const msg: ResumeThreadMessage = {
type: "resume-thread",
runId,
messages,
maxRounds: launch.maxRounds,
dryRun: launch.dryRun,
};
sendResumeThread(worker.process, msg);
process.stderr.write(
@@ -376,7 +422,7 @@ export function createWorkflowManager(
`[workflow-manager] worker for "${workflowName}" crashed with ${crashedCount} active thread(s)\n`,
);
for (const runId of state.active) {
logWorkflowEvent(workflowName, runId, "crashed");
logWorkflowEvent(workflowName, runId, "crashed", undefined, 255);
}
}
@@ -423,7 +469,7 @@ export function createWorkflowManager(
type: "thread_workflow_message",
refId: msg.runId,
payload: JSON.stringify(msg.message),
ts: Date.now(),
timestamp: Date.now(),
});
return;
}
@@ -437,7 +483,7 @@ export function createWorkflowManager(
state.active.delete(msg.runId);
dequeueNext(workflowName);
}
logWorkflowEvent(workflowName, msg.runId, "failed", { error: msg.error });
logWorkflowEvent(workflowName, msg.runId, "failed", { error: msg.error }, msg.exitCode);
return;
}
@@ -518,6 +564,26 @@ export function createWorkflowManager(
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 {
if (stopped) return;
@@ -531,10 +597,10 @@ export function createWorkflowManager(
const state = getOrCreateState(workflowName);
const runId = crypto.randomUUID();
const { prompt, maxRounds } = launch;
const { prompt, maxRounds, dryRun } = launch;
if (state.active.size < wfConfig.concurrency) {
dispatchThread(workflowName, runId, prompt, maxRounds);
dispatchThread(workflowName, runId, prompt, maxRounds, dryRun);
return;
}
@@ -559,7 +625,7 @@ export function createWorkflowManager(
}
}
state.queue.push({ runId, prompt, maxRounds });
state.queue.push({ runId, prompt, maxRounds, dryRun });
logWorkflowEvent(workflowName, runId, "queued");
process.stderr.write(
`[workflow-manager] queued thread for "${workflowName}" runId "${runId}" (queue length: ${state.queue.length})\n`,
@@ -629,6 +695,7 @@ export function createWorkflowManager(
return {
startWorkflow,
killThread,
activeCount,
queueLength,
totalActiveCount,
+146 -51
View File
@@ -12,13 +12,7 @@
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import type {
Moderator,
RoleMeta,
StartSignal,
WorkflowDefinition,
WorkflowMessage,
} from "@uncaged/nerve-core";
import type { RoleMeta, StartStep, WorkflowDefinition, WorkflowMessage } from "@uncaged/nerve-core";
import { END, START, isPlainRecord } from "@uncaged/nerve-core";
import type {
@@ -29,8 +23,6 @@ import type {
import { parseParentMessage } from "./ipc.js";
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
type ModeratorInput = Parameters<Moderator<RoleMeta>>[0];
// ---------------------------------------------------------------------------
// IPC helpers
// ---------------------------------------------------------------------------
@@ -49,8 +41,8 @@ function sendThreadEvent(runId: string, eventType: ThreadEventType, payload: unk
send({ type: "thread-event", runId, eventType, payload });
}
function sendWorkflowError(runId: string, error: string): void {
send({ type: "workflow-error", runId, error });
function sendWorkflowError(runId: string, error: string, exitCode = 1): void {
send({ type: "workflow-error", runId, error, exitCode });
}
function sendWorkflowMessage(runId: string, message: WorkflowMessage): void {
@@ -87,42 +79,92 @@ function validateRoleResult(
return true;
}
function buildInitialLastSignal(lastMsg: WorkflowMessage): ModeratorInput {
if (lastMsg.role === START) {
return {
role: START,
content: lastMsg.content,
meta: lastMsg.meta as StartSignal["meta"],
timestamp: lastMsg.timestamp,
};
}
return { role: lastMsg.role, meta: lastMsg.meta as Record<string, unknown> };
function isStartMeta(meta: unknown): meta is StartStep["meta"] {
return (
isPlainRecord(meta) && typeof meta.maxRounds === "number" && typeof meta.dryRun === "boolean"
);
}
function initChain(
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,
): WorkflowMessage[] {
dryRun: boolean,
): ThreadMessagesState {
if (resumeMessages.length > 0) {
return [...resumeMessages];
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 startMsg: WorkflowMessage = {
const start: StartStep = {
role: START,
content: prompt,
meta: { maxRounds },
meta: { maxRounds, dryRun },
timestamp: Date.now(),
};
sendWorkflowMessage(runId, startMsg);
return [startMsg];
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,
chain: WorkflowMessage[],
start: StartStep,
messages: WorkflowMessage[],
runId: string,
): Promise<{ content: string; meta: Record<string, unknown> } | null> {
const role = def.roles[nextRole];
@@ -133,10 +175,10 @@ async function executeRole(
let result: { content: string; meta: Record<string, unknown> };
try {
result = await role(chain);
result = await role(start, messages);
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
sendThreadEvent(runId, "failed", { error: errMsg });
sendThreadEvent(runId, "failed", { error: errMsg, exitCode: 1 });
return null;
}
@@ -144,31 +186,62 @@ async function executeRole(
return result;
}
type KillFlag = { value: boolean };
async function runThread(
def: WorkflowDefinition<RoleMeta>,
runId: string,
maxRounds: number,
killFlag: KillFlag,
resumeMessages: WorkflowMessage[] = [],
freshPrompt: string | null = null,
dryRun = false,
): Promise<void> {
const chain = initChain(runId, resumeMessages, freshPrompt, maxRounds);
const { start, messages: roleMessages } = initThreadMessages(
runId,
resumeMessages,
freshPrompt,
maxRounds,
dryRun,
);
let roleRound = chain.filter((m) => m.role !== START).length;
const lastMsg = chain[chain.length - 1];
if (lastMsg === undefined) {
sendWorkflowError(runId, "empty workflow message chain");
const steps: Array<{
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;
}
let nextRole = def.moderator(buildInitialLastSignal(lastMsg), roleRound, maxRounds);
let nextRole = def.moderator({ start, steps });
if (nextRole === END) {
sendThreadEvent(runId, "completed", null);
sendThreadEvent(runId, "completed", { exitCode: 0 });
return;
}
while (roleRound < maxRounds) {
const result = await executeRole(def, nextRole, chain, runId);
while (steps.length < maxRounds) {
const result = await executeRole(def, nextRole, start, roleMessages, runId);
if (killFlag.value) {
sendThreadEvent(runId, "killed", { exitCode: 137 });
return;
}
if (result === null) return;
const message: WorkflowMessage = {
@@ -177,21 +250,25 @@ async function runThread(
meta: result.meta,
timestamp: Date.now(),
};
chain.push(message);
roleMessages.push(message);
sendWorkflowMessage(runId, message);
roleRound += 1;
steps.push({
role: nextRole,
meta: result.meta,
content: result.content,
timestamp: message.timestamp,
});
const signal: ModeratorInput = { role: nextRole, meta: result.meta };
nextRole = def.moderator(signal, roleRound, maxRounds);
nextRole = def.moderator({ start, steps });
if (nextRole === END) {
sendThreadEvent(runId, "completed", null);
sendThreadEvent(runId, "completed", { exitCode: 0 });
return;
}
}
sendWorkflowError(runId, `Thread exceeded maximum rounds (${maxRounds})`);
sendWorkflowError(runId, `Thread exceeded maximum rounds (${maxRounds})`, 2);
}
// ---------------------------------------------------------------------------
@@ -246,6 +323,7 @@ function handleMessage(
raw: unknown,
def: WorkflowDefinition<RoleMeta>,
inFlight: Map<string, Promise<void>>,
killFlags: Map<string, KillFlag>,
shuttingDown: { value: boolean },
): void {
const parseResult = parseParentMessage(raw);
@@ -267,17 +345,21 @@ function handleMessage(
if (msg.type === "start-thread") {
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 next = previous
.then(() => runThread(def, runId, maxRounds, [], prompt))
.then(() => runThread(def, runId, maxRounds, killFlag, [], prompt, dryRun))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendWorkflowError(runId, errMsg);
})
.finally(() => {
inFlight.delete(runId);
killFlags.delete(runId);
});
inFlight.set(runId, next);
@@ -286,22 +368,34 @@ function handleMessage(
if (msg.type === "resume-thread") {
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 next = previous
.then(() => runThread(def, runId, maxRounds, messages, null))
.then(() => runThread(def, runId, maxRounds, killFlag, messages, null, dryRun))
.catch((e: unknown) => {
const errMsg = e instanceof Error ? e.message : String(e);
sendWorkflowError(runId, errMsg);
})
.finally(() => {
inFlight.delete(runId);
killFlags.delete(runId);
});
inFlight.set(runId, next);
return;
}
if (msg.type === "kill-thread") {
const flag = killFlags.get(msg.runId);
if (flag !== undefined) {
flag.value = true;
}
return;
}
}
// ---------------------------------------------------------------------------
@@ -319,12 +413,13 @@ async function bootstrap(nerveRoot: string, workflowName: string): Promise<void>
}
const inFlight = new Map<string, Promise<void>>();
const killFlags = new Map<string, KillFlag>();
const shuttingDown = { value: false };
sendReady();
process.on("message", (raw: unknown) => {
handleMessage(raw, def, inFlight, shuttingDown);
handleMessage(raw, def, inFlight, killFlags, shuttingDown);
});
}
+48
View File
@@ -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
@@ -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", () => {
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: "reflex", type: "y", refId: "z", payload: null, ts: ts + 1 });
store.append({ source: "system", type: "x", refId: null, payload: '{"a":1}', timestamp: ts });
store.append({ source: "reflex", type: "y", refId: "z", payload: null, timestamp: ts + 1 });
const now = nowForLastArchivableFeb1();
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", () => {
const now = Date.UTC(2026, 3, 23, 12, 0, 0);
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 });
expect(r.days).toHaveLength(0);
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)", () => {
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();
store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
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", () => {
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();
store.archiveLogs({ now, retentionMs: 30 * DAY_MS });
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 });
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", () => {
const t1 = Date.UTC(2026, 1, 1, 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: "b", refId: null, payload: null, ts: t2 });
store.append({ source: "system", type: "a", refId: null, payload: null, timestamp: t1 });
store.append({ source: "system", type: "b", refId: null, payload: null, timestamp: t2 });
const now = Date.UTC(2027, 0, 1, 12, 0, 0);
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", () => {
store.setMeta(LOG_ARCHIVE_META_KEY, "2026-01-10");
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 });
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", () => {
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({
now: nowForLastArchivableFeb1(),
retentionMs: 30 * DAY_MS,
@@ -39,9 +39,9 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
type: "started",
refId: "run-1",
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");
@@ -55,9 +55,9 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
type: "started",
refId: "run-2",
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();
@@ -72,14 +72,14 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
type: "started",
refId: "run-3",
payload: JSON.stringify({ triggerPayload: payloadA }),
ts: 100,
timestamp: 100,
});
store.append({
source: "workflow",
type: "started",
refId: "run-3",
payload: JSON.stringify({ triggerPayload: payloadB }),
ts: 200,
timestamp: 200,
});
const result = store.getTriggerPayload("run-3");
@@ -106,7 +106,7 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
type: "thread_command_event",
refId: "run-4",
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",
refId: "run-5",
payload: null,
ts: 1000,
timestamp: 1000,
});
store.append({
source: "workflow",
type: "thread_command_event",
refId: "run-5",
payload: JSON.stringify({ type: "valid_event" }),
ts: 1001,
timestamp: 1001,
});
const result = store.getThreadEvents("run-5");
@@ -146,23 +146,23 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
type: "started",
refId: "run-6",
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({
source: "workflow",
type: "thread_command_event",
refId: "run-6",
payload: JSON.stringify({ type: "step_one" }),
ts: 1001,
timestamp: 1001,
});
store.append({
source: "workflow",
type: "step_complete",
refId: "run-6",
payload: JSON.stringify({ message: "done step" }),
ts: 1002,
timestamp: 1002,
});
const result = store.getThreadEvents("run-6");
@@ -176,14 +176,14 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
type: "thread_command_event",
refId: "run-7",
payload: JSON.stringify({ type: "event_for_7" }),
ts: 1000,
timestamp: 1000,
});
store.append({
source: "workflow",
type: "thread_command_event",
refId: "run-8",
payload: JSON.stringify({ type: "event_for_8" }),
ts: 1001,
timestamp: 1001,
});
const result7 = store.getThreadEvents("run-7");
@@ -203,7 +203,7 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
type: "thread_command_event",
refId: "run-tr",
payload: JSON.stringify({ type: "thread_start", triggerPayload: { x: 1 } }),
ts: 100,
timestamp: 100,
});
store.append({
source: "workflow",
@@ -215,14 +215,14 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
content: "hello",
meta: 1,
}),
ts: 101,
timestamp: 101,
});
store.append({
source: "workflow",
type: "thread_command_event",
refId: "run-tr",
payload: JSON.stringify({ type: "step_b", role: "beta", content: "world" }),
ts: 102,
timestamp: 102,
});
expect(store.getThreadRoundCount("run-tr")).toBe(2);
@@ -241,7 +241,7 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
type: "thread_command_event",
refId: "run-b4",
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",
workflow: "cleanup",
status: "started",
ts: 1000,
timestamp: 1000,
exitCode: null,
};
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,
);
@@ -47,23 +48,29 @@ describe("LogStore — workflow_runs", () => {
expect(stored?.runId).toBe("run-1");
expect(stored?.workflow).toBe("cleanup");
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)", () => {
store.upsertWorkflowRun(
{ source: "workflow", type: "started", refId: "run-2", payload: null, ts: 1000 },
{ runId: "run-2", workflow: "cleanup", status: "started", ts: 1000 },
{ source: "workflow", type: "started", refId: "run-2", payload: null, timestamp: 1000 },
{ runId: "run-2", workflow: "cleanup", status: "started", timestamp: 1000, exitCode: null },
);
store.upsertWorkflowRun(
{ source: "workflow", type: "completed", refId: "run-2", payload: null, ts: 2000 },
{ runId: "run-2", workflow: "cleanup", status: "completed", ts: 2000 },
{ source: "workflow", type: "completed", refId: "run-2", payload: null, timestamp: 2000 },
{
runId: "run-2",
workflow: "cleanup",
status: "completed",
timestamp: 2000,
exitCode: null,
},
);
const stored = store.getWorkflowRun("run-2");
expect(stored?.status).toBe("completed");
expect(stored?.ts).toBe(2000);
expect(stored?.timestamp).toBe(2000);
// Both log entries should be present (event sourcing)
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", () => {
for (const [type, status, ts] of [
for (const [type, status, timestamp] of [
["queued", "queued", 1000],
["started", "started", 1001],
["step_complete", "started", 1002],
["completed", "completed", 1005],
] as const) {
store.upsertWorkflowRun(
{ source: "workflow", type, refId: "run-3", payload: null, ts },
{ runId: "run-3", workflow: "cleanup", status, ts },
{ source: "workflow", type, refId: "run-3", payload: null, timestamp },
{ 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", () => {
store.upsertWorkflowRun(
{ source: "workflow", type: "queued", refId: "run-4", payload: null, ts: 100 },
{ runId: "run-4", workflow: "code-review", status: "queued", ts: 100 },
{ source: "workflow", type: "queued", refId: "run-4", payload: null, timestamp: 100 },
{
runId: "run-4",
workflow: "code-review",
status: "queued",
timestamp: 100,
exitCode: null,
},
);
store.upsertWorkflowRun(
{ source: "workflow", type: "started", refId: "run-4", payload: null, ts: 200 },
{ runId: "run-4", workflow: "code-review", status: "started", ts: 200 },
{ source: "workflow", type: "started", refId: "run-4", payload: null, timestamp: 200 },
{
runId: "run-4",
workflow: "code-review",
status: "started",
timestamp: 200,
exitCode: null,
},
);
const run = store.getWorkflowRun("run-4");
expect(run?.status).toBe("started");
expect(run?.ts).toBe(200);
expect(run?.timestamp).toBe(200);
});
});
describe("getActiveWorkflowRuns", () => {
beforeEach(() => {
store.upsertWorkflowRun(
{ source: "workflow", type: "queued", refId: "r1", payload: null, ts: 100 },
{ runId: "r1", workflow: "cleanup", status: "queued", ts: 100 },
{ source: "workflow", type: "queued", refId: "r1", payload: null, timestamp: 100 },
{ runId: "r1", workflow: "cleanup", status: "queued", timestamp: 100, exitCode: null },
);
store.upsertWorkflowRun(
{ source: "workflow", type: "started", refId: "r2", payload: null, ts: 200 },
{ runId: "r2", workflow: "cleanup", status: "started", ts: 200 },
{ source: "workflow", type: "started", refId: "r2", payload: null, timestamp: 200 },
{ runId: "r2", workflow: "cleanup", status: "started", timestamp: 200, exitCode: null },
);
store.upsertWorkflowRun(
{ source: "workflow", type: "completed", refId: "r3", payload: null, ts: 300 },
{ runId: "r3", workflow: "cleanup", status: "completed", ts: 300 },
{ source: "workflow", type: "completed", refId: "r3", payload: null, timestamp: 300 },
{ runId: "r3", workflow: "cleanup", status: "completed", timestamp: 300, exitCode: null },
);
store.upsertWorkflowRun(
{ source: "workflow", type: "failed", refId: "r4", payload: null, ts: 400 },
{ runId: "r4", workflow: "deploy", status: "queued", ts: 400 },
{ source: "workflow", type: "failed", refId: "r4", payload: null, timestamp: 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);
});
it("returns runs ordered by ts ascending", () => {
it("returns runs ordered by timestamp ascending", () => {
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", () => {
it.each(["queued", "started", "completed", "failed", "crashed", "dropped"] as const)(
it.each(["queued", "started", "completed", "failed", "crashed", "dropped", "killed"] as const)(
"stores status=%s",
(status) => {
const runId = `run-${status}`;
store.upsertWorkflowRun(
{ source: "workflow", type: status, refId: runId, payload: null, ts: 1 },
{ runId, workflow: "test", status, ts: 1 },
{ source: "workflow", type: status, refId: runId, payload: null, timestamp: 1 },
{ runId, workflow: "test", status, timestamp: 1, exitCode: null },
);
expect(store.getWorkflowRun(runId)?.status).toBe(status);
},
+67 -19
View File
@@ -27,7 +27,7 @@ describe("LogStore", () => {
type: "start",
refId: null,
payload: null,
ts: 1000,
timestamp: 1000,
});
expect(entry.id).toBe(1);
@@ -41,28 +41,40 @@ describe("LogStore", () => {
type: "start",
refId: null,
payload: null,
ts: 1000,
timestamp: 1000,
});
const e2 = store.append({
source: "system",
type: "stop",
refId: null,
payload: null,
ts: 2000,
timestamp: 2000,
});
expect(e2.id).toBe((e1.id ?? 0) + 1);
});
it("returns all entries when queried with no filter", () => {
store.append({ source: "system", type: "start", refId: null, payload: null, ts: 1000 });
store.append({ source: "reflex", type: "run_start", refId: "cpu", payload: null, ts: 2000 });
store.append({
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({
source: "reflex",
type: "run_complete",
refId: "cpu",
payload: '{"v":42}',
ts: 3000,
timestamp: 3000,
});
const all = store.query();
@@ -72,23 +84,35 @@ describe("LogStore", () => {
describe("query filters", () => {
beforeEach(() => {
store.append({ source: "system", type: "start", refId: null, payload: null, ts: 1000 });
store.append({ source: "reflex", type: "run_start", refId: "cpu", payload: null, ts: 2000 });
store.append({
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({
source: "reflex",
type: "run_complete",
refId: "cpu",
payload: '{"v":42}',
ts: 3000,
timestamp: 3000,
});
store.append({
source: "system",
type: "error",
refId: "disk",
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", () => {
@@ -111,7 +135,7 @@ describe("LogStore", () => {
it("filters by since (inclusive)", () => {
const results = store.query({ since: 3000 });
expect(results).toHaveLength(3);
expect(results[0].ts).toBe(3000);
expect(results[0].timestamp).toBe(3000);
});
it("filters by until (inclusive)", () => {
@@ -146,12 +170,24 @@ describe("LogStore", () => {
describe("query ordering", () => {
it("returns entries in insertion order (ascending id)", () => {
store.append({ source: "system", type: "start", refId: null, payload: null, ts: 5000 });
store.append({ source: "reflex", type: "run_start", refId: "a", payload: null, ts: 1000 });
store.append({
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();
expect(all[0].ts).toBe(5000);
expect(all[1].ts).toBe(1000);
expect(all[0].timestamp).toBe(5000);
expect(all[1].timestamp).toBe(1000);
});
});
@@ -182,7 +218,7 @@ describe("LogStore", () => {
describe("append-only semantics", () => {
it("ids are always increasing", () => {
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++) {
@@ -194,7 +230,13 @@ describe("LogStore", () => {
describe("payload JSON round-trip", () => {
it("preserves JSON payload", () => {
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" });
expect(results).toHaveLength(1);
@@ -206,7 +248,13 @@ describe("LogStore", () => {
it("creates nested directory structure for db path", () => {
const deepPath = join(tmpDir, "a", "b", "c", "test.db");
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);
deepStore.close();
});
+113 -73
View File
@@ -35,7 +35,7 @@ export type LogEntry = {
type: string;
refId: string | null;
payload: string | null;
ts: number;
timestamp: number;
};
export type LogQuery = {
@@ -58,7 +58,8 @@ export type WorkflowRunStatus =
| "failed"
| "crashed"
| "dropped"
| "interrupted";
| "interrupted"
| "killed";
const VALID_WORKFLOW_STATUSES = new Set<string>([
"queued",
@@ -68,6 +69,7 @@ const VALID_WORKFLOW_STATUSES = new Set<string>([
"crashed",
"dropped",
"interrupted",
"killed",
]);
function isWorkflowRunStatus(value: string): value is WorkflowRunStatus {
@@ -86,14 +88,15 @@ export type WorkflowRun = {
runId: string;
workflow: string;
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). */
export type ThreadRoundRow = {
round: number;
logId: number;
ts: number;
timestamp: number;
message: { role: string; content: string; meta: unknown; timestamp: number };
};
@@ -131,7 +134,7 @@ export type LogStore = {
*/
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.
*/
getAllWorkflowRuns: (workflowName: string | null) => WorkflowRun[];
@@ -174,16 +177,16 @@ export type LogStore = {
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
type TEXT NOT NULL,
ref_id TEXT,
payload TEXT,
ts INTEGER NOT NULL
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
type TEXT NOT NULL,
ref_id TEXT,
payload TEXT,
timestamp INTEGER NOT NULL
);
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 TABLE IF NOT EXISTS meta (
@@ -192,10 +195,11 @@ CREATE TABLE IF NOT EXISTS meta (
);
CREATE TABLE IF NOT EXISTS workflow_runs (
run_id TEXT PRIMARY KEY,
workflow TEXT NOT NULL,
status TEXT NOT NULL,
ts INTEGER NOT NULL
run_id TEXT PRIMARY KEY,
workflow TEXT NOT NULL,
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
exit_code INTEGER
);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
@@ -208,7 +212,7 @@ type SqlLogRow = {
type: string;
ref_id: string | null;
payload: string | null;
ts: number;
timestamp: number;
};
function buildJsonlBody(rows: SqlLogRow[]): string {
@@ -220,7 +224,7 @@ function buildJsonlBody(rows: SqlLogRow[]): string {
type: r.type,
refId: r.ref_id,
payload: r.payload,
ts: r.ts,
timestamp: r.timestamp,
}),
);
return `${lines.join("\n")}\n`;
@@ -242,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 {
if (vacuum !== true) return false;
sqlite.exec("VACUUM");
@@ -297,8 +336,15 @@ export function createLogStore(dbPath: string): LogStore {
sqlite.exec("PRAGMA journal_mode=WAL");
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(
"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 = ?");
@@ -307,11 +353,11 @@ export function createLogStore(dbPath: string): LogStore {
);
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(
"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(
@@ -336,7 +382,7 @@ export function createLogStore(dbPath: string): LogStore {
const getThreadRoundsStmt = sqlite.prepare(
`WITH numbered AS (
SELECT id, ts, payload,
SELECT id, timestamp, payload,
ROW_NUMBER() OVER (ORDER BY id ASC) AS rn
FROM logs
WHERE source = 'workflow' AND type IN ('thread_command_event', 'thread_workflow_message') AND ref_id = @runId
@@ -344,34 +390,34 @@ export function createLogStore(dbPath: string): LogStore {
AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_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)
ORDER BY rn DESC
LIMIT @lim`,
);
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(
"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(
"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(
"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(
"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(
"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 {
@@ -381,13 +427,14 @@ export function createLogStore(dbPath: string): LogStore {
type: entry.type,
refId: entry.refId,
payload: entry.payload,
ts: entry.ts,
timestamp: entry.timestamp,
});
upsertWorkflowRunStmt.run({
runId: run.runId,
workflow: run.workflow,
status: run.status,
ts: run.ts,
timestamp: run.timestamp,
exitCode: run.exitCode,
});
return { ...entry, id: Number(info.lastInsertRowid) };
});
@@ -399,7 +446,7 @@ export function createLogStore(dbPath: string): LogStore {
type: entry.type,
refId: entry.refId,
payload: entry.payload,
ts: entry.ts,
timestamp: entry.timestamp,
});
return { ...entry, id: Number(info.lastInsertRowid) };
}
@@ -421,17 +468,17 @@ export function createLogStore(dbPath: string): LogStore {
params.refId = filter.refId;
}
if (filter.since !== undefined) {
conditions.push("ts >= @since");
conditions.push("timestamp >= @since");
params.since = filter.since;
}
if (filter.until !== undefined) {
conditions.push("ts <= @until");
conditions.push("timestamp <= @until");
params.until = filter.until;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
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<{
id: number;
@@ -439,7 +486,7 @@ export function createLogStore(dbPath: string): LogStore {
type: string;
ref_id: string | null;
payload: string | null;
ts: number;
timestamp: number;
}>;
return rows.map((r) => ({
@@ -448,7 +495,7 @@ export function createLogStore(dbPath: string): LogStore {
type: r.type,
refId: r.ref_id,
payload: r.payload,
ts: r.ts,
timestamp: r.timestamp,
}));
}
@@ -469,31 +516,37 @@ export function createLogStore(dbPath: string): LogStore {
return upsertWorkflowRunTx(entry, run);
}
function getWorkflowRun(runId: string): WorkflowRun | null {
const row = getWorkflowRunStmt.get(runId) as
| { run_id: string; workflow: string; status: string; ts: number }
| undefined;
if (row === undefined) return null;
type SqlWorkflowRunRow = {
run_id: string;
workflow: string;
status: string;
timestamp: number;
exit_code: number | null;
};
function mapWorkflowRunRow(r: SqlWorkflowRunRow): WorkflowRun {
return {
runId: row.run_id,
workflow: row.workflow,
status: validateWorkflowRunStatus(row.status),
ts: row.ts,
runId: r.run_id,
workflow: r.workflow,
status: validateWorkflowRunStatus(r.status),
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[] {
const rows = (
workflowName !== undefined
? getActiveWorkflowRunsByNameStmt.all(workflowName)
: getActiveWorkflowRunsStmt.all()
) as Array<{ run_id: string; workflow: string; status: string; ts: number }>;
return rows.map((r) => ({
runId: r.run_id,
workflow: r.workflow,
status: validateWorkflowRunStatus(r.status),
ts: r.ts,
}));
) as SqlWorkflowRunRow[];
return rows.map(mapWorkflowRunRow);
}
function getAllWorkflowRuns(workflowName: string | null): WorkflowRun[] {
@@ -501,27 +554,14 @@ export function createLogStore(dbPath: string): LogStore {
workflowName !== null
? getAllWorkflowRunsByNameStmt.all(workflowName)
: getAllWorkflowRunsStmt.all()
) as Array<{ run_id: string; workflow: string; status: string; ts: number }>;
return rows.map((r) => ({
runId: r.run_id,
workflow: r.workflow,
status: validateWorkflowRunStatus(r.status),
ts: r.ts,
}));
) as SqlWorkflowRunRow[];
return rows.map(mapWorkflowRunRow);
}
function getTriggerPayload(runId: string): unknown {
const row = getTriggerPayloadStmt.get(runId) as { payload: string | null } | undefined;
if (row === undefined || row.payload === null) return null;
try {
const parsed: unknown = JSON.parse(row.payload);
if (isPlainRecord(parsed)) {
return parsed.triggerPayload ?? null;
}
} catch {
// malformed
}
return null;
return triggerPayloadFromStartedLogJson(row.payload);
}
function getThreadEvents(runId: string): Array<{ type: string; [key: string]: unknown }> {
@@ -623,14 +663,14 @@ export function createLogStore(dbPath: string): LogStore {
runId,
before: params.before,
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[] = [];
for (const row of rows) {
if (row.payload === null) continue;
const message = parseRoundPayload(row.payload, row.ts);
const message = parseRoundPayload(row.payload, row.timestamp);
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;
+25
View File
@@ -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"
}
}
+19
View File
@@ -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,151 @@
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 });
});
it("dryRun applies dryRunDefaults over schema 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,
dryRunDefaults: { n: 42 },
});
expect(fetchMock).not.toHaveBeenCalled();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({ n: 42 });
});
});
@@ -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,
});
});
});
+47
View File
@@ -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);
}
+23
View File
@@ -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";
+195
View File
@@ -0,0 +1,195 @@
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;
dryRunDefaults?: Partial<T>;
};
type LlmExtractOptionsInput<T> = LlmExtractOptions<T> | Omit<LlmExtractOptions<T>, "dryRun">;
function resolveLlmExtractDryRun<T>(options: LlmExtractOptionsInput<T>): boolean {
return "dryRun" in options ? options.dryRun : false;
}
function buildLlmExtractDryRunValue<T>(options: LlmExtractOptionsInput<T>): T {
return { ...schemaDefaults(options.schema), ...(options.dryRunDefaults ?? {}) } as T;
}
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(buildLlmExtractDryRunValue(options));
}
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);
}
+159
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false
},
"include": ["src"]
}
+27 -2
View File
@@ -69,7 +69,7 @@ importers:
version: link:../store
drizzle-orm:
specifier: 1.0.0-beta.23-c10d10c
version: 1.0.0-beta.23-c10d10c(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.11(@azure/core-client@1.10.1))(better-sqlite3@11.10.0)(mssql@11.0.1(@azure/core-client@1.10.1))(sql.js@1.14.1)
version: 1.0.0-beta.23-c10d10c(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.11(@azure/core-client@1.10.1))(better-sqlite3@11.10.0)(mssql@11.0.1(@azure/core-client@1.10.1))(sql.js@1.14.1)(zod@4.3.6)
yaml:
specifier: ^2.8.3
version: 2.8.3
@@ -100,6 +100,25 @@ importers:
specifier: ^4.1.5
version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3))
packages/workflow-utils:
dependencies:
'@uncaged/nerve-core':
specifier: workspace:*
version: link:../core
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@rslib/core':
specifier: ^0.21.3
version: 0.21.3(typescript@5.9.3)
'@types/node':
specifier: ^22.0.0
version: 22.19.17
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3))
packages:
'@ast-grep/napi-darwin-arm64@0.37.0':
@@ -1472,6 +1491,9 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
snapshots:
'@ast-grep/napi-darwin-arm64@0.37.0':
@@ -2169,13 +2191,14 @@ snapshots:
detect-libc@2.1.2: {}
drizzle-orm@1.0.0-beta.23-c10d10c(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.11(@azure/core-client@1.10.1))(better-sqlite3@11.10.0)(mssql@11.0.1(@azure/core-client@1.10.1))(sql.js@1.14.1):
drizzle-orm@1.0.0-beta.23-c10d10c(@types/better-sqlite3@7.6.13)(@types/mssql@9.1.11(@azure/core-client@1.10.1))(better-sqlite3@11.10.0)(mssql@11.0.1(@azure/core-client@1.10.1))(sql.js@1.14.1)(zod@4.3.6):
optionalDependencies:
'@types/better-sqlite3': 7.6.13
'@types/mssql': 9.1.11(@azure/core-client@1.10.1)
better-sqlite3: 11.10.0
mssql: 11.0.1(@azure/core-client@1.10.1)
sql.js: 1.14.1
zod: 4.3.6
ecdsa-sig-formatter@1.0.11:
dependencies:
@@ -2772,3 +2795,5 @@ snapshots:
optional: true
yaml@2.8.3: {}
zod@4.3.6: {}