diff --git a/.cursor/rules/global.mdc b/.cursor/rules/global.mdc index c9ff589..ee93412 100644 --- a/.cursor/rules/global.mdc +++ b/.cursor/rules/global.mdc @@ -9,7 +9,7 @@ alwaysApply: true ## Core Concepts ``` -External World → Sense → Signal → Reflex → Workflow → Log +External World → Sense(state) → { newState, workflow? } → Workflow → Log ↑ ↑ "what to observe" "what to do" ``` @@ -20,19 +20,17 @@ External World → Sense → Signal → Reflex → Workflow → Log | Concept | What it is | |---------|-----------| -| **Sense** | A `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. | -| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. | -| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. | +| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling (`interval`, `on`) is configured per sense in nerve.yaml. | | **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. | -| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. | -| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. | +| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger senses or workflows** — prevents feedback loops. | +| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. | | **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. | ### Architecture Rules -- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do) +- **Two orthogonal extension points**: Sense (what to observe + when), Workflow (what to do) - **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other. -- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain. +- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain. @@ -44,18 +42,18 @@ Use `function` + `type`, not `class` + `interface`. ```typescript // ✅ Good -type Signal = { - senseId: string; - value: unknown; +type WorkflowLaunch = { + senseName: string; + workflowName: string; ts: number; }; -function createSignal(senseId: string, value: unknown): Signal { - return { senseId, value, ts: Date.now() }; +function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch { + return { senseName, workflowName, ts: Date.now() }; } // ❌ Bad — no class, no interface -class Signal implements ISignal { ... } +class WorkflowLaunch implements IWorkflowLaunch { ... } ``` ### Rules @@ -100,9 +98,9 @@ For mutually exclusive fields, use discriminated unions: ```typescript // ✅ Good -type ReflexConfig = - | { kind: "sense"; sense: string; interval: string | null; on: string[] | null } - | { kind: "workflow"; workflow: string; on: string[] | null }; +type WorkflowConfig = + | { concurrency: number; overflow: "drop" } + | { concurrency: number; overflow: "queue"; maxQueue: number }; ``` ## Modules & Exports @@ -123,9 +121,9 @@ export default function startEngine() { ... } | Type | Style | Example | |------|-------|---------| -| Files | kebab-case | `signal-bus.ts` | -| Types | PascalCase | `SignalBus` | -| Functions/variables | camelCase | `createSignalBus` | +| Files | kebab-case | `sense-scheduler.ts` | +| Types | PascalCase | `SenseScheduler` | +| Functions/variables | camelCase | `createSenseScheduler` | | Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` | | Generics | Single letter or descriptive | `T`, `TValue` | diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5972be8..532b1b2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,7 +3,7 @@ ## Core Concepts ``` -External World → Sense → Signal → Reflex → Workflow → Log +External World → Sense(state) → { newState, workflow? } → Workflow → Log ↑ ↑ "what to observe" "what to do" ``` @@ -14,19 +14,17 @@ External World → Sense → Signal → Reflex → Workflow → Log | Concept | What it is | |---------|-----------| -| **Sense** | A `compute()` function that samples or derives data. Returns `T \| null` — non-null emits a Signal, null is silent. Each Sense has its own SQLite database. | -| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. | -| **Reflex** | A declarative trigger (YAML) connecting Senses to actions. Trigger types: `interval` (periodic), `on` (react to Signals). Action types: trigger a Sense, or start a Workflow. | +| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling (`interval`, `on`) is configured per sense in nerve.yaml. | | **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. | -| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger Reflexes** — prevents feedback loops. | -| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Reflex Scheduler, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. | +| **Log** | Immutable audit trail. Records executions, state transitions, errors. **Cannot trigger senses or workflows** — prevents feedback loops. | +| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. | | **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. | ### Architecture Rules -- **Three orthogonal extension points**: Sense (what to compute), Reflex (when to compute), Workflow (what to do) +- **Two orthogonal extension points**: Sense (what to observe + when), Workflow (what to do) - **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other. -- **Causality is one-directional**: External world → Sense → Signal → Reflex → Action + Log. Logs are the end of the chain. +- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain. @@ -38,18 +36,18 @@ Use `function` + `type`, not `class` + `interface`. ```typescript // ✅ Good -type Signal = { - senseId: string; - value: unknown; +type WorkflowLaunch = { + senseName: string; + workflowName: string; ts: number; }; -function createSignal(senseId: string, value: unknown): Signal { - return { senseId, value, ts: Date.now() }; +function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch { + return { senseName, workflowName, ts: Date.now() }; } // ❌ Bad — no class, no interface -class Signal implements ISignal { ... } +class WorkflowLaunch implements IWorkflowLaunch { ... } ``` ### Rules @@ -94,9 +92,9 @@ For mutually exclusive fields, use discriminated unions: ```typescript // ✅ Good -type ReflexConfig = - | { kind: "sense"; sense: string; interval: string | null; on: string[] | null } - | { kind: "workflow"; workflow: string; on: string[] | null }; +type WorkflowConfig = + | { concurrency: number; overflow: "drop" } + | { concurrency: number; overflow: "queue"; maxQueue: number }; ``` ## Modules & Exports @@ -108,9 +106,9 @@ type ReflexConfig = | Type | Style | Example | |------|-------|---------| -| Files | kebab-case | `signal-bus.ts` | -| Types | PascalCase | `SignalBus` | -| Functions/variables | camelCase | `createSignalBus` | +| Files | kebab-case | `sense-scheduler.ts` | +| Types | PascalCase | `SenseScheduler` | +| Functions/variables | camelCase | `createSenseScheduler` | | Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` | | Generics | Single letter or descriptive | `T`, `TValue` | diff --git a/.knowledge/monorepo.md b/.knowledge/monorepo.md index 33e26d2..13f3b35 100644 --- a/.knowledge/monorepo.md +++ b/.knowledge/monorepo.md @@ -5,7 +5,7 @@ nerve/ packages/ core/ # @uncaged/nerve-core — shared types, config parser, Result, spawn-safe cli/ # @uncaged/nerve-cli — CLI (init, validate, dev, daemon, knowledge) - daemon/ # @uncaged/nerve-daemon — kernel, workers, signal bus, scheduler + daemon/ # @uncaged/nerve-daemon — kernel, workers, sense scheduler, workflow manager store/ # @uncaged/nerve-store — append-only log, SQLite, CAS blob store workflow-utils/ # @uncaged/nerve-workflow-utils — role factories, extract, LLM helpers adapter-cursor/ # @uncaged/nerve-adapter-cursor — cursor-agent CLI adapter diff --git a/.knowledge/signal-routing.md b/.knowledge/signal-routing.md index 8481730..b5d07eb 100644 --- a/.knowledge/signal-routing.md +++ b/.knowledge/signal-routing.md @@ -1,91 +1,31 @@ -# Signal Routing +# Sense compute → workflow (RFC #308) -Signal routing is the core mechanism that determines how Sense outputs flow through the Nerve system. +Stateful senses no longer emit signals or pass outputs through `routeSenseComputeOutput`. The worker runs `compute(state)` and returns `{ state, workflow }`. -## Routing Logic - -When a Sense `compute()` function returns non-null, the output goes through `routeSenseComputeOutput()` in `packages/core/src/sense-workflow-directive.ts`: +## Flow ``` -Sense compute() → non-null → routeSenseComputeOutput() → { signal, workflow } - ↓ - kernel.ts → signal ALWAYS emitted + optional workflow start +Sense worker: compute(state) → { state, workflow } + ↓ + persist state JSON (data/senses/.json) + ↓ + IPC compute-result → kernel + ↓ + workflow !== null → parseWorkflowTrigger (validation) → workflowManager.startWorkflow + scheduler.onSenseCompleted(senseName) → dependents with `on: [senseName]` ``` -## Two Output Formats +## Workflow trigger shape -### 1. Explicit Format -```typescript -{ - signal: any, // emitted as signal - workflow: { // optional workflow trigger - name: string, - maxRounds: number, - prompt: string, - dryRun: boolean - } | null -} -``` +When `workflow` is non-null it must be a plain object validated by `parseWorkflowTrigger()` in `packages/core/src/sense.ts`: -### 2. Shorthand Format -Any other value is treated as: -```typescript -{ signal: payload, workflow: null } -``` +- `name`: non-empty string +- `maxRounds`: integer ≥ 1 +- `prompt`: string +- `dryRun`: boolean -## Workflow Directive Parsing +Invalid triggers are rejected by the daemon when handling the worker message (workflow is not started). -## Concrete Routing Predicates +## Scheduling -The routing decision is implemented in `routeSenseComputeOutput()` using these exact matching criteria: - -### 1. Explicit Format Detection -```typescript -if (isPlainRecord(payload) && Object.hasOwn(payload, "signal")) -``` -- Payload must be a plain object -- Must have `signal` property (any value) -- Workflow extracted from `workflow` property or defaults to null - -### 2. Workflow Validation -When workflow is non-null, it's validated via `parseWorkflowTrigger()`: -- `name`: non-empty string (trimmed) -- `maxRounds`: positive integer >= 1 -- `prompt`: string -- `dryRun`: boolean - -**Critical behavior**: Invalid workflows are silently dropped (become null) but signal emission continues. This prevents malformed workflow config from blocking signals. - -### 3. Fallback to Shorthand -Any value that doesn't match explicit format becomes: -```typescript -{ signal: payload, workflow: null } -``` - -## Processing Flow - -```typescript -// In kernel.ts handleSenseWorkerSignal() -const { signal: signalPayload, workflow } = routeResult.value; - -// Signal is ALWAYS emitted when compute returns non-null -bus.emit({ id, senseId, payload: signalPayload, timestamp }); - -// Workflow is started ONLY if workflow is non-null -if (workflow !== null) { - workflowManager.startWorkflow(workflow.name, { ... }); -} -``` - -## Legacy String Format (Deprecated) - -The old `"name|maxRounds|prompt"` string format is converted to the structured format internally but should not be used in new code. - -## Key Behaviors - -1. **Signal priority**: Every non-null compute result emits a signal, regardless of workflow -2. **Additive behavior**: Valid workflow triggers are executed in addition to signal emission -3. **Failure tolerance**: Invalid workflow directives are silently ignored, signal still emits -4. **Structure-based routing**: No complex predicates - simply checks object structure and property existence - -This routing mechanism ensures clean separation between perception (signals) and action (workflows) while maintaining backward compatibility. \ No newline at end of file +Other senses list this sense under `on` in `nerve.yaml` to be scheduled when this sense completes a successful compute (see sense scheduler reverse-index in the daemon). diff --git a/CLAUDE.md b/CLAUDE.md index 0c1fe51..b76440e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ ## Core Concepts ``` -External World → Sense → Signal → Workflow → Log +External World → Sense(state) → { newState, workflow? } → Workflow → Log ↑ ↑ "what to observe" "what to do" ``` @@ -14,18 +14,17 @@ External World → Sense → Signal → Workflow → Log | Concept | What it is | |---------|-----------| -| **Sense** | A `compute()` function that samples or derives data. Returns `ComputeResult` — non-null emits a Signal (and optionally triggers a Workflow), null is silent. Each Sense has its own SQLite database. Scheduling (interval, on) is configured in nerve.yaml. | -| **Signal** | A notification emitted when a Sense returns non-null. Pure fact, no intent. Distributed via an in-memory Signal Bus. Not persisted. | +| **Sense** | A stateful `compute(state)` function. Returns new state and an optional workflow trigger. State persisted as JSON. Scheduling configured in nerve.yaml. | | **Workflow** | A stateful multi-step execution. Contains **Roles** (actors with side effects) and a **Moderator** (pure router). Each instance is a **Thread** with a unique `runId`. | | **Log** | Immutable audit trail. Records executions, state transitions, errors. Cannot trigger Senses — prevents feedback loops. | -| **Engine** | The kernel orchestrating everything. Holds Signal Bus, Process Manager, Workflow Manager. Never loads user code directly — all user code runs in isolated Workers. | +| **Engine** | The kernel orchestrating everything. Holds Process Manager, Workflow Manager, Sense Scheduler. Never loads user code directly — all user code runs in isolated Workers. | | **Daemon** | The `nerve-daemon` package — engine runtime. Runs as a background process. | ### Architecture Rules - **Two extension points**: Sense (what to observe + when), Workflow (what to do) - **Process isolation**: One worker per Sense group (long-lived), one per Workflow type (on-demand). Workers never talk to each other. -- **Causality is one-directional**: External world → Sense → Signal → Workflow + Log. Logs are the end of the chain. +- **Causality is one-directional**: External world → Sense(state) → Workflow (when triggered) + Log. Logs are the end of the chain. @@ -37,18 +36,18 @@ Use `function` + `type`, not `class` + `interface`. ```typescript // ✅ Good -type Signal = { - senseId: string; - value: unknown; +type WorkflowLaunch = { + senseName: string; + workflowName: string; ts: number; }; -function createSignal(senseId: string, value: unknown): Signal { - return { senseId, value, ts: Date.now() }; +function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch { + return { senseName, workflowName, ts: Date.now() }; } // ❌ Bad — no class, no interface -class Signal implements ISignal { ... } +class WorkflowLaunch implements IWorkflowLaunch { ... } ``` ### Rules @@ -92,12 +91,20 @@ type SenseConfig = { For mutually exclusive fields, use discriminated unions: ```typescript -// ✅ Good -type ComputeResult = - | null - | { signal: T; workflow: WorkflowTrigger | null }; +// ✅ Good — sense modules return explicit next state + optional workflow trigger +type SenseComputeReturn = { + state: S; + workflow: WorkflowTrigger | null; +}; ``` +### Workflow Naming + +Workflow identifiers — `WorkflowDefinition.name`, the directory under `workflows/`, and keys under `workflows:` in `nerve.yaml` — must use **verb-first** kebab-case phrases so the name reads as an action. + +- ✅ `solve-issue`, `extract-knowledge`, `develop-sense` +- ❌ `knowledge-extraction`, `issue-solver` + ### Workflow authoring (user modules) Roles and moderators take **ThreadContext** (`threadId`, `start`, `steps`) — not separate `StartStep` / message arrays. @@ -132,9 +139,9 @@ const workflow: WorkflowDefinition> = { | Type | Style | Example | |------|-------|---------| -| Files | kebab-case | `signal-bus.ts` | -| Types | PascalCase | `SignalBus` | -| Functions/variables | camelCase | `createSignalBus` | +| Files | kebab-case | `sense-scheduler.ts` | +| Types | PascalCase | `SenseScheduler` | +| Functions/variables | camelCase | `createSenseScheduler` | | Constants | UPPER_SNAKE | `MAX_RETRY_COUNT` | | Generics | Single letter or descriptive | `T`, `TValue` | diff --git a/README.md b/README.md index d8ce67f..1403733 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,34 @@ **Observation engine for autonomous agents** — sense the world, react to changes, run workflows. -Nerve is a lightweight daemon that continuously observes external state through **Senses**, reacts via declarative **Reflexes**, and orchestrates multi-step **Workflows**. Built for the [Uncaged](https://github.com/uncaged) agent framework. +Nerve is a lightweight daemon that continuously observes external state through **Senses** (stateful `compute(state)` + JSON persistence), schedules them via **`interval` / `on`** in `nerve.yaml`, and orchestrates multi-step **Workflows**. Built for the [Uncaged](https://github.com/uncaged) agent framework. ## Core Concepts ``` -External World → Sense ─┬→ Signal → Reflex → Sense (scheduled compute) - │ - └→ Workflow (Sense return with workflow directive) → Log +External World → Sense(state) → { newState, workflow? } → Workflow → Log + ↑ + scheduling: interval / on (per sense in nerve.yaml) ``` | Concept | Metaphor | Role | |---------|----------|------| -| **Sense** | 👁️ Perception | A `compute()` function that samples or derives data. Each sense has its own SQLite database. | -| **Reflex** | ⚡ Reaction | Declarative rules that **only schedule Sense computes** (interval and/or `on` signal names). Reflex YAML cannot reference workflows. | -| **Signal** | 📡 Notification | Emitted when a sense returns a non-null value that is routed as a normal signal (see Sense → Workflow below). Other reflexes can listen via `on`. | -| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started from a Sense return value or from CLI/daemon IPC—not from reflex YAML. | -| **Log** | 📝 Record | Immutable audit trail. Queryable by senses, but **cannot** trigger reflexes (prevents feedback loops). | +| **Sense** | 👁️ Perception | Stateful `compute(state)` returning `{ state, workflow }`. State lives in `data/senses/.json`. | +| **Schedule** | ⏱️ When | Each sense entry sets optional `interval` (periodic) and `on: [other senses]` (run after those senses complete a compute). | +| **Workflow** | 🔧 Action | Stateful multi-step execution with Roles and a Moderator. Started when `workflow` is non-null in the compute result, or via CLI/daemon IPC. | +| **Log** | 📝 Record | Immutable audit trail. **Cannot** schedule senses or workflows (prevents feedback loops). | -**Sense → Workflow:** if `compute()` returns a plain object with a string field `workflow` in the form `name|maxRounds|prompt` (only the first two `|` delimit name and rounds; the rest is the prompt), the engine starts that workflow and **does not** emit a Signal for that return. `workflow: null` or `""` means “emit a signal” and strip the key from the payload. Invalid `workflow` strings are treated like a normal signal (directive stripped). See `@uncaged/nerve-core` `routeSenseComputeOutput` / `parseSenseWorkflowDirective`. +**Sense → Workflow:** when `workflow` is a structured object `{ name, maxRounds, prompt, dryRun }`, the kernel validates it (`@uncaged/nerve-core` `parseWorkflowTrigger`) and starts that workflow. Use `workflow: null` when no run should start. -Three extension points for **what / when / multi-step action** — reflexes never replace Sense-driven workflow launches. +Two extension points for **what to observe (+ when)** vs **multi-step action** — scheduling is declarative config on each sense, not a separate YAML section. ## Packages | Package | Description | |---------|-------------| -| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, Sense→workflow routing, daemon IPC protocol | +| [`@uncaged/nerve-core`](./packages/core) | Shared types, config parser, workflow trigger validation, daemon IPC protocol | | [`@uncaged/nerve-store`](./packages/store) | Append-only log SQLite, JSONL archive, CAS blob store, workflow run rows | -| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, workers, signal bus, sense scheduler, workflow manager, file watcher, IPC | +| [`@uncaged/nerve-daemon`](./packages/daemon) | Kernel, sense workers, sense scheduler, workflow manager, file watcher, IPC | | [`@uncaged/nerve-cli`](./packages/cli) | CLI (`nerve`) — init, validate, daemon, dev, logs, sense, store, workflow | ## Quick Start @@ -43,24 +42,28 @@ pnpm add -g @uncaged/nerve-cli mkdir my-agent && cd my-agent nerve init -# Write a sense -cat > senses/cpu-usage/compute.ts << 'EOF' -export async function compute() { - const [load] = (await import("node:os")).loadavg(); - return load > 2.0 ? { load } : null; // signal only when load is high +# Write a sense (see `nerve init` for the full template) +mkdir -p senses/cpu-usage/src +cat > senses/cpu-usage/src/index.ts << 'EOF' +import { loadavg } from "node:os"; + +type CpuState = { lastLoad: number }; + +export const initialState: CpuState = { lastLoad: 0 }; + +export async function compute(state: CpuState) { + const [oneMin] = loadavg(); + const lastLoad = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0; + return { state: { lastLoad }, workflow: null }; } EOF -# Configure reflexes in nerve.yaml +# Configure scheduling on each sense in nerve.yaml cat > nerve.yaml << 'EOF' senses: cpu-usage: group: system throttle: 10s - -reflexes: - - kind: sense - sense: cpu-usage interval: 30s EOF @@ -73,7 +76,7 @@ nerve logs # view logs ## Configuration -`nerve.yaml` declares senses, reflexes (sense-only), optional workflows (concurrency), and optional engine `max_rounds`: +`nerve.yaml` declares senses (each with optional `interval` / `on`), optional workflows (concurrency), and optional engine `max_rounds`. Top-level `reflexes` is **not** supported — use `interval` and `on` on each sense. ```yaml max_rounds: 100 # default moderator cap (e.g. CLI workflow trigger) @@ -84,12 +87,15 @@ senses: throttle: 10s # min interval between computes timeout: 30s # max compute duration grace_period: 5s # wait before first compute after startup - -reflexes: - - kind: sense - sense: cpu-usage interval: 30s # periodic trigger - on: [disk-pressure] # also trigger on signals from other senses + + derived-example: + group: system + throttle: null + timeout: 30s + grace_period: null + on: + - cpu-usage # run after cpu-usage completes a compute workflows: cleanup: @@ -101,17 +107,24 @@ workflows: max_queue: 20 ``` -YAML must **not** include `workflow:` under `reflexes` — the parser rejects it. Declare workflows under `workflows:` and start them from Sense `compute()` or `nerve workflow trigger`. +Declare workflows under `workflows:` and start them from Sense `compute()` (non-null `workflow`) or `nerve workflow trigger`. -**Example — Sense starts a workflow** (`senses/disk-pressure/compute.ts`): +**Example — Sense starts a workflow** (`senses/disk-pressure/src/index.ts`): ```typescript -export async function compute() { +export const initialState = { checked: false }; + +export async function compute(state: typeof initialState) { const full = await diskNearlyFull(); - if (!full) return null; + if (!full) return { state: { ...state, checked: true }, workflow: null }; return { - path: "/data", - workflow: "cleanup|10|Disk partition nearly full", // name|maxRounds|prompt + state: { ...state, checked: true }, + workflow: { + name: "cleanup", + maxRounds: 10, + prompt: "Disk partition nearly full", + dryRun: false, + }, }; } ``` @@ -138,12 +151,9 @@ export async function compute() { │ │ └──────────────┼──────────────┘ │ │ │ ▼ │ │ │ ┌──────────────┐ │ -│ │ │ Signal Bus │ │ +│ │ │Sense Scheduler │ +│ │ │(interval + on) │ │ │ └──────┬───────┘ │ -│ │ ▼ │ -│ │ ┌──────────────────┐ │ -│ │ │ Reflex Scheduler│ │ -│ │ └────────┬─────────┘ │ │ │ ▼ │ │ │ ┌───────────────────┐ │ │ └───────────────────►│ Workflow Manager │──→ @uncaged/nerve-store │ @@ -152,8 +162,7 @@ export async function compute() { ``` - **Worker pool** — one child process per sense group; isolation between groups. -- **Signal Bus** — in-memory pub/sub for signal distribution. -- **Reflex Scheduler** — interval timers + signal subscriptions, with throttle/coalesce. +- **Sense scheduler** — interval timers + `on` reverse-index (upstream sense → dependents), with throttle/coalesce. - **Workflow Manager** — concurrency (drop/queue), per-workflow workers, crash recovery. - **File watcher** — hot reload for config, sense modules, and workflow modules. - **Daemon IPC** — Unix domain socket; used by the CLI when the daemon is running. @@ -161,8 +170,8 @@ export async function compute() { ## Tech Stack -- **Zero native addons** — uses Node.js built-in `node:sqlite` (DatabaseSync) -- **Drizzle ORM** v1.0 for sense databases +- **Zero native addons** — uses Node.js built-in `node:sqlite` (DatabaseSync) for logs / workflow persistence via `@uncaged/nerve-store` +- **Sense state as JSON** — files under `data/senses/` written by sense workers - **rslib** (rspack) for building - **Biome** for formatting/linting - **Vitest** for testing @@ -180,7 +189,7 @@ pnpm -r test # run all tests ## Design Documents -- [RFC-001: Observation Engine](./docs/rfc-001-observation-engine.md) — Sense, Signal, Reflex model +- [RFC-001: Observation Engine](./docs/rfc-001-observation-engine.md) — historical sense / scheduling model (superseded in places by stateful senses — see `CLAUDE.md`) - [RFC-002: Workflow Engine](./docs/rfc-002-workflow-engine.md) — Stateful workflow execution - [Coding Conventions](./docs/coding-conventions.md) diff --git a/docs/coding-conventions.md b/docs/coding-conventions.md index ca3356c..b4e4880 100644 --- a/docs/coding-conventions.md +++ b/docs/coding-conventions.md @@ -8,25 +8,29 @@ ```typescript // ✅ Good -type Signal = { - senseId: string - value: unknown +type WorkflowLaunch = { + senseName: string + workflowName: string ts: number } -function createSignal(senseId: string, value: unknown): Signal { - return { senseId, value, ts: Date.now() } +function recordWorkflowLaunch(senseName: string, workflowName: string): WorkflowLaunch { + return { senseName, workflowName, ts: Date.now() } } // ❌ Bad -interface ISignal { - senseId: string - value: unknown +interface IWorkflowLaunch { + senseName: string + workflowName: string ts: number } -class Signal implements ISignal { - constructor(public senseId: string, public value: unknown, public ts: number) {} +class WorkflowLaunch implements IWorkflowLaunch { + constructor( + public senseName: string, + public workflowName: string, + public ts: number, + ) {} } ``` @@ -65,17 +69,16 @@ type SenseConfig = { 当多个字段互斥时,用 discriminated union 代替一堆 optional: ```typescript -// ✅ Good — 编译器保证 sense 和 workflow 不会同时出现 -type ReflexConfig = - | { kind: "sense"; sense: string; interval: string | null; on: string[] | null } - | { kind: "workflow"; workflow: string; on: string[] | null } +// ✅ Good — 编译器保证两种 overflow 形态互斥且字段完整 +type WorkflowConfig = + | { concurrency: number; overflow: "drop" } + | { concurrency: number; overflow: "queue"; maxQueue: number } -// ❌ Bad — sense 和 workflow 都 optional,运行时才知道到底填了哪个 -type ReflexConfig = { - sense?: string; - workflow?: string; - interval?: string; - on?: string[]; +// ❌ Bad — 字段一堆 optional,运行时才知道到底填了哪种并发策略 +type WorkflowConfig = { + concurrency?: number; + overflow?: string; + maxQueue?: number; } ``` @@ -103,9 +106,9 @@ export default function startEngine() { ... } | 类型 | 风格 | 示例 | |------|------|------| -| 文件 | kebab-case | `signal-bus.ts` | -| 类型 | PascalCase | `SignalBus` | -| 函数/变量 | camelCase | `createSignalBus` | +| 文件 | kebab-case | `sense-scheduler.ts` | +| 类型 | PascalCase | `SenseScheduler` | +| 函数/变量 | camelCase | `createSenseScheduler` | | 常量 | UPPER_SNAKE | `MAX_RETRY_COUNT` | | 泛型参数 | 单字母或描述性 | `T`, `TValue` | diff --git a/docs/dead-code-analysis.md b/docs/dead-code-analysis.md index 6ec0955..d1f169f 100644 --- a/docs/dead-code-analysis.md +++ b/docs/dead-code-analysis.md @@ -117,7 +117,7 @@ | 项目 | 位置 | 说明 | 置信度 | 建议 | |------|------|------|--------|------| -| 已更名 API 仍出现在 README | `packages/core/README.md` | 仍描述 `parseSenseWorkflowDirective`、`ParsedSenseWorkflowDirective`、`SenseComputeRoute`;源码已为 `parseWorkflowTrigger` / `routeSenseComputeOutput` / `RoutedSenseOutput` | **高** | **更新文档**(本次分析不改代码,仅记录)。 | +| ~~已更名 API 仍出现在 README~~ | `packages/core/README.md` | (已修正)文档与 stateful sense、`parseWorkflowTrigger` 对齐;`routeSenseComputeOutput` 已移除 | — | 关闭 | | Hermes 选项合并注释 | `packages/workflow-utils/src/shared/hermes-agent.ts` | 注释称 absorbed from `hermes-options.ts`,该文件已不存在 | **中** | **清理注释**,避免误导。 | | `KNOWN_AGENT_ADAPTER_IDS` 含 `codex` | `packages/core/src/agent.ts` | 仓内无 `codex` 适配器包;与常量未被引用叠加 | **中** | **对齐产品**:实现适配器或从列表移除。 | @@ -130,7 +130,7 @@ 1. **高优先级调查**: `createEchoAgent` 与 `KNOWN_AGENT_ADAPTER_IDS` — 要么接入运行时,要么删减以免维护假象。 2. **API 面收敛**: `parseDurationStringToMs`、`labelSenseTrigger` 若无意对外,可从 `core` 公共导出移除。 3. **`workflow-utils`**: 评估 `isDryRun` 删除;`spawnSafe` 等从 `workflow-utils` 再导出是否仍有必要。 -4. **文档**: 修正 `packages/core/README.md` 中 Sense→Workflow 路由 API 名称。 +4. ~~**文档**: 修正 `packages/core/README.md` 中 Sense→Workflow 路由 API 名称。~~(已完成) --- diff --git a/examples/cpu-usage/index.ts b/examples/cpu-usage/index.ts index 690781c..510dc12 100644 --- a/examples/cpu-usage/index.ts +++ b/examples/cpu-usage/index.ts @@ -1,22 +1,17 @@ import { loadavg } from "node:os"; -import type { DrizzleDB, PeerMap } from "@uncaged/nerve-daemon"; +type CpuState = { + samples: Array<{ ts: number; value: number }>; +}; -import { samples } from "./schema.js"; +export const initialState: CpuState = { samples: [] }; -/** - * Read the 1-minute CPU load average, persist it, and emit a Signal. - * - * Returns `null` only if `loadavg` is unavailable (non-POSIX platforms). - * On every successful read a row is inserted and a Signal is emitted with the load value. - */ -export async function compute(db: DrizzleDB, _peers: PeerMap) { +export async function compute(state: CpuState): Promise<{ + state: CpuState; + workflow: null; +}> { const [oneMin] = loadavg(); - - if (typeof oneMin !== "number" || Number.isNaN(oneMin)) { - return null; - } - - await db.insert(samples).values({ ts: Date.now(), value: oneMin }); - return { signal: oneMin, workflow: null }; + const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0; + const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }]; + return { state: { samples: newSamples }, workflow: null }; } diff --git a/examples/cpu-usage/migrations/0001_init.sql b/examples/cpu-usage/migrations/0001_init.sql deleted file mode 100644 index 13f1821..0000000 --- a/examples/cpu-usage/migrations/0001_init.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Migration: 0001_init --- Creates the samples table for the cpu-usage sense. - -CREATE TABLE IF NOT EXISTS samples ( - ts INTEGER PRIMARY KEY, - value REAL NOT NULL -); diff --git a/examples/cpu-usage/schema.ts b/examples/cpu-usage/schema.ts deleted file mode 100644 index 49525e0..0000000 --- a/examples/cpu-usage/schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core"; - -/** - * Each row records one CPU load sample. - * `ts` is the Unix timestamp in milliseconds (primary key, append-only). - * `value` is the 1-minute load average from os.loadavg()[0]. - */ -export const samples = sqliteTable("samples", { - ts: integer("ts").primaryKey(), - value: real("value").notNull(), -}); diff --git a/examples/nerve.yaml b/examples/nerve.yaml index c2768fa..e3c67df 100644 --- a/examples/nerve.yaml +++ b/examples/nerve.yaml @@ -1,9 +1,9 @@ -# Example nerve.yaml demonstrating Signal Bus & Reflex Scheduler (Phase 3) +# Example nerve.yaml demonstrating per-sense scheduling (interval + on) # # Layout: # - cpu-usage: periodic every 10s, throttled to 5s minimum between computes # - disk-usage: periodic every 30s -# - system-health: derived sense, triggered whenever cpu-usage OR disk-usage emits +# - system-health: derived sense, scheduled when cpu-usage OR disk-usage completes a compute senses: cpu-usage: diff --git a/examples/senses/nerve-health.ts b/examples/senses/nerve-health.ts index 07dd3cd..34b7523 100644 --- a/examples/senses/nerve-health.ts +++ b/examples/senses/nerve-health.ts @@ -2,8 +2,7 @@ * nerve-health — built-in sense that reports daemon health via IPC. * * When running inside a sense worker, this compute function sends a - * "health-request" to the parent kernel process and returns the - * health-response payload as its signal. + * "health-request" to the parent kernel process and merges the response into state. * * Usage in nerve.yaml: * senses: @@ -22,9 +21,23 @@ export type NerveHealth = { workerUptime: number; }; -export async function compute() { +type HealthState = { + lastCheck: number | null; + lastHealth: NerveHealth | null; +}; + +export const initialState: HealthState = { lastCheck: null, lastHealth: null }; + +export async function compute(_state: HealthState): Promise<{ + state: HealthState; + workflow: null; +}> { + void _state; const health = await requestHealthFromKernel(); - return { signal: health, workflow: null }; + return { + state: { lastCheck: Date.now(), lastHealth: health }, + workflow: null, + }; } function requestHealthFromKernel(): Promise { diff --git a/package.json b/package.json index f324577..064f41b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "prepare": "husky", "build": "pnpm -r run build", + "test": "pnpm -r test", "check": "biome check .", "format": "biome format --write .", "link:dev": "bash scripts/link-dev.sh" diff --git a/packages/cli/README.md b/packages/cli/README.md index 8c548ee..b895929 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -51,10 +51,10 @@ Structured rows in `data/logs.db` are surfaced via **`nerve workflow inspect`** ```bash nerve sense list # List senses (live fields from daemon IPC when running) nerve sense trigger # IPC trigger-sense — queue a compute for that sense -nerve sense query # Read-only SQL on data/senses/.db (optional SQL args) -nerve sense schema # Print CREATE TABLE statements for that sense DB ``` +Sense state is persisted as JSON under `data/senses/.json` by the sense worker after each successful compute. + ### Store maintenance ```bash diff --git a/packages/cli/src/__tests__/create-sense.test.ts b/packages/cli/src/__tests__/create-sense.test.ts index 5c4c425..93e0a61 100644 --- a/packages/cli/src/__tests__/create-sense.test.ts +++ b/packages/cli/src/__tests__/create-sense.test.ts @@ -4,12 +4,7 @@ import { describe, expect, it } from "vitest"; -import { - buildSenseIndexTs, - buildSenseMigrationSql, - buildSenseSchemaTs, - validateResourceName, -} from "../commands/create.js"; +import { buildSenseIndexTs, validateResourceName } from "../commands/create.js"; describe("validateSenseName", () => { it("accepts valid ids", () => { @@ -25,39 +20,13 @@ describe("validateSenseName", () => { }); }); -describe("buildSenseSchemaTs", () => { - it("maps kebab-case id to snake table and camel export", () => { - const src = buildSenseSchemaTs("my-sense"); - expect(src).toContain('sqliteTable("my_sense"'); - expect(src).toContain("export const mySense = "); - }); - - it("handles single-segment id", () => { - const src = buildSenseSchemaTs("metrics"); - expect(src).toContain('sqliteTable("metrics"'); - expect(src).toContain("export const metrics = "); - }); -}); - -describe("buildSenseMigrationSql", () => { - it("uses snake_case table name", () => { - expect(buildSenseMigrationSql("disk-io")).toContain("CREATE TABLE IF NOT EXISTS disk_io"); - }); -}); - describe("buildSenseIndexTs", () => { - it("embeds sense id in stub with TypeScript types", () => { + it("generates a stateful sense stub with TypeScript types", () => { const ts = buildSenseIndexTs("my-sense"); - expect(ts).toContain("my-sense"); - expect(ts).toContain("export { mySense as table }"); + expect(ts).toContain("type SenseState"); + expect(ts).toContain("export const initialState"); expect(ts).toContain("export async function compute"); - expect(ts).toContain("LibSQLDatabase"); - expect(ts).toContain("Promise"); - expect(ts).toContain('from "./schema.js"'); - }); - - it("imports the correct schema export", () => { - const ts = buildSenseIndexTs("cpu-usage"); - expect(ts).toContain("cpuUsage"); + expect(ts).toContain("workflow: null"); + expect(ts).toContain("lastRun"); }); }); diff --git a/packages/cli/src/__tests__/e2e-create.test.ts b/packages/cli/src/__tests__/e2e-create.test.ts index 6553351..e82de11 100644 --- a/packages/cli/src/__tests__/e2e-create.test.ts +++ b/packages/cli/src/__tests__/e2e-create.test.ts @@ -147,7 +147,7 @@ describe("e2e create", () => { ); it( - "create sense scaffolds src/, migration, and root build emits dist/senses//index.js", + "create sense scaffolds src/ and root build emits dist/senses//index.js", { timeout: 120_000 }, async () => { fakeHome = mkdtempSync(join(tmpdir(), "nerve-create-e2e-")); @@ -162,8 +162,6 @@ describe("e2e create", () => { const base = join(nerveRoot, "senses", "e2e-sense"); expect(existsSync(join(base, "package.json"))).toBe(false); expect(existsSync(join(base, "src", "index.ts"))).toBe(true); - expect(existsSync(join(base, "src", "schema.ts"))).toBe(true); - expect(existsSync(join(base, "migrations", "0001_init.sql"))).toBe(true); expect(existsSync(join(nerveRoot, "dist", "senses", "e2e-sense", "index.js"))).toBe(true); }, ); diff --git a/packages/cli/src/__tests__/e2e-harness.ts b/packages/cli/src/__tests__/e2e-harness.ts index 58e3536..8c5ddea 100644 --- a/packages/cli/src/__tests__/e2e-harness.ts +++ b/packages/cli/src/__tests__/e2e-harness.ts @@ -2,14 +2,7 @@ * Shared E2E harness: temp HOME layout (`.uncaged-nerve`), real kernel + sense worker, * IPC socket for CLI, and `runCommand` helpers with captured stdio. * - * ## Signal persistence (CLI `nerve sense list`) - * - * The kernel appends a `source: "sense", type: "signal"` row to `data/logs.db` when a - * worker emits a signal (see `packages/daemon/src/kernel.ts`). The daemon also - * auto-persists each signal into a `_signals` table in the per-sense SQLite DB - * (see `runtime.persistSignal` in `packages/daemon/src/sense-runtime.ts`). - * `listSenses()` reads `lastSignalTimestamp` from the kernel's in-memory state, - * while `sense query` reads from the `_signals` table (or a user-defined preview table). + * Stateful senses persist JSON under `data/senses/.json` (see sense-worker). * * ## Timeout guard (vitest) * @@ -37,15 +30,7 @@ * ``` */ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -69,27 +54,6 @@ const nerveDaemonRoot = dirname(require.resolve("@uncaged/nerve-daemon/package.j const senseWorkerScript = join(nerveDaemonRoot, "dist", "sense-worker.js"); const workflowWorkerScript = join(nerveDaemonRoot, "dist", "workflow-worker.js"); -function resolveDrizzleOrmPackageRoot(): string { - const requireFromDaemon = createRequire(join(nerveDaemonRoot, "package.json")); - const entry = requireFromDaemon.resolve("drizzle-orm"); - let dir = dirname(entry); - for (let i = 0; i < 12; i += 1) { - const pkgPath = join(dir, "package.json"); - if (existsSync(pkgPath)) { - try { - const name = (JSON.parse(readFileSync(pkgPath, "utf8")) as { name: string }).name; - if (name === "drizzle-orm") return dir; - } catch { - // keep walking - } - } - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - throw new Error("Could not resolve drizzle-orm package root for e2e harness"); -} - const nerveYamlTemplate = `senses: counter: group: e2e @@ -150,52 +114,24 @@ api: host: 127.0.0.1 `; -/** Schema for sense signal rows persisted via \`db.insert(table)\` (see sense-runtime). */ -const counterMigration = `CREATE TABLE IF NOT EXISTS counter_signals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - count INTEGER, - launched INTEGER, - idle INTEGER -); -`; +/** Minimal counter sense — each compute increments \`count\` in persisted state. */ +const counterIndexJs = `export const initialState = { count: 0 }; -/** - * Minimal counter sense — each compute returns an incrementing count. - * Does NOT touch the DB directly in compute(); the daemon inserts into \`table\` - * and persistSignal handles \`_signals\`. - */ -const counterIndexJs = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core"; - -export const table = sqliteTable("counter_signals", { - id: integer("id").primaryKey({ autoIncrement: true }), - count: integer("count"), - launched: integer("launched"), - idle: integer("idle"), -}); - -let _count = 0; -export async function compute(_db, _peers, _options) { - _count += 1; - return { signal: { count: _count }, workflow: null }; +export async function compute(state) { + return { + state: { count: state.count + 1 }, + workflow: null, + }; } `; -/** First trigger launches local noop workflow; later triggers emit a plain signal. */ -const counterIndexJsWithNoopWorkflow = `import { integer, sqliteTable } from "drizzle-orm/sqlite-core"; +/** First trigger launches local noop workflow; later triggers only advance idleTicks. */ +const counterIndexJsWithNoopWorkflow = `export const initialState = { launched: false, idleTicks: 0 }; -export const table = sqliteTable("counter_signals", { - id: integer("id").primaryKey({ autoIncrement: true }), - count: integer("count"), - launched: integer("launched"), - idle: integer("idle"), -}); - -let _launched = false; -export async function compute(_db, _peers, _options) { - if (!_launched) { - _launched = true; +export async function compute(state) { + if (!state.launched) { return { - signal: { launched: 1 }, + state: { launched: true, idleTicks: state.idleTicks }, workflow: { name: "noop", maxRounds: 3, @@ -204,7 +140,10 @@ export async function compute(_db, _peers, _options) { }, }; } - return { signal: { idle: 1 }, workflow: null }; + return { + state: { launched: state.launched, idleTicks: state.idleTicks + 1 }, + workflow: null, + }; } `; @@ -241,7 +180,6 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -259,7 +197,6 @@ function defaultTestConfig(withNoopWorkflow: boolean): NerveConfig { function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): void { mkdirSync(join(nerveRoot, "data", "senses"), { recursive: true }); mkdirSync(join(nerveRoot, "data", "blobs"), { recursive: true }); - mkdirSync(join(nerveRoot, "senses", "counter", "migrations"), { recursive: true }); mkdirSync(join(nerveRoot, "dist", "senses", "counter"), { recursive: true }); mkdirSync(join(nerveRoot, "dist", "workflows", "echo"), { recursive: true }); writeFileSync( @@ -267,11 +204,6 @@ function writeWorkspaceLayout(nerveRoot: string, withNoopWorkflow: boolean): voi withNoopWorkflow ? nerveYamlWithNoopWorkflow : nerveYamlTemplate, "utf8", ); - writeFileSync( - join(nerveRoot, "senses", "counter", "migrations", "001.sql"), - counterMigration, - "utf8", - ); writeFileSync( join(nerveRoot, "dist", "senses", "counter", "index.js"), withNoopWorkflow ? counterIndexJsWithNoopWorkflow : counterIndexJs, @@ -325,10 +257,6 @@ export function linkWorkspaceDaemonIntoNerveRoot(nerveRoot: string): void { mkdirSync(linkDir, { recursive: true }); const linkPath = join(linkDir, "nerve-daemon"); if (!existsSync(linkPath)) symlinkSync(daemonPkgRoot, linkPath); - - const drizzlePkgRoot = resolveDrizzleOrmPackageRoot(); - const drizzleLink = join(nm, "drizzle-orm"); - if (!existsSync(drizzleLink)) symlinkSync(drizzlePkgRoot, drizzleLink); } /** diff --git a/packages/cli/src/__tests__/e2e-sense-query.test.ts b/packages/cli/src/__tests__/e2e-sense-query.test.ts deleted file mode 100644 index 0e4f29e..0000000 --- a/packages/cli/src/__tests__/e2e-sense-query.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * E2E: `nerve sense query` against a real daemon + persisted `_signals` (issue #156). - */ - -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { - type TestDaemonHandle, - pollUntil, - runCli, - startTestDaemon, - stopTestDaemon, -} from "./e2e-harness.js"; - -describe("e2e sense query", () => { - let daemon: TestDaemonHandle | null = null; - - afterEach(async () => { - const h = daemon; - daemon = null; - if (h === null) return; - await Promise.race([ - stopTestDaemon(h), - new Promise((_, reject) => - setTimeout(() => reject(new Error("stopTestDaemon timed out after 10s")), 10_000), - ), - ]); - }); - - async function waitForSignalsPersisted(handle: TestDaemonHandle): Promise { - const dbPath = join(handle.nerveRoot, "data", "senses", "counter.db"); - await pollUntil(() => { - if (!existsSync(dbPath)) return false; - try { - const db = new DatabaseSync(dbPath, { readOnly: true }); - const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as - | { cnt: number } - | undefined; - db.close(); - return row !== undefined && row.cnt > 0; - } catch { - return false; - } - }, 10_000); - } - - /** Start daemon, trigger counter, wait until `_signals` has a row. */ - async function startDaemonWithPersistedSignal(): Promise { - const handle = await startTestDaemon(); - const triggerResult = await runCli(handle, ["sense", "trigger", "counter"]); - expect(triggerResult.exitCode).toBe(0); - expect(triggerResult.stdout).toContain("Triggered"); - await waitForSignalsPersisted(handle); - return handle; - } - - it( - "after trigger, persisted _signals and sense query counter returns at least one row", - { timeout: 30_000 }, - async () => { - daemon = await startDaemonWithPersistedSignal(); - - const queryResult = await runCli(daemon, ["sense", "query", "counter"]); - expect(queryResult.exitCode).toBe(0); - expect(queryResult.stdout).not.toContain("(0 rows)"); - }, - ); - - it( - "default sense query output lists payload column and counter count in payload", - { timeout: 30_000 }, - async () => { - daemon = await startDaemonWithPersistedSignal(); - - const queryResult = await runCli(daemon, ["sense", "query", "counter"]); - expect(queryResult.exitCode).toBe(0); - expect(queryResult.stdout).toContain("payload"); - expect(queryResult.stdout).toMatch(/count/); - }, - ); - - it( - "nerve sense query counter --json prints a JSON array with payload on each row", - { timeout: 30_000 }, - async () => { - daemon = await startDaemonWithPersistedSignal(); - - const jsonResult = await runCli(daemon, ["sense", "query", "counter", "--json"]); - expect(jsonResult.exitCode).toBe(0); - const rows = JSON.parse(jsonResult.stdout.trim()) as unknown; - expect(Array.isArray(rows)).toBe(true); - expect(rows.length).toBeGreaterThanOrEqual(1); - for (const row of rows as Record[]) { - expect(Object.keys(row)).toContain("payload"); - } - }, - ); - - it( - "nerve sense query counter --sql runs custom read-only SQL and prints total column", - { timeout: 30_000 }, - async () => { - daemon = await startDaemonWithPersistedSignal(); - - const sqlResult = await runCli(daemon, [ - "sense", - "query", - "counter", - "--sql", - "SELECT count(*) as total FROM _signals", - ]); - expect(sqlResult.exitCode).toBe(0); - expect(sqlResult.stdout).toContain("total"); - expect(sqlResult.stdout).not.toContain("(0 rows)"); - }, - ); -}); diff --git a/packages/cli/src/__tests__/e2e-smoke.test.ts b/packages/cli/src/__tests__/e2e-smoke.test.ts index c00c21c..a0bbbb8 100644 --- a/packages/cli/src/__tests__/e2e-smoke.test.ts +++ b/packages/cli/src/__tests__/e2e-smoke.test.ts @@ -1,8 +1,11 @@ /** * Smoke test: start a real daemon with a counter sense, trigger it, - * then verify CLI commands can list and query the persisted signal. + * then verify CLI lists the sense and state file is written. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + import { afterEach, describe, expect, it } from "vitest"; import { @@ -28,46 +31,27 @@ describe("e2e smoke", () => { ]); }); - it("sense list + sense query after trigger", { timeout: 30_000 }, async () => { + it("sense list after trigger and persisted counter state", { timeout: 30_000 }, async () => { daemon = await startTestDaemon(); - // Trigger counter sense via IPC const triggerResult = await runCli(daemon, ["sense", "trigger", "counter"]); expect(triggerResult.exitCode).toBe(0); expect(triggerResult.stdout).toContain("Triggered"); - // Wait for signal to be persisted (_signals table in the sense DB) - const { existsSync } = await import("node:fs"); - const { join } = await import("node:path"); - const { DatabaseSync } = await import("node:sqlite"); - - const dbPath = join(daemon.nerveRoot, "data", "senses", "counter.db"); + const statePath = join(daemon.nerveRoot, "data", "senses", "counter.json"); await pollUntil(() => { - if (!existsSync(dbPath)) return false; try { - const db = new DatabaseSync(dbPath, { readOnly: true }); - const row = db.prepare("SELECT COUNT(*) as cnt FROM _signals").get() as - | { cnt: number } - | undefined; - db.close(); - return row !== undefined && row.cnt > 0; + const raw = readFileSync(statePath, "utf8"); + const parsed = JSON.parse(raw) as { count?: number }; + return typeof parsed.count === "number" && parsed.count >= 1; } catch { return false; } }, 10_000); - // nerve sense list — should show counter with a last signal timestamp const listResult = await runCli(daemon, ["sense", "list"]); expect(listResult.exitCode).toBe(0); expect(listResult.stdout).toContain("counter"); - expect(listResult.stdout).toContain("last signal:"); - // Should NOT say "(never)" since we triggered and persisted - expect(listResult.stdout).not.toContain("(never)"); - - // nerve sense query counter — should return rows from _signals - const queryResult = await runCli(daemon, ["sense", "query", "counter"]); - expect(queryResult.exitCode).toBe(0); - // Should have actual data rows (not "(0 rows)") - expect(queryResult.stdout).not.toContain("(0 rows)"); + expect(listResult.stdout).not.toContain("last signal"); }); }); diff --git a/packages/cli/src/__tests__/e2e-validate-init.test.ts b/packages/cli/src/__tests__/e2e-validate-init.test.ts index a9fa58e..4197efd 100644 --- a/packages/cli/src/__tests__/e2e-validate-init.test.ts +++ b/packages/cli/src/__tests__/e2e-validate-init.test.ts @@ -210,10 +210,6 @@ describe("e2e init", () => { expect(agentMd).toContain("verb-first"); expect(agentMd).toContain("createRole"); expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "index.ts"))).toBe(true); - expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "src", "schema.ts"))).toBe(true); - expect(existsSync(join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"))).toBe( - true, - ); expect(existsSync(join(nerveRoot, ".cursor", "rules", "nerve-skills.mdc"))).toBe(true); const pkgJson = readFileSync(join(nerveRoot, "package.json"), "utf8"); diff --git a/packages/cli/src/__tests__/e2e/sense.md b/packages/cli/src/__tests__/e2e/sense.md index 4b2196b..0100e1e 100644 --- a/packages/cli/src/__tests__/e2e/sense.md +++ b/packages/cli/src/__tests__/e2e/sense.md @@ -2,7 +2,7 @@ ## sense list -- ✅ prints sense list with name, group, throttle, triggers, and last signal time +- ✅ prints sense list with name, group, throttle, triggers - 🔲 empty state — no senses registered, prints empty message - 🔲 `--json` — outputs valid JSON array @@ -11,19 +11,3 @@ - ✅ trigger known sense exits 0, stdout contains "Triggered" - ✅ trigger non-existent sense writes error to stderr and exits 1 - ✅ sends correct IPC message `{ type: trigger-sense, sense: }` to daemon - -## sense query - -- ✅ after trigger, persisted `_signals` table has at least one row -- ✅ default output lists payload column and counter count -- ✅ `--json` prints valid JSON array with payload on each row -- ✅ `--sql` runs custom read-only SQL and prints result -- 🔲 query non-existent sense — error message -- 🔲 `--limit` / `--offset` pagination - -## sense schema - -- ✅ prints CREATE TABLE statements for the sense database -- ✅ includes `_signals` table in output -- ✅ `--json` prints valid JSON array of SQL strings -- 🔲 schema for non-existent sense — error message diff --git a/packages/cli/src/__tests__/e2e/smoke.md b/packages/cli/src/__tests__/e2e/smoke.md index c2817ff..f5fdee1 100644 --- a/packages/cli/src/__tests__/e2e/smoke.md +++ b/packages/cli/src/__tests__/e2e/smoke.md @@ -2,5 +2,5 @@ Full round-trip integration tests that exercise multiple subcommands together. -- ✅ sense list + sense query after trigger — registers sense, triggers, verifies persisted signal and query output +- ✅ sense list after trigger — daemon lists configured senses; trigger queues a compute (state persisted under `data/senses/` by the worker) - 🔲 init → dev → trigger workflow → thread inspect round-trip diff --git a/packages/cli/src/__tests__/knowledge-query.test.ts b/packages/cli/src/__tests__/knowledge-query.test.ts index cbd3994..f57760d 100644 --- a/packages/cli/src/__tests__/knowledge-query.test.ts +++ b/packages/cli/src/__tests__/knowledge-query.test.ts @@ -72,8 +72,8 @@ describe("queryKnowledgeRepo (word overlap fallback)", () => { path: "a.md", slug: "a.md#0", chunkIndex: 0, - text: "the signal bus emits notifications", - contentHash: contentHash("the signal bus emits notifications"), + text: "the sense scheduler triggers computes", + contentHash: contentHash("the sense scheduler triggers computes"), embedding: fakeEmbeddingBytes("a"), }, { @@ -89,7 +89,7 @@ describe("queryKnowledgeRepo (word overlap fallback)", () => { db.close(); } - const ranked = await queryKnowledgeRepo(root, dbPath, "signal bus", 10); + const ranked = await queryKnowledgeRepo(root, dbPath, "sense scheduler", 10); expect(ranked.length).toBe(2); expect(ranked[0]?.path).toBe("a.md"); expect(ranked[1]?.path).toBe("b.md"); diff --git a/packages/cli/src/__tests__/sense-list-e2e.test.ts b/packages/cli/src/__tests__/sense-list-e2e.test.ts index 196d978..5538a39 100644 --- a/packages/cli/src/__tests__/sense-list-e2e.test.ts +++ b/packages/cli/src/__tests__/sense-list-e2e.test.ts @@ -27,15 +27,12 @@ describe("nerve sense list CLI (runCommand + temp HOME + mock IPC socket)", () = let stdoutSpy: ReturnType> | null; let listSensesRequests: unknown[]; - const LAST_SIGNAL_TS = 1_714_521_600_000; // fixed wall time for stable ISO in assertions - const daemonSenseRow: SenseInfo = { name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, triggers: ["every 5s"], - lastSignalTimestamp: LAST_SIGNAL_TS, }; function nerveYamlFixture(): string { @@ -119,9 +116,7 @@ senses: rmSync(sockDir, { recursive: true, force: true }); }); - it("prints sense list from daemon path with name, group, throttle, trigger schedule, and last signal time", async () => { - // With a real daemon, we would wait for a compute cycle; the mock server - // returns SenseInfo as if one already produced lastSignalTimestamp. + it("prints sense list from daemon path with name, group, throttle, and trigger schedule", async () => { await runCommand(senseCommand, { rawArgs: ["list"] }); expect(listSensesRequests).toHaveLength(1); @@ -133,7 +128,6 @@ senses: expect(out).toContain("throttle: 5s"); expect(out).toContain("timeout: 3s"); expect(out).toContain("trigger schedule: every 5s"); - expect(out).not.toContain("(never)"); - expect(out).toContain(new Date(LAST_SIGNAL_TS).toISOString()); + expect(out).not.toContain("last signal"); }); }); diff --git a/packages/cli/src/__tests__/sense-list.test.ts b/packages/cli/src/__tests__/sense-list.test.ts index 22f5c20..d5f1217 100644 --- a/packages/cli/src/__tests__/sense-list.test.ts +++ b/packages/cli/src/__tests__/sense-list.test.ts @@ -30,7 +30,6 @@ const SAMPLE_SENSES: SenseInfo[] = [ throttle: 5000, timeout: 3000, triggers: ["every 30s", "on: cpu-threshold"], - lastSignalTimestamp: 1_700_000_000_000, }, { name: "disk-usage", @@ -38,7 +37,6 @@ const SAMPLE_SENSES: SenseInfo[] = [ throttle: 30000, timeout: null, triggers: [], - lastSignalTimestamp: null, }, { name: "active-tasks", @@ -46,7 +44,6 @@ const SAMPLE_SENSES: SenseInfo[] = [ throttle: 10000, timeout: 30000, triggers: ["every 1m"], - lastSignalTimestamp: null, }, ]; @@ -122,15 +119,9 @@ describe("formatSenseList", () => { expect(output).toContain("(none)"); }); - it("shows '(never)' when lastSignalTimestamp is null", () => { + it("does not include last signal line", () => { const output = formatSenseList(SAMPLE_SENSES); - expect(output).toContain("(never)"); - }); - - it("shows ISO timestamp when lastSignalTimestamp is set", () => { - const output = formatSenseList(SAMPLE_SENSES); - // cpu-usage has lastSignalTimestamp = 1_700_000_000_000 - expect(output).toContain(new Date(1_700_000_000_000).toISOString()); + expect(output).not.toContain("last signal"); }); }); @@ -181,29 +172,13 @@ senses: expect(result[0]).toMatchObject({ name: "cpu-usage", group: "system", - lastSignalTimestamp: null, }); expect(result[1]).toMatchObject({ name: "disk-usage", group: "system", - lastSignalTimestamp: null, }); }); - it("always sets lastSignalTimestamp to null (static fallback)", () => { - const path = join(tmpDir, "nerve.yaml"); - writeFileSync( - path, - ` -senses: - my-sense: - group: default -`.trim(), - ); - const result = sensesFromConfig(path); - expect(result[0].lastSignalTimestamp).toBeNull(); - }); - it("populates throttle and timeout from config", () => { const path = join(tmpDir, "nerve.yaml"); writeFileSync( @@ -292,7 +267,6 @@ describe("listSensesViaDaemon", () => { throttle: 5000, timeout: 3000, triggers: [], - lastSignalTimestamp: 12345, }, ]; const server = createServer((s) => { diff --git a/packages/cli/src/__tests__/sense-schema-e2e.test.ts b/packages/cli/src/__tests__/sense-schema-e2e.test.ts deleted file mode 100644 index c5df305..0000000 --- a/packages/cli/src/__tests__/sense-schema-e2e.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * E2E-style tests for `nerve sense schema` with a temp HOME and a real sense SQLite file. - * `getNerveRoot()` uses `os.homedir()`, which respects `process.env.HOME` on POSIX. - */ - -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { runCommand } from "citty"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { senseCommand } from "../commands/sense.js"; - -const SENSE_NAME = "e2e-schema-sense"; - -function createFakeSenseDb(nerveRoot: string): void { - const sensesDir = join(nerveRoot, "data", "senses"); - mkdirSync(sensesDir, { recursive: true }); - const dbPath = join(sensesDir, `${SENSE_NAME}.db`); - const db = new DatabaseSync(dbPath); - db.exec( - "CREATE TABLE _signals(id INTEGER PRIMARY KEY, sense TEXT, timestamp INTEGER, payload TEXT)", - ); - db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)"); - db.close(); -} - -describe("nerve sense schema CLI (runCommand + temp HOME)", () => { - let prevHome: string | undefined; - let fakeHome: string; - let stdoutSpy: ReturnType | null; - let capturedStdout: string; - - beforeEach(() => { - capturedStdout = ""; - stdoutSpy = vi - .spyOn(process.stdout, "write") - .mockImplementation( - (chunk: string | Uint8Array, enc?: BufferEncoding, cb?: (err?: Error | null) => void) => { - if (typeof chunk === "string") { - capturedStdout += chunk; - } else { - capturedStdout += Buffer.from(chunk).toString(typeof enc === "string" ? enc : "utf8"); - } - if (typeof cb === "function") { - cb(); - } - return true; - }, - ); - - prevHome = process.env.HOME; - fakeHome = mkdtempSync(join(tmpdir(), "nerve-sense-schema-e2e-")); - process.env.HOME = fakeHome; - - const nerveRoot = join(fakeHome, ".uncaged-nerve"); - createFakeSenseDb(nerveRoot); - }); - - afterEach(() => { - stdoutSpy?.mockRestore(); - stdoutSpy = null; - if (prevHome === undefined) { - // biome-ignore lint/performance/noDelete: semantically correct for env cleanup - delete process.env.HOME; - } else { - process.env.HOME = prevHome; - } - rmSync(fakeHome, { recursive: true, force: true }); - }); - - it("prints CREATE TABLE statements for the sense database", async () => { - await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] }); - expect(capturedStdout).toMatch(/CREATE TABLE/i); - }); - - it("includes the _signals table in output", async () => { - await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME] }); - expect(capturedStdout).toContain("_signals"); - }); - - it("with --json prints a valid JSON array of SQL strings", async () => { - await runCommand(senseCommand, { rawArgs: ["schema", SENSE_NAME, "--json"] }); - const parsed: unknown = JSON.parse(capturedStdout.trim()); - expect(Array.isArray(parsed)).toBe(true); - const arr = parsed as unknown[]; - expect(arr.length).toBeGreaterThanOrEqual(1); - for (const item of arr) { - expect(typeof item).toBe("string"); - expect(item).toMatch(/CREATE TABLE/i); - } - const joined = arr.join("\n"); - expect(joined).toContain("_signals"); - }); -}); diff --git a/packages/cli/src/__tests__/sense-sqlite.test.ts b/packages/cli/src/__tests__/sense-sqlite.test.ts deleted file mode 100644 index 4825b9b..0000000 --- a/packages/cli/src/__tests__/sense-sqlite.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`. - */ - -import { mkdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { - assertSenseDbExists, - collectColumnKeys, - defaultPreviewSql, - formatRowsAsAlignedTable, - listTableSqlStatements, - parseSenseQueryArgs, - pickDefaultPreviewTable, - senseDbPath, -} from "../sense-sqlite.js"; - -let tmpDir: string; - -beforeEach(() => { - tmpDir = join( - tmpdir(), - `nerve-sense-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`, - ); - mkdirSync(join(tmpDir, "data", "senses"), { recursive: true }); -}); - -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe("senseDbPath", () => { - it("points at data/senses/.db under the given root", () => { - expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db")); - }); -}); - -describe("assertSenseDbExists", () => { - it("throws when the file is missing", () => { - expect(() => assertSenseDbExists(tmpDir, "nope")).toThrow(/No database at/); - }); - - it("returns the path when the file exists", () => { - const p = join(tmpDir, "data", "senses", "x.db"); - new DatabaseSync(p).close(); - expect(assertSenseDbExists(tmpDir, "x")).toBe(p); - }); -}); - -describe("listTableSqlStatements", () => { - it("returns CREATE statements ordered by tbl_name", () => { - const p = join(tmpDir, "data", "senses", "t.db"); - const db = new DatabaseSync(p); - db.exec("CREATE TABLE zebra (id INTEGER)"); - db.exec("CREATE TABLE alpha (id INTEGER)"); - const stmts = listTableSqlStatements(db); - db.close(); - expect(stmts).toHaveLength(2); - expect(stmts[0]).toMatch(/^CREATE TABLE alpha/i); - expect(stmts[1]).toMatch(/^CREATE TABLE zebra/i); - }); -}); - -describe("pickDefaultPreviewTable", () => { - it("prefers non-_migrations tables when both exist", () => { - const p = join(tmpDir, "data", "senses", "t.db"); - const db = new DatabaseSync(p); - db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)"); - db.exec("CREATE TABLE readings (id INTEGER)"); - expect(pickDefaultPreviewTable(db)).toBe("readings"); - db.close(); - }); - - it("uses _migrations when it is the only table", () => { - const p = join(tmpDir, "data", "senses", "t.db"); - const db = new DatabaseSync(p); - db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)"); - expect(pickDefaultPreviewTable(db)).toBe("_migrations"); - db.close(); - }); -}); - -describe("defaultPreviewSql", () => { - it("quotes identifiers for SQL safety", () => { - expect(defaultPreviewSql(`weird"name`)).toContain(`weird""name`); - }); -}); - -describe("parseSenseQueryArgs", () => { - it("parses sense name only", () => { - expect(parseSenseQueryArgs(["cpu"])).toEqual({ name: "cpu", sql: undefined }); - }); - - it("strips --json", () => { - expect(parseSenseQueryArgs(["cpu", "--json"])).toEqual({ name: "cpu", sql: undefined }); - expect(parseSenseQueryArgs(["--json", "cpu"])).toEqual({ name: "cpu", sql: undefined }); - }); - - it("joins remaining tokens into SQL", () => { - expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" }); - }); - - it("uses --sql value instead of positional SQL", () => { - expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT 2"])).toEqual({ - name: "cpu", - sql: "SELECT 2", - }); - expect(parseSenseQueryArgs(["cpu", "--sql=SELECT 3"])).toEqual({ - name: "cpu", - sql: "SELECT 3", - }); - }); - - it("prefers --sql over trailing positional SQL", () => { - expect(parseSenseQueryArgs(["cpu", "--sql", "SELECT a", "SELECT b"])).toEqual({ - name: "cpu", - sql: "SELECT a", - }); - }); - - it("throws when --sql has no value", () => { - expect(() => parseSenseQueryArgs(["cpu", "--sql"])).toThrow(/Missing value for --sql/); - }); - - it("throws when name is missing", () => { - expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/); - }); -}); - -describe("formatRowsAsAlignedTable", () => { - it("shows empty marker for no rows", () => { - expect(formatRowsAsAlignedTable([])).toContain("(0 rows)"); - }); - - it("aligns columns from row data", () => { - const out = formatRowsAsAlignedTable([ - { a: 1, b: "x" }, - { a: 22, b: "yy" }, - ]); - expect(out).toContain("a"); - expect(out).toContain("b"); - expect(out).toContain("22"); - }); -}); - -describe("collectColumnKeys", () => { - it("preserves key order from first row then appends new keys", () => { - expect( - collectColumnKeys([ - { z: 1, a: 2 }, - { a: 3, b: 4 }, - ]), - ).toEqual(["z", "a", "b"]); - }); -}); - -describe("readonly query integration", () => { - it("runs default preview SQL on a real db", () => { - const p = join(tmpDir, "data", "senses", "demo.db"); - const rw = new DatabaseSync(p); - rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)"); - rw.exec("INSERT INTO items (v) VALUES ('a'), ('b')"); - rw.close(); - - const db = new DatabaseSync(p, { readOnly: true }); - const table = pickDefaultPreviewTable(db); - expect(table).toBe("items"); - if (table === null) { - throw new Error("expected items table"); - } - const sql = defaultPreviewSql(table); - const rows = db.prepare(sql).all() as Record[]; - db.close(); - expect(rows.length).toBeGreaterThanOrEqual(1); - }); -}); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 6480331..48a6179 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -85,76 +85,26 @@ and optionally load this file at runtime if you keep prompts outside code. `; } -function senseIdToSqlTableName(id: string): string { - return id.replaceAll("-", "_"); -} +export function buildSenseIndexTs(_senseId: string): string { + return `type SenseState = { + lastRun: number | null; +}; -function senseIdToSchemaExportName(id: string): string { - const parts = id.split("-"); - return parts - .map((part, index) => - index === 0 ? part : part.length === 0 ? "" : part.charAt(0).toUpperCase() + part.slice(1), - ) - .join(""); -} +export const initialState: SenseState = { lastRun: null }; -export function buildSenseSchemaTs(senseId: string): string { - const table = senseIdToSqlTableName(senseId); - const exportName = senseIdToSchemaExportName(senseId); - return `import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; - -export const ${exportName} = sqliteTable("${table}", { - id: integer("id").primaryKey({ autoIncrement: true }), - ts: integer("ts").notNull(), - label: text("label").notNull(), -}); -`; -} - -export function buildSenseIndexTs(senseId: string): string { - const exportName = senseIdToSchemaExportName(senseId); - return `import type { LibSQLDatabase } from "drizzle-orm/libsql"; - -import { ${exportName} } from "./schema.js"; - -export { ${exportName} as table } from "./schema.js"; - -type SenseResult = { - signal: { label: string; ts: number }; +export async function compute(state: SenseState): Promise<{ + state: SenseState; workflow: null; -} | null; - -/** - * ${senseId} — replace this stub with your sampling logic. - * Returns non-null to emit a signal, null to stay silent. - */ -export async function compute( - db: LibSQLDatabase, - _peers: Record, - _options: { signal: AbortSignal }, -): Promise { - void ${exportName}; +}> { + // TODO: implement sense logic return { - signal: { - label: "${senseId}", - ts: Date.now(), - }, + state: { lastRun: Date.now() }, workflow: null, }; } `; } -export function buildSenseMigrationSql(senseId: string): string { - const table = senseIdToSqlTableName(senseId); - return `CREATE TABLE IF NOT EXISTS ${table} ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts INTEGER NOT NULL, - label TEXT NOT NULL -); -`; -} - function writeFile(filePath: string, content: string): void { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, content, "utf8"); @@ -278,15 +228,10 @@ const createSenseCommand = defineCommand({ } mkdirSync(join(senseDir, "src"), { recursive: true }); - mkdirSync(join(senseDir, "migrations"), { recursive: true }); writeFile(join(senseDir, "src", "index.ts"), buildSenseIndexTs(args.name)); - writeFile(join(senseDir, "src", "schema.ts"), buildSenseSchemaTs(args.name)); - writeFile(join(senseDir, "migrations", "0001_init.sql"), buildSenseMigrationSql(args.name)); process.stdout.write("✅ Sense scaffolded:\n"); process.stdout.write(` ${join(senseDir, "src", "index.ts")}\n`); - process.stdout.write(` ${join(senseDir, "src", "schema.ts")}\n`); - process.stdout.write(` ${join(senseDir, "migrations", "0001_init.sql")}\n`); process.stdout.write("\nBuilding workspace (senses + workflows)…\n"); try { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 4c114f3..c00914c 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -54,13 +54,11 @@ const PACKAGE_JSON = `${JSON.stringify( dependencies: { "@uncaged/nerve-core": "latest", "@uncaged/nerve-daemon": "latest", - "drizzle-orm": "latest", zod: "^4.3.6", }, devDependencies: { "@biomejs/biome": "latest", "@types/node": "^22.0.0", - "drizzle-kit": "latest", esbuild: "^0.27.0", typescript: "^5.7.0", }, @@ -139,9 +137,8 @@ This file is created by \`nerve init\`. Read it before implementing senses or wo | \`nerve.yaml\` | Senses, workflows, intervals, groups | | \`package.json\` | Single root package — no per-sense/per-workflow packages | | \`scripts/build.mjs\` | Root esbuild step; output under \`dist/\` | -| \`senses//src/index.ts\` | Sense \`compute()\` entry | -| \`senses//src/schema.ts\` | Drizzle SQLite schema (TypeScript) | -| \`senses//migrations/*.sql\` | SQL migrations (next to \`src/\`, not inside it) | +| \`senses//src/index.ts\` | Sense \`compute()\` + \`initialState\` | +| \`data/senses/.json\` | Persisted sense state (written by the daemon) | | \`workflows//index.ts\` | Default export: \`WorkflowDefinition\` | | \`workflows//roles/.ts\` | One TypeScript file per role | | \`dist/senses//index.js\` | Bundled sense (after build) | @@ -217,58 +214,25 @@ There is no separate npm package for skills in the default workspace. To align w const execFileAsync = promisify(execFile); -const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +const CPU_INDEX_TS = `import { loadavg } from "node:os"; -export const cpuUsage = sqliteTable("cpu_usage", { - id: integer("id").primaryKey({ autoIncrement: true }), - ts: integer("ts").notNull(), - model: text("model").notNull(), - loadPercent: real("load_percent").notNull(), -}); -`; - -const CPU_INDEX_TS = `import { cpus } from "node:os"; - -export { cpuUsage as table } from "./schema.js"; - -type SenseResult = { - signal: { model: string; loadPercent: number; ts: number }; - workflow: null; +type CpuState = { + samples: Array<{ ts: number; value: number }>; }; -export async function compute(): Promise { - const cpuList = cpus(); +export const initialState: CpuState = { samples: [] }; - let totalIdle = 0; - let totalTick = 0; - for (const cpu of cpuList) { - for (const [, time] of Object.entries(cpu.times)) { - totalTick += time; - } - totalIdle += cpu.times.idle; - } - - const loadPercent = totalTick === 0 ? 0 : ((totalTick - totalIdle) / totalTick) * 100; - - return { - signal: { - model: cpuList[0]?.model ?? "unknown", - loadPercent: Math.round(loadPercent * 100) / 100, - ts: Date.now(), - }, - workflow: null, - }; +export async function compute(state: CpuState): Promise<{ + state: CpuState; + workflow: null; +}> { + const [oneMin] = loadavg(); + const value = typeof oneMin === "number" && !Number.isNaN(oneMin) ? oneMin : 0; + const newSamples = [...state.samples.slice(-99), { ts: Date.now(), value }]; + return { state: { samples: newSamples }, workflow: null }; } `; -const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts INTEGER NOT NULL, - model TEXT NOT NULL, - load_percent REAL NOT NULL -); -`; - function writeFile(filePath: string, content: string): void { mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, content, "utf8"); @@ -419,7 +383,6 @@ async function runInitWorkspace(force: boolean, skipInstall = false): Promise 0 ? s.triggers.join("; ") : "(none)"}\n`, ); - const lastSignal = - s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)"; - lines.push(` last signal: ${lastSignal}\n`); } return lines.join(""); } @@ -76,7 +59,6 @@ export function sensesFromConfig(configPath: string): SenseInfo[] { throttle: cfg.throttle, timeout: cfg.timeout, triggers: senseTriggerLabels(name, senses), - lastSignalTimestamp: null, })); } @@ -92,7 +74,7 @@ const senseListCommand = defineCommand({ async run() { if (!isRemoteDaemonCli() && !isRunning()) { process.stderr.write( - "⚠️ Daemon is not running — showing static config only (no last signal time).\n\n", + "⚠️ Daemon is not running — showing static config from nerve.yaml only.\n\n", ); const configPath = join(getNerveRoot(), "nerve.yaml"); const senses = sensesFromConfig(configPath); @@ -154,116 +136,6 @@ const senseTriggerCommand = defineCommand({ }, }); -// --------------------------------------------------------------------------- -// nerve sense schema -// --------------------------------------------------------------------------- - -const senseSchemaCommand = defineCommand({ - meta: { - name: "schema", - description: "Print CREATE TABLE statements from a sense SQLite database", - }, - args: { - name: { - type: "positional", - description: "Sense name (data/senses/.db under the nerve workspace)", - }, - json: { - type: "boolean", - description: "Print JSON array of CREATE TABLE SQL strings", - default: false, - }, - }, - async run({ args }) { - const nerveRoot = getNerveRoot(); - let db: DatabaseSync | undefined; - try { - db = openSenseDb(nerveRoot, args.name); - const statements = listTableSqlStatements(db); - if (args.json) { - process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`); - } else if (statements.length === 0) { - process.stdout.write("(no tables)\n"); - } else { - for (const sql of statements) { - process.stdout.write(`${sql};\n\n`); - } - } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`❌ ${msg}\n`); - process.exit(1); - } finally { - db?.close(); - } - }, -}); - -// --------------------------------------------------------------------------- -// nerve sense query [sql...] -// --------------------------------------------------------------------------- - -const senseQueryCommand = defineCommand({ - meta: { - name: "query", - description: - 'Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name, or use --sql "…".', - }, - args: { - name: { - type: "positional", - description: "Sense name (data/senses/.db under the nerve workspace)", - }, - json: { - type: "boolean", - description: "Print result rows as JSON", - default: false, - }, - }, - async run({ args, rawArgs }) { - const nerveRoot = getNerveRoot(); - let db: DatabaseSync | undefined; - try { - let parsed: { name: string; sql: string | undefined }; - try { - parsed = parseSenseQueryArgs(rawArgs); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`❌ ${msg}\n`); - process.exit(1); - } - - db = openSenseDb(nerveRoot, args.name); - - let sql = parsed.sql?.trim(); - if (!sql) { - const table = pickDefaultPreviewTable(db); - if (table === null) { - process.stderr.write("❌ No tables found in database.\n"); - process.exit(1); - } else { - sql = defaultPreviewSql(table); - } - } - - const rawRows: unknown[] = db.prepare(sql).all(); - const rows: Record[] = rawRows.filter(isPlainRecord); - - if (args.json) { - process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); - } else { - process.stdout.write(formatRowsAsAlignedTable(rows)); - } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`❌ ${msg}\n`); - process.exit(1); - } finally { - db?.close(); - } - }, -}); - // --------------------------------------------------------------------------- // nerve sense (parent command) // --------------------------------------------------------------------------- @@ -276,7 +148,5 @@ export const senseCommand = defineCommand({ subCommands: { list: senseListCommand, trigger: senseTriggerCommand, - schema: senseSchemaCommand, - query: senseQueryCommand, }, }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 0c4109f..b935a43 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -97,6 +97,5 @@ export const statusCommand = defineCommand({ process.stdout.write( ` workers: ${workerGroups.length > 0 ? workerGroups.join(", ") : "(none)"}\n`, ); - process.stdout.write(" signals: (pending SignalBus persistence)\n"); }, }); diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index a0229b0..4c7cc31 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -8,7 +8,7 @@ import { stringify } from "yaml"; import type { LogStore, ThreadRoundRow, WorkflowRun } from "@uncaged/nerve-store"; import { isRemoteDaemonCli } from "../cli-global.js"; import { resolveDaemonTransport } from "../daemon-client.js"; -import { formatRowsAsAlignedTable } from "../sense-sqlite.js"; +import { formatRowsAsAlignedTable } from "../table-format.js"; import { loadDaemonModule } from "../workspace-daemon.js"; import { getNerveRoot, isRunning } from "../workspace.js"; diff --git a/packages/cli/src/sense-sqlite.ts b/packages/cli/src/sense-sqlite.ts deleted file mode 100644 index 3e09afd..0000000 --- a/packages/cli/src/sense-sqlite.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -/** SQLite path for a sense under the nerve workspace root. */ -export function senseDbPath(nerveRoot: string, senseName: string): string { - return join(nerveRoot, "data", "senses", `${senseName}.db`); -} - -export function assertSenseDbExists(nerveRoot: string, senseName: string): string { - const path = senseDbPath(nerveRoot, senseName); - if (!existsSync(path)) { - throw new Error(`No database at ${path}`); - } - return path; -} - -/** Open a sense SQLite database in readonly mode using node:sqlite. */ -export function openSenseDb(nerveRoot: string, senseName: string): DatabaseSync { - const path = assertSenseDbExists(nerveRoot, senseName); - return new DatabaseSync(path, { readOnly: true }); -} - -/** `SELECT sql FROM sqlite_master WHERE type='table'` (non-null sql only). */ -export function listTableSqlStatements(db: DatabaseSync): string[] { - const rows = db - .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`, - ) - .all() as { sql: string }[]; - return rows.map((r) => r.sql); -} - -/** - * Table used for `nerve sense query ` with no SQL. - * Prefers real data tables over `_migrations`, then lexicographic by name. - */ -export function pickDefaultPreviewTable(db: DatabaseSync): string | null { - const row = db - .prepare( - `SELECT name FROM sqlite_master - WHERE type = 'table' AND sql IS NOT NULL - AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' - ORDER BY - CASE WHEN name = '_signals' THEN 0 - WHEN name = '_migrations' THEN 2 - ELSE 1 END, - name - LIMIT 1`, - ) - .get() as { name: string } | undefined; - return row?.name ?? null; -} - -export function defaultPreviewSql(table: string): string { - return `SELECT * FROM "${table.replace(/"/g, '""')}" ORDER BY rowid DESC LIMIT 10`; -} - -function endIndexAfterGenericDashArg(rawArgs: string[], i: number): number { - const a = rawArgs[i]; - const eq = a.indexOf("="); - if (eq === -1 && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) { - return i + 1; - } - return i; -} - -type SenseQueryArgStep = { - nextIndex: number; - flagSql: string | undefined; - positionalToken: string | null; -}; - -function nextSenseQueryArgStep(rawArgs: string[], i: number): SenseQueryArgStep { - const a = rawArgs[i]; - if (a === "--json" || a === "--no-json") { - return { nextIndex: i, flagSql: undefined, positionalToken: null }; - } - if (a.startsWith("--sql=")) { - return { nextIndex: i, flagSql: a.slice("--sql=".length), positionalToken: null }; - } - if (a === "--sql") { - if (i + 1 >= rawArgs.length || rawArgs[i + 1].startsWith("-")) { - throw new Error("Missing value for --sql"); - } - return { nextIndex: i + 1, flagSql: rawArgs[i + 1], positionalToken: null }; - } - if (a.startsWith("-")) { - return { - nextIndex: endIndexAfterGenericDashArg(rawArgs, i), - flagSql: undefined, - positionalToken: null, - }; - } - return { nextIndex: i, flagSql: undefined, positionalToken: a }; -} - -/** Parse sense name and optional SQL from subcommand raw argv (flags stripped). */ -export function parseSenseQueryArgs(rawArgs: string[]): { name: string; sql: string | undefined } { - let flagSql: string | undefined; - const pos: string[] = []; - let i = 0; - while (i < rawArgs.length) { - const step = nextSenseQueryArgStep(rawArgs, i); - if (step.flagSql !== undefined) { - flagSql = step.flagSql; - } - if (step.positionalToken !== null) { - pos.push(step.positionalToken); - } - i = step.nextIndex + 1; - } - if (pos.length < 1) { - throw new Error("Missing sense name"); - } - const name = pos[0]; - const positionalSql = pos.length > 1 ? pos.slice(1).join(" ") : undefined; - const trimmedFlag = flagSql?.trim(); - const sql = trimmedFlag !== undefined && trimmedFlag.length > 0 ? trimmedFlag : positionalSql; - return { name, sql }; -} - -function stringifyCell(value: unknown): string { - if (value === null || value === undefined) return ""; - if (typeof value === "bigint") return value.toString(); - if (typeof value === "number" || typeof value === "boolean") return String(value); - if (typeof value === "string") return value; - if (Buffer.isBuffer(value)) return value.toString("hex"); - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -/** Collect column keys in stable order (first row keys, then any extras). */ -export function collectColumnKeys(rows: Record[]): string[] { - const keys: string[] = []; - const seen = new Set(); - for (const row of rows) { - for (const k of Object.keys(row)) { - if (!seen.has(k)) { - seen.add(k); - keys.push(k); - } - } - } - return keys; -} - -const MAX_CELL = 64; - -function truncate(s: string): string { - if (s.length <= MAX_CELL) return s; - return `${s.slice(0, MAX_CELL - 1)}…`; -} - -/** Plain aligned table for terminal output. */ -export function formatRowsAsAlignedTable(rows: Record[]): string { - if (rows.length === 0) { - return "(0 rows)\n"; - } - const cols = collectColumnKeys(rows); - const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c])))); - const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length))); - const sep = widths.map((w) => "-".repeat(w)).join("-+-"); - const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | "); - const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n"); - return `${header}\n${sep}\n${body}\n`; -} diff --git a/packages/cli/src/table-format.ts b/packages/cli/src/table-format.ts new file mode 100644 index 0000000..af3ef34 --- /dev/null +++ b/packages/cli/src/table-format.ts @@ -0,0 +1,48 @@ +function stringifyCell(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString("hex"); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** Collect column keys in stable order (first row keys, then any extras). */ +export function collectColumnKeys(rows: Record[]): string[] { + const keys: string[] = []; + const seen = new Set(); + for (const row of rows) { + for (const k of Object.keys(row)) { + if (!seen.has(k)) { + seen.add(k); + keys.push(k); + } + } + } + return keys; +} + +const MAX_CELL = 64; + +function truncate(s: string): string { + if (s.length <= MAX_CELL) return s; + return `${s.slice(0, MAX_CELL - 1)}…`; +} + +/** Plain aligned table for terminal output. */ +export function formatRowsAsAlignedTable(rows: Record[]): string { + if (rows.length === 0) { + return "(0 rows)\n"; + } + const cols = collectColumnKeys(rows); + const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c])))); + const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length))); + const sep = widths.map((w) => "-".repeat(w)).join("-+-"); + const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | "); + const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n"); + return `${header}\n${sep}\n${body}\n`; +} diff --git a/packages/core/README.md b/packages/core/README.md index 95abf44..3c7e1f6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,9 +4,9 @@ Shared types and configuration parser for the [nerve](../../README.md) observati ## What's Inside -- **Type definitions** — `Signal`, `SenseConfig`, `SenseInfo`, `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** — `parseWorkflowTrigger`, `routeSenseComputeOutput`, and types `WorkflowTrigger`, `RoutedSenseOutput` +- **Type definitions** — `SenseConfig`, `SenseInfo`, `SenseComputeFn`, `SenseModule`, `WorkflowConfig`, `NerveConfig`, `WorkflowTrigger`, and related types +- **Config parser** — `parseNerveConfig(yaml)` validates and parses `nerve.yaml` into `NerveConfig` (top-level `reflexes` is rejected; use `interval` / `on` on each sense) +- **Workflow triggers** — `parseWorkflowTrigger` validates structured workflow launch objects from Sense compute results or IPC - **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`, `RoleResult`, plus `DEFAULT_ENGINE_MAX_ROUNDS` - **Result type** — `Result` with `ok()` / `err()` helpers for explicit error handling (no thrown exceptions for parse paths) @@ -15,7 +15,7 @@ Shared types and configuration parser for the [nerve](../../README.md) observati ```typescript import { parseNerveConfig, ok, err } from "@uncaged/nerve-core"; -import type { NerveConfig, Signal, Result } from "@uncaged/nerve-core"; +import type { NerveConfig, Result } from "@uncaged/nerve-core"; const result: Result = parseNerveConfig(yamlString); if (result.ok) { @@ -23,10 +23,10 @@ if (result.ok) { } ``` -### Sense return → signal vs workflow +### Workflow trigger validation ```typescript -import { parseWorkflowTrigger, routeSenseComputeOutput } from "@uncaged/nerve-core"; +import { parseWorkflowTrigger } from "@uncaged/nerve-core"; const directive = parseWorkflowTrigger({ name: "my-workflow", @@ -37,23 +37,10 @@ const directive = parseWorkflowTrigger({ if (directive.ok) { console.log(directive.value.name, directive.value.maxRounds, directive.value.prompt); } - -const route = routeSenseComputeOutput({ - signal: { metric: 42 }, - workflow: { - name: "my-workflow", - maxRounds: 8, - prompt: "Run now", - dryRun: false, - }, -}); -if (route.ok && route.value.workflow !== null) { - console.log(route.value.workflow); -} else if (route.ok) { - console.log(route.value.signal); -} ``` +Sense modules return `{ state, workflow }` from `compute(state)`; when `workflow` is non-null it must satisfy the shape validated by `parseWorkflowTrigger` (the daemon validates before starting a run). + ## Duration Format Config fields like `throttle`, `timeout`, and `interval` accept human-readable durations: diff --git a/packages/core/package.json b/packages/core/package.json index b46028a..e784889 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,7 +20,6 @@ "test": "vitest run" }, "dependencies": { - "drizzle-orm": "1.0.0-beta.23-c10d10c", "yaml": "^2.8.3" }, "devDependencies": { diff --git a/packages/core/src/__tests__/config.test.ts b/packages/core/src/__tests__/config.test.ts index 9443f28..126b8e0 100644 --- a/packages/core/src/__tests__/config.test.ts +++ b/packages/core/src/__tests__/config.test.ts @@ -34,7 +34,6 @@ describe("parseNerveConfig", () => { throttle: 5000, timeout: null, gracePeriod: null, - retention: 10_000, interval: 30_000, on: [], }); @@ -43,7 +42,6 @@ describe("parseNerveConfig", () => { throttle: null, timeout: 10_000, gracePeriod: 3000, - retention: 10_000, interval: null, on: ["high_usage"], }); @@ -83,25 +81,11 @@ senses: throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }); }); - it("parses optional retention as a positive integer", () => { - const yaml = ` -senses: - cpu: - group: system - retention: 5000 -`; - const result = parseNerveConfig(yaml); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value.senses.cpu.retention).toBe(5000); - }); - it("accepts all valid duration suffixes (s, m, h)", () => { const yaml = ` senses: @@ -343,45 +327,6 @@ api: expect(result.error.message).toMatch(/senses/); }); - it("returns error when retention is zero", () => { - const yaml = ` -senses: - cpu: - group: system - retention: 0 -`; - const result = parseNerveConfig(yaml); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.message).toMatch(/retention.*positive integer/); - }); - - it("returns error when retention is not an integer", () => { - const yaml = ` -senses: - cpu: - group: system - retention: 1.5 -`; - const result = parseNerveConfig(yaml); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.message).toMatch(/retention.*positive integer/); - }); - - it("returns error when retention is not a number", () => { - const yaml = ` -senses: - cpu: - group: system - retention: "5000" -`; - const result = parseNerveConfig(yaml); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error.message).toMatch(/retention.*positive integer/); - }); - it("returns error for invalid throttle format", () => { const yaml = ` senses: diff --git a/packages/core/src/__tests__/sense-workflow-directive.test.ts b/packages/core/src/__tests__/sense-workflow-directive.test.ts index 402dbea..316b8f3 100644 --- a/packages/core/src/__tests__/sense-workflow-directive.test.ts +++ b/packages/core/src/__tests__/sense-workflow-directive.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseWorkflowTrigger, routeSenseComputeOutput } from "../sense.js"; +import { parseWorkflowTrigger } from "../sense.js"; describe("parseWorkflowTrigger", () => { it("accepts a valid trigger object", () => { @@ -57,60 +57,3 @@ describe("parseWorkflowTrigger", () => { expect(r.ok).toBe(false); }); }); - -describe("routeSenseComputeOutput", () => { - it("wraps non-record values as signal-only", () => { - const r = routeSenseComputeOutput(99); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.value).toEqual({ signal: 99, workflow: null }); - }); - - it("wraps plain objects without signal key as signal-only", () => { - const r = routeSenseComputeOutput({ count: 2 }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.value).toEqual({ signal: { count: 2 }, workflow: null }); - }); - - it("parses explicit signal with null workflow", () => { - const r = routeSenseComputeOutput({ signal: { a: 1 }, workflow: null }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.value).toEqual({ signal: { a: 1 }, workflow: null }); - }); - - it("parses explicit signal with workflow trigger", () => { - const r = routeSenseComputeOutput({ - signal: { x: true }, - workflow: { name: "wf", maxRounds: 2, prompt: "p", dryRun: false }, - }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.value.signal).toEqual({ x: true }); - expect(r.value.workflow).toEqual({ - name: "wf", - maxRounds: 2, - prompt: "p", - dryRun: false, - }); - }); - - it("defaults missing workflow key to null", () => { - const r = routeSenseComputeOutput({ signal: 7 }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.value).toEqual({ signal: 7, workflow: null }); - }); - - it("degrades to signal-only when workflow object is invalid", () => { - const r = routeSenseComputeOutput({ - signal: { v: 1 }, - workflow: { name: "w", maxRounds: 0, prompt: "", dryRun: false }, - }); - expect(r.ok).toBe(true); - if (!r.ok) return; - expect(r.value.signal).toEqual({ v: 1 }); - expect(r.value.workflow).toBeNull(); - }); -}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ce85a1b..b63dac5 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -8,11 +8,9 @@ export type SenseConfig = { throttle: number | null; timeout: number | null; gracePeriod: number | null; - /** Max rows to retain in `_signals`; older rows are pruned periodically after inserts. */ - retention: number; /** Polling interval (ms). When set, the sense is triggered periodically. */ interval: number | null; - /** Other sense names whose signals trigger this sense. */ + /** Other sense names whose successful computes schedule this sense (kernel reverse-index). */ on: string[]; }; @@ -62,12 +60,6 @@ export type WorkflowTrigger = { dryRun: boolean; }; -/** - * Sense `compute()` return: silence, or a signal payload with an optional workflow to start. - * `workflow: null` means signal only; signal is always emitted first when non-null. - */ -export type ComputeResult = null | { signal: T; workflow: WorkflowTrigger | null }; - export type NerveConfig = { /** Engine-wide default max moderator rounds (e.g. CLI workflow trigger when omitted). */ maxRounds: number; @@ -83,23 +75,10 @@ export type KnowledgeConfig = { exclude: ReadonlyArray; }; -/** Default max rows kept in each sense's `_signals` SQLite table (see `retention` on `SenseConfig`). */ -export const DEFAULT_SENSE_SIGNAL_RETENTION = 10_000; - function isValidGroupName(value: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(value); } -function parseRetentionField(name: string, field: unknown): Result { - if (field === undefined || field === null) { - return ok(DEFAULT_SENSE_SIGNAL_RETENTION); - } - if (typeof field !== "number" || !Number.isInteger(field) || field < 1) { - return err(new Error(`senses.${name}.retention: must be a positive integer`)); - } - return ok(field); -} - function parseDurationField(field: unknown, label: string): Result { if (field === undefined || field === null) return ok(null); if (typeof field !== "string") { @@ -142,9 +121,6 @@ function validateSenseConfig(name: string, raw: unknown): Result { const graceResult = parseDurationField(obj.grace_period, `senses.${name}.grace_period`); if (!graceResult.ok) return graceResult; - const retentionResult = parseRetentionField(name, obj.retention); - if (!retentionResult.ok) return retentionResult; - const intervalResult = parseDurationField(obj.interval, `senses.${name}.interval`); if (!intervalResult.ok) return intervalResult; @@ -164,7 +140,6 @@ function validateSenseConfig(name: string, raw: unknown): Result { throttle: throttleResult.value, timeout: timeoutResult.value, gracePeriod: graceResult.value, - retention: retentionResult.value, interval: intervalResult.value, on, }); diff --git a/packages/core/src/daemon.ts b/packages/core/src/daemon.ts index aeb2a74..532416e 100644 --- a/packages/core/src/daemon.ts +++ b/packages/core/src/daemon.ts @@ -186,8 +186,7 @@ export function isSenseInfo(value: unknown): value is SenseInfo { (value.throttle === null || typeof value.throttle === "number") && (value.timeout === null || typeof value.timeout === "number") && Array.isArray(value.triggers) && - value.triggers.every((t: unknown) => typeof t === "string") && - (value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number") + value.triggers.every((t: unknown) => typeof t === "string") ); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3076306..0a34531 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,3 @@ -export { DEFAULT_SENSE_SIGNAL_RETENTION } from "./config.js"; export type { SenseConfig, DropOverflowConfig, @@ -9,9 +8,8 @@ export type { ExtractConfig, NerveConfig, WorkflowTrigger, - ComputeResult, } from "./config.js"; -export type { Signal, SenseInfo } from "./sense.js"; +export type { SenseInfo } from "./sense.js"; export type { SenseComputeFn, SenseModule } from "./sense.js"; export { senseTriggerLabels } from "./sense.js"; export type { @@ -46,8 +44,7 @@ export type { KnowledgeConfig } from "./config.js"; export { parseKnowledgeYaml } from "./config.js"; export { isPlainRecord } from "./util.js"; -export type { RoutedSenseOutput } from "./sense.js"; -export { parseWorkflowTrigger, routeSenseComputeOutput } from "./sense.js"; +export { parseWorkflowTrigger } from "./sense.js"; export { isSenseInfo, isWorkflowStatus } from "./daemon.js"; export type { diff --git a/packages/core/src/sense.ts b/packages/core/src/sense.ts index 1981aa7..19ceb3e 100644 --- a/packages/core/src/sense.ts +++ b/packages/core/src/sense.ts @@ -1,15 +1,6 @@ -import type { SQLiteTable } from "drizzle-orm/sqlite-core"; - -import type { ComputeResult, SenseConfig, WorkflowTrigger } from "./config.js"; +import type { SenseConfig, WorkflowTrigger } from "./config.js"; import { type Result, err, isPlainRecord, ok } from "./util.js"; -export type Signal = { - id: number; - senseId: string; - payload: unknown; - timestamp: number; -}; - /** Runtime metadata for a sense (e.g. daemon list-senses IPC). */ export type SenseInfo = { name: string; @@ -18,7 +9,6 @@ export type SenseInfo = { timeout: number | null; /** Declarative schedule (`interval` / `on`) for this sense (derived from nerve.yaml). */ triggers: string[]; - lastSignalTimestamp: number | null; }; /** @@ -26,25 +16,18 @@ export type SenseInfo = { * `compute` export. * * Pure: no DB, no peers. - * Return `null` to stay silent, or `{ signal, workflow }` to emit a Signal - * (and optionally trigger a Workflow). - * The runtime handles persistence via `db.insert(table).values(result.signal)`. + * Returns the next sense state and an optional workflow to start (`workflow: null` means no workflow). */ -export type SenseComputeFn = () => Promise>; +export type SenseComputeFn = ( + state: S, +) => Promise<{ state: S; workflow: WorkflowTrigger | null }>; /** * The full shape a sense module (`src/index.ts`) must export. - * `compute` provides the data; `table` tells the runtime where to persist it. */ -export type SenseModule = { - compute: SenseComputeFn; - table: SQLiteTable; -}; - -/** Normalized non-null compute output for the kernel (unknown signal payload). */ -export type RoutedSenseOutput = { - signal: unknown; - workflow: WorkflowTrigger | null; +export type SenseModule = { + compute: SenseComputeFn; + initialState: S; }; function formatIntervalMs(ms: number): string { @@ -111,23 +94,3 @@ export function parseWorkflowTrigger(value: unknown): Result { } return ok({ name: nameRaw.trim(), maxRounds, prompt, dryRun }); } - -/** - * Interprets a Sense compute non-null return value for the engine. - * - Explicit `{ signal, workflow }` (workflow may be null): validates `workflow` when non-null. - * - Any other value: treated as `{ signal: payload, workflow: null }` (shorthand). - */ -export function routeSenseComputeOutput(payload: unknown): Result { - if (isPlainRecord(payload) && Object.hasOwn(payload, "signal")) { - const wfRaw = Object.hasOwn(payload, "workflow") ? payload.workflow : null; - if (wfRaw === null) { - return ok({ signal: payload.signal, workflow: null }); - } - const parsed = parseWorkflowTrigger(wfRaw); - if (!parsed.ok) { - return ok({ signal: payload.signal, workflow: null }); - } - return ok({ signal: payload.signal, workflow: parsed.value }); - } - return ok({ signal: payload, workflow: null }); -} diff --git a/packages/daemon/README.md b/packages/daemon/README.md index fe35dc5..025cdc2 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -1,23 +1,22 @@ # @uncaged/nerve-daemon -The observation engine runtime for [nerve](../../README.md) — runs senses, routes signals, runs the sense scheduler, and manages workflows. +The observation engine runtime for [nerve](../../README.md) — runs senses, persists JSON state, runs the sense scheduler, and manages workflows. ## Architecture | Module | Source (indicative) | Responsibility | |--------|---------------------|----------------| -| **Kernel** | `kernel.ts` | Orchestrator — worker pool, signal bus, sense scheduler, workflow manager, optional file watcher and daemon IPC, config reload hooks | +| **Kernel** | `kernel.ts` | Orchestrator — worker pool, sense scheduler, workflow manager, optional file watcher and daemon IPC, config reload hooks | | **Worker pool** | `worker-pool.ts` | Fork and supervise one child process per sense group; restart/shutdown; crash cleanup hooks for scheduler state | | **Kernel sense groups** | `kernel-sense-groups.ts` | Derive sense groups from config; list senses per group for scheduling | -| **Sense runtime** | sense worker + Drizzle | Per-sense SQLite (`node:sqlite`), migrations, peer DB reads | -| **Sense worker** | `sense-worker.ts` (fork target) | Child process entry — runs `compute()` per sense in a group | -| **Signal bus** | `signal-bus.ts` | In-memory pub/sub for sense signals | -| **Sense scheduler** | `sense-scheduler.ts` | Interval + `on` subscriptions, throttle/coalesce | +| **Sense runtime** | `sense-runtime.ts` + sense worker | Loads user modules (`compute`, `initialState`), reads/writes `data/senses/.json` | +| **Sense worker** | `sense-worker.ts` (fork target) | Child process entry — runs `compute(state)` per sense in a group | +| **Sense scheduler** | `sense-scheduler.ts` | Interval + `on` subscriptions (reverse-index by upstream sense), throttle/coalesce | | **Workflow manager** | `workflow-manager.ts` | One worker per workflow name, concurrency (drop/queue), queue caps | | **Workflow worker** | `workflow-worker.ts` | Child process — runs RFC-002 threads (`start-thread`, `resume-thread` IPC) | | **IPC (parent ↔ workers)** | `ipc.ts` | Typed messages for sense and workflow workers (includes `resume-thread` for recovery) | | **Log / workflow persistence** | via `@uncaged/nerve-store` | Structured logs, `workflow_runs`, thread messages (used for recovery) | -| **Blob store** | `@uncaged/nerve-store` | CAS under `data/blobs/` — sense workers construct `createBlobStore(join(nerveRoot, "data", "blobs"))` for artifact writes | +| **Blob store** | `@uncaged/nerve-store` | CAS under `data/blobs/` — workflows use blob storage for artifacts as configured | | **File watcher** | `file-watcher.ts` | Watches workspace paths for config / sense / workflow file changes | | **Kernel file watch** | `kernel-file-watch.ts` | Maps watcher events to `reloadConfig`, sense group restart, workflow `drainAndRespawn` | | **Daemon IPC** | `daemon-ipc.ts` | Unix socket server — parses `@uncaged/nerve-core` `DaemonIpcRequest`, dispatches trigger-workflow / trigger-sense / list-senses | @@ -35,9 +34,9 @@ Hot reload (`drainAndRespawn`) uses a controlled drain: in-flight runs may be ma ## Key Design Decisions - **One worker process per sense group** — isolation between groups, shared compute within a group -- **`node:sqlite` (DatabaseSync)** — zero native addons, WAL mode, built into Node.js ≥ 22.5 +- **Sense state as JSON** — `data/senses/.json`, updated after each successful compute in the worker - **Throttle + coalesce** — if compute is in-flight, at most one pending trigger is queued (no unbounded accumulation) -- **Log ≠ Signal** — logs are queryable data assets but cannot trigger the sense scheduler or workflows (prevents feedback loops) +- **Log ≠ Sense trigger** — logs are queryable data assets but cannot schedule sense computes or workflows (prevents feedback loops) ## Usage @@ -85,7 +84,7 @@ await kernel.stop(); pnpm add @uncaged/nerve-daemon ``` -Requires Node.js ≥ 22.5 (for `node:sqlite`). +Requires Node.js ≥ 22.5 (for `node:sqlite` in the log store and related persistence). ## License diff --git a/packages/daemon/package.json b/packages/daemon/package.json index bce6d47..e205d1d 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -28,7 +28,6 @@ "dependencies": { "@uncaged/nerve-core": "workspace:*", "@uncaged/nerve-store": "workspace:*", - "drizzle-orm": "1.0.0-beta.23-c10d10c", "yaml": "^2.8.3" }, "devDependencies": { diff --git a/packages/daemon/src/__tests__/daemon-ipc.test.ts b/packages/daemon/src/__tests__/daemon-ipc.test.ts index 5335ee9..c99cf99 100644 --- a/packages/daemon/src/__tests__/daemon-ipc.test.ts +++ b/packages/daemon/src/__tests__/daemon-ipc.test.ts @@ -241,7 +241,6 @@ describe("daemon-ipc — list-senses", () => { throttle: 5000, timeout: 3000, triggers: ["every 30s"], - lastSignalTimestamp: 1000, }, { name: "disk-usage", @@ -249,7 +248,6 @@ describe("daemon-ipc — list-senses", () => { throttle: 30000, timeout: null, triggers: [], - lastSignalTimestamp: null, }, ]; const listSenses = vi.fn(() => sensesData); diff --git a/packages/daemon/src/__tests__/file-watcher.test.ts b/packages/daemon/src/__tests__/file-watcher.test.ts index c957586..f29f30f 100644 --- a/packages/daemon/src/__tests__/file-watcher.test.ts +++ b/packages/daemon/src/__tests__/file-watcher.test.ts @@ -79,7 +79,7 @@ describe("createFileWatcher", () => { await new Promise((r) => setTimeout(r, 100)); writeFileSync( join(root, "senses", "cpu-usage", "index.js"), - "export async function compute() { return { signal: 42, workflow: null }; }", + "export const initialState = {}; export async function compute(state) { return { state, workflow: null }; }", ); await waitFor(() => changes.length > 0, 3000); diff --git a/packages/daemon/src/__tests__/fixtures/crash-once-worker.mjs b/packages/daemon/src/__tests__/fixtures/crash-once-worker.mjs index 73911fe..4324163 100644 --- a/packages/daemon/src/__tests__/fixtures/crash-once-worker.mjs +++ b/packages/daemon/src/__tests__/fixtures/crash-once-worker.mjs @@ -31,6 +31,11 @@ process.on("message", (msg) => { writeFileSync(markerFile, "crashed", "utf8"); process.exit(1); } - process.send({ type: "signal", sense: msg.sense, payload: 42 }); + process.send({ + type: "compute-result", + sense: msg.sense, + state: 42, + workflow: null, + }); } }); diff --git a/packages/daemon/src/__tests__/fixtures/mock-worker.mjs b/packages/daemon/src/__tests__/fixtures/mock-worker.mjs index 27265a1..da7b3e3 100644 --- a/packages/daemon/src/__tests__/fixtures/mock-worker.mjs +++ b/packages/daemon/src/__tests__/fixtures/mock-worker.mjs @@ -9,7 +9,7 @@ * * Behaviour: * - Sends { type: "ready" } on startup - * - On { type: "compute", sense } → sends back { type: "signal", sense, payload: 42 } + * - On { type: "compute", sense } → sends back compute-result with state + workflow:null * - On { type: "shutdown" } → exits cleanly with code 0 */ @@ -23,6 +23,11 @@ process.on("message", (msg) => { } if (msg.type === "compute" && typeof msg.sense === "string") { - process.send({ type: "signal", sense: msg.sense, payload: 42 }); + process.send({ + type: "compute-result", + sense: msg.sense, + state: 42, + workflow: null, + }); } }); diff --git a/packages/daemon/src/__tests__/fixtures/slow-worker.mjs b/packages/daemon/src/__tests__/fixtures/slow-worker.mjs index 8dad2c2..e974bd4 100644 --- a/packages/daemon/src/__tests__/fixtures/slow-worker.mjs +++ b/packages/daemon/src/__tests__/fixtures/slow-worker.mjs @@ -18,7 +18,12 @@ process.on("message", (msg) => { if (msg.type === "compute" && typeof msg.sense === "string") { // Intentionally slow — will be killed by grace period setTimeout(() => { - process.send({ type: "signal", sense: msg.sense, payload: "late" }); + process.send({ + type: "compute-result", + sense: msg.sense, + state: "late", + workflow: null, + }); }, 10_000); } }); diff --git a/packages/daemon/src/__tests__/kernel-integration.test.ts b/packages/daemon/src/__tests__/kernel-integration.test.ts index 10906e4..ff65455 100644 --- a/packages/daemon/src/__tests__/kernel-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-integration.test.ts @@ -11,7 +11,6 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { Signal } from "@uncaged/nerve-core"; import type { NerveConfig } from "@uncaged/nerve-core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -30,7 +29,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -82,7 +80,6 @@ describe("kernel integration — real child processes", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -91,7 +88,6 @@ describe("kernel integration — real child processes", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -100,7 +96,6 @@ describe("kernel integration — real child processes", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -116,31 +111,32 @@ describe("kernel integration — real child processes", () => { expect(kernel.senseCount).toBe(3); }); - it("workers start and respond to compute messages with signals", async () => { + it("workers start and respond to compute messages with compute-result", async () => { const config = makeConfig(); kernel = createKernel(config, nerveRoot, { workerScript: MOCK_WORKER, }); - // Wait for all workers to be ready (event-based, not fixed delay) await kernel.ready; - // Subscribe to the bus before triggering compute - const received: Signal[] = []; - const unsub = kernel.bus.subscribe((signal) => { - received.push(signal); - }); - - // Trigger a compute for "cpu-usage" through the kernel's triggerCompute kernel.triggerCompute("cpu-usage"); - // Poll until a signal arrives on the bus (event-driven, no fixed delay) - await pollUntil(() => received.length > 0, 10_000); + await pollUntil( + () => + kernel!.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "cpu-usage", + }).length > 0, + 10_000, + ); - expect(received).toHaveLength(1); - expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 }); - - unsub(); + const rows = kernel.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "cpu-usage", + }); + expect(rows).toHaveLength(1); }, 15_000); it("graceful shutdown: stop() resolves after all workers exit", async () => { @@ -151,7 +147,6 @@ describe("kernel integration — real child processes", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -160,7 +155,6 @@ describe("kernel integration — real child processes", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -180,31 +174,32 @@ describe("kernel integration — real child processes", () => { await expect(stopPromise).resolves.toBeUndefined(); }, 10_000); - it("compute round-trip: worker receives compute and sends signal back through bus", async () => { + it("compute round-trip: worker receives compute and kernel logs compute-complete", async () => { const config = makeConfig(); kernel = createKernel(config, nerveRoot, { workerScript: MOCK_WORKER, }); - // Wait for all workers to be ready (event-based, not fixed delay) await kernel.ready; - const received: Signal[] = []; - const unsub = kernel.bus.subscribe((signal) => { - received.push(signal); - }); - - // Trigger compute via the kernel — the kernel sends IPC to the worker, - // the mock worker responds with a signal message, and the kernel routes it to the bus. kernel.triggerCompute("cpu-usage"); - // Poll for the signal on the bus (no fixed delay) - await pollUntil(() => received.length > 0, 10_000); + await pollUntil( + () => + kernel!.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "cpu-usage", + }).length > 0, + 10_000, + ); - expect(received).toHaveLength(1); - expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 }); - - unsub(); + const rows = kernel.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "cpu-usage", + }); + expect(rows).toHaveLength(1); }, 15_000); it("crash recovery: kernel respawns worker after unexpected exit and new worker is functional", async () => { @@ -234,23 +229,24 @@ describe("kernel integration — real child processes", () => { expect(newPid).not.toBeNull(); expect(newPid).not.toBe(originalPid); - // Wait a bit for the new worker to send its "ready" message and be fully up. - // Poll until the new worker responds to a compute message on the bus. - const postRespawnSignals: Signal[] = []; - const unsub = kernel.bus.subscribe((signal) => { - postRespawnSignals.push(signal); - }); - - // Trigger compute through the kernel to the new worker kernel.triggerCompute("cpu-usage"); - // Poll for the signal — verifies the new worker is fully functional - await pollUntil(() => postRespawnSignals.length > 0, 15_000); + await pollUntil( + () => + kernel!.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "cpu-usage", + }).length > 0, + 15_000, + ); - expect(postRespawnSignals).toHaveLength(1); - expect(postRespawnSignals[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 }); - - unsub(); + const rows = kernel.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "cpu-usage", + }); + expect(rows.length).toBeGreaterThanOrEqual(1); // Kernel should still stop gracefully after respawn await kernel.stop(); diff --git a/packages/daemon/src/__tests__/kernel-phase6.test.ts b/packages/daemon/src/__tests__/kernel-phase6.test.ts index cdc66ff..4958a18 100644 --- a/packages/daemon/src/__tests__/kernel-phase6.test.ts +++ b/packages/daemon/src/__tests__/kernel-phase6.test.ts @@ -86,7 +86,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -125,7 +124,6 @@ describe("kernel — getHealth", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -134,7 +132,6 @@ describe("kernel — getHealth", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -143,7 +140,6 @@ describe("kernel — getHealth", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -246,7 +242,6 @@ describe("kernel — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -255,7 +250,6 @@ describe("kernel — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -283,7 +277,6 @@ describe("kernel — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -292,7 +285,6 @@ describe("kernel — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -318,7 +310,6 @@ describe("kernel — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -352,7 +343,6 @@ describe("kernel — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -361,7 +351,6 @@ describe("kernel — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, diff --git a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts index 9688a7a..e742875 100644 --- a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts +++ b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts @@ -101,7 +101,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -153,7 +152,6 @@ describe("kernel.triggerSense()", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -162,7 +160,6 @@ describe("kernel.triggerSense()", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -199,7 +196,6 @@ describe("kernel.triggerSense()", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -208,7 +204,6 @@ describe("kernel.triggerSense()", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, diff --git a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts index d9cc1bb..dd48647 100644 --- a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts @@ -1,8 +1,8 @@ /** * Integration tests for Kernel + WorkflowManager integration. * - * Verifies that sense signals trigger workflow runs when Sense compute routes - * to workflows; that workflow events are logged; that reloadConfig handles workflow changes; + * Verifies that sense compute-result IPC triggers workflow runs when `workflow` + * is non-null; that workflow events are logged; that reloadConfig handles workflow changes; * and that graceful shutdown stops workflow workers. * * Uses mocked child_process.fork to avoid real subprocesses. @@ -53,7 +53,12 @@ function makeMockChild(pid = 1): MockChild { // Sense IPC: reply to compute so scheduler completes (onComputeComplete). if (m.type === "compute" && typeof m.sense === "string") { setImmediate(() => { - child.emit("message", { type: "signal", sense: m.sense, payload: 42 }); + child.emit("message", { + type: "compute-result", + sense: m.sense, + state: 42, + workflow: null, + }); }); } }); @@ -117,7 +122,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -159,7 +163,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -174,22 +177,17 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - // Simulate a sense worker sending a signal with workflow launch payload - // The kernel's handleWorkerMessage processes "signal" type messages - // and uses routeSenseComputeOutput to detect workflow launches const workerPool = mockChildren[0]; if (workerPool) { workerPool.emit("message", { - type: "signal", + type: "compute-result", sense: "cpu-usage", - payload: { - signal: { reason: "test" }, - workflow: { - name: "my-workflow", - maxRounds: 10, - prompt: "run this workflow", - dryRun: false, - }, + state: { reason: "test" }, + workflow: { + name: "my-workflow", + maxRounds: 10, + prompt: "run this workflow", + dryRun: false, }, }); } @@ -221,7 +219,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -236,20 +233,17 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - // Simulate sense worker returning a signal plus workflow launch const workerPool = mockChildren[0]; if (workerPool) { workerPool.emit("message", { - type: "signal", + type: "compute-result", sense: "cpu-usage", - payload: { - signal: { level: "critical" }, - workflow: { - name: "alert-workflow", - maxRounds: 5, - prompt: "handle critical alert", - dryRun: false, - }, + state: { level: "critical" }, + workflow: { + name: "alert-workflow", + maxRounds: 5, + prompt: "handle critical alert", + dryRun: false, }, }); } @@ -280,7 +274,7 @@ describe("kernel + workflowManager integration", () => { await stopPromise; }); - it("logs sense signal before workflow-launch when both are present", async () => { + it("logs compute-complete before workflow-launch when workflow is present", async () => { const logStore = makeLogStore(); const config = makeConfig({ workflows: { "order-wf": { concurrency: 1, overflow: "drop" } }, @@ -296,16 +290,14 @@ describe("kernel + workflowManager integration", () => { const workerPool = mockChildren[0]; if (workerPool) { workerPool.emit("message", { - type: "signal", + type: "compute-result", sense: "cpu-usage", - payload: { - signal: { seq: 1 }, - workflow: { - name: "order-wf", - maxRounds: 2, - prompt: "p", - dryRun: true, - }, + state: { seq: 1 }, + workflow: { + name: "order-wf", + maxRounds: 2, + prompt: "p", + dryRun: true, }, }); } @@ -316,17 +308,17 @@ describe("kernel + workflowManager integration", () => { .map((c) => c[0] as { source: string; type: string; refId: string | null }) .filter((e) => e.source === "sense" && e.refId === "cpu-usage"); const typeOrder = senseEntries.map((e) => e.type); - const sigAt = typeOrder.indexOf("signal"); + const completeAt = typeOrder.indexOf("compute-complete"); const launchAt = typeOrder.indexOf("workflow-launch"); - expect(sigAt).toBeGreaterThanOrEqual(0); - expect(launchAt).toBeGreaterThan(sigAt); + expect(completeAt).toBeGreaterThanOrEqual(0); + expect(launchAt).toBeGreaterThan(completeAt); const stopPromise = kernel.stop(); await vi.runAllTimersAsync(); await stopPromise; }); - it("does not trigger workflow when signal senseId is not in 'on' list", async () => { + it("does not trigger workflow when compute-result has workflow null", async () => { const logStore = makeLogStore(); const config = makeConfig({ senses: { @@ -335,7 +327,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -344,7 +335,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -359,13 +349,13 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - // Emit a regular signal (shorthand payload) — should NOT trigger any workflow const workerPool = mockChildren[0]; if (workerPool) { workerPool.emit("message", { - type: "signal", + type: "compute-result", sense: "cpu-usage", - payload: 50, + state: 50, + workflow: null, }); } @@ -396,7 +386,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -411,20 +400,17 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - // Simulate sense compute returning a signal plus workflow launch const workerPool = mockChildren[0]; if (workerPool) { workerPool.emit("message", { - type: "signal", + type: "compute-result", sense: "cpu-usage", - payload: { - signal: { note: "log" }, - workflow: { - name: "log-test-workflow", - maxRounds: 10, - prompt: "test prompt", - dryRun: false, - }, + state: { note: "log" }, + workflow: { + name: "log-test-workflow", + maxRounds: 10, + prompt: "test prompt", + dryRun: false, }, }); } @@ -452,7 +438,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -476,7 +461,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -488,20 +472,17 @@ describe("kernel + workflowManager integration", () => { }; kernel.reloadConfig(newConfig); - // Simulate sense compute returning a signal plus workflow for the new workflow const workerPool = mockChildren[0]; if (workerPool) { workerPool.emit("message", { - type: "signal", + type: "compute-result", sense: "cpu-usage", - payload: { - signal: { phase: "reload" }, - workflow: { - name: "new-workflow", - maxRounds: 10, - prompt: "reload test", - dryRun: false, - }, + state: { phase: "reload" }, + workflow: { + name: "new-workflow", + maxRounds: 10, + prompt: "reload test", + dryRun: false, }, }); } @@ -534,7 +515,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -557,7 +537,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -574,20 +553,17 @@ describe("kernel + workflowManager integration", () => { (c.send as ReturnType).mockClear(); } - // Simulate sense compute trying to launch the removed workflow — it should not start const workerPool = mockChildren[0]; if (workerPool) { workerPool.emit("message", { - type: "signal", + type: "compute-result", sense: "cpu-usage", - payload: { - signal: { stale: true }, - workflow: { - name: "old-workflow", - maxRounds: 10, - prompt: "should not work", - dryRun: false, - }, + state: { stale: true }, + workflow: { + name: "old-workflow", + maxRounds: 10, + prompt: "should not work", + dryRun: false, }, }); } @@ -621,7 +597,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -636,20 +611,17 @@ describe("kernel + workflowManager integration", () => { await flushSenseWorkerForkMicrotasks(kernel); await vi.runAllTimersAsync(); - // Trigger a workflow via sense compute return value const workerPool = mockChildren[0]; if (workerPool) { workerPool.emit("message", { - type: "signal", + type: "compute-result", sense: "cpu-usage", - payload: { - signal: { shutdownCase: true }, - workflow: { - name: "shutdown-test", - maxRounds: 10, - prompt: "test", - dryRun: false, - }, + state: { shutdownCase: true }, + workflow: { + name: "shutdown-test", + maxRounds: 10, + prompt: "test", + dryRun: false, }, }); } @@ -689,7 +661,6 @@ describe("kernel + workflowManager integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, diff --git a/packages/daemon/src/__tests__/kernel.test.ts b/packages/daemon/src/__tests__/kernel.test.ts index 591f795..1e894f5 100644 --- a/packages/daemon/src/__tests__/kernel.test.ts +++ b/packages/daemon/src/__tests__/kernel.test.ts @@ -37,7 +37,12 @@ function makeMockChild(pid = 1): MockChild { } if (m.type === "compute" && typeof m.sense === "string") { setImmediate(() => { - child.emit("message", { type: "signal", sense: m.sense, payload: 42 }); + child.emit("message", { + type: "compute-result", + sense: m.sense, + state: 42, + workflow: null, + }); }); } }); @@ -81,7 +86,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -112,7 +116,7 @@ describe("kernel — message routing", () => { rmSync(nerveRoot, { recursive: true, force: true }); }); - it("routes signal message to bus without throwing", async () => { + it("routes compute-result message without throwing", async () => { const config = makeConfig({ senses: { "cpu-usage": { @@ -120,7 +124,6 @@ describe("kernel — message routing", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -133,14 +136,19 @@ describe("kernel — message routing", () => { const child = mockChildren[0]; expect(() => { - child.emit("message", { type: "signal", sense: "cpu-usage", payload: 42 }); + child.emit("message", { + type: "compute-result", + sense: "cpu-usage", + state: 42, + workflow: null, + }); }).not.toThrow(); await kernel.stop(); await vi.runAllTimersAsync(); }); - it("persists emitted signals as sense/signal log entries", async () => { + it("persists compute-complete log entries for sense IPC", async () => { const tmpDir = mkdtempSync(join(tmpdir(), "nerve-kernel-sig-")); const logStore = createLogStore(join(tmpDir, "logs.db")); try { @@ -151,7 +159,6 @@ describe("kernel — message routing", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -160,10 +167,19 @@ describe("kernel — message routing", () => { const kernel = createKernel(config, tmpDir, { logStore }); await vi.runAllTimersAsync(); const child = mockChildren[0]; - child.emit("message", { type: "signal", sense: "cpu-usage", payload: 123 }); - const rows = logStore.query({ source: "sense", type: "signal", refId: "cpu-usage" }); + child.emit("message", { + type: "compute-result", + sense: "cpu-usage", + state: 123, + workflow: null, + }); + const rows = logStore.query({ + source: "sense", + type: "compute-complete", + refId: "cpu-usage", + }); expect(rows).toHaveLength(1); - expect(rows[0].payload).toBe(JSON.stringify(123)); + expect(rows[0].payload).toBeNull(); await kernel.stop(); await vi.runAllTimersAsync(); } finally { @@ -180,7 +196,6 @@ describe("kernel — message routing", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -209,7 +224,6 @@ describe("kernel — message routing", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -237,7 +251,6 @@ describe("kernel — message routing", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -278,7 +291,6 @@ describe("kernel — groupForSense mapping", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -287,7 +299,6 @@ describe("kernel — groupForSense mapping", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -296,7 +307,6 @@ describe("kernel — groupForSense mapping", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -322,7 +332,6 @@ describe("kernel — groupForSense mapping", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: 500, on: [], }, diff --git a/packages/daemon/src/__tests__/log-store-integration.test.ts b/packages/daemon/src/__tests__/log-store-integration.test.ts index 41c067a..4310a30 100644 --- a/packages/daemon/src/__tests__/log-store-integration.test.ts +++ b/packages/daemon/src/__tests__/log-store-integration.test.ts @@ -3,11 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { NerveConfig, Signal } from "@uncaged/nerve-core"; +import type { NerveConfig } from "@uncaged/nerve-core"; import { createLogStore } from "@uncaged/nerve-store"; import type { LogStore } from "@uncaged/nerve-store"; import { createSenseScheduler } from "../sense-scheduler.js"; -import { createSignalBus } from "../signal-bus.js"; describe("LogStore + SenseScheduler integration", () => { let tmpDir: string; @@ -31,7 +30,6 @@ describe("LogStore + SenseScheduler integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: ["cpu-usage"], }, @@ -41,14 +39,12 @@ describe("LogStore + SenseScheduler integration", () => { extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; - const bus = createSignalBus(); const triggered: string[] = []; - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name), { + const scheduler = createSenseScheduler(config, (name) => triggered.push(name), { logStore, }); - const signal: Signal = { id: 1, senseId: "cpu-usage", payload: 42, timestamp: Date.now() }; - bus.emit(signal); + scheduler.onSenseCompleted("cpu-usage"); const logs = logStore.query({ source: "sense_scheduler", type: "run_start" }); expect(logs).toHaveLength(1); @@ -68,7 +64,6 @@ describe("LogStore + SenseScheduler integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: 1000, on: [], }, @@ -78,11 +73,9 @@ describe("LogStore + SenseScheduler integration", () => { extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; - const bus = createSignalBus(); const ref: { scheduler: ReturnType | null } = { scheduler: null }; const scheduler = createSenseScheduler( config, - bus, (name) => { ref.scheduler?.onComputeComplete(name); }, @@ -108,7 +101,6 @@ describe("LogStore + SenseScheduler integration", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: ["cpu-usage"], }, @@ -118,11 +110,9 @@ describe("LogStore + SenseScheduler integration", () => { extract: null, api: { port: null, token: null, host: "127.0.0.1" }, }; - const bus = createSignalBus(); const triggered: string[] = []; const scheduler = createSenseScheduler( config, - bus, (name) => { triggered.push(name); scheduler.onComputeComplete(name); @@ -138,8 +128,6 @@ describe("LogStore + SenseScheduler integration", () => { timestamp: Date.now(), }); - // Writing to the log store should NOT trigger any sense compute. - // Only bus.emit(signal) triggers scheduled senses. expect(triggered).toHaveLength(0); scheduler.stop(); diff --git a/packages/daemon/src/__tests__/phase6-integration.test.ts b/packages/daemon/src/__tests__/phase6-integration.test.ts index 5dd40aa..2cf03f8 100644 --- a/packages/daemon/src/__tests__/phase6-integration.test.ts +++ b/packages/daemon/src/__tests__/phase6-integration.test.ts @@ -7,7 +7,6 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { Signal } from "@uncaged/nerve-core"; import type { NerveConfig } from "@uncaged/nerve-core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -27,7 +26,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -97,13 +95,16 @@ describe("phase6 — restartGroup", () => { expect(newPid).not.toBeNull(); expect(newPid).not.toBe(oldPid); - // Verify new worker is functional - const received: Signal[] = []; - const unsub = kernel.bus.subscribe((s) => received.push(s)); kernel.triggerCompute("cpu-usage"); - await pollUntil(() => received.length > 0, 10_000); - expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 }); - unsub(); + await pollUntil( + () => + kernel!.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "cpu-usage", + }).length > 0, + 10_000, + ); }, 35_000); it("restartGroup on nonexistent group does nothing", async () => { @@ -154,7 +155,6 @@ describe("phase6 — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -163,7 +163,6 @@ describe("phase6 — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -191,7 +190,6 @@ describe("phase6 — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -200,7 +198,6 @@ describe("phase6 — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -224,7 +221,6 @@ describe("phase6 — reloadConfig", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -269,7 +265,6 @@ describe("phase6 — error isolation", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -278,7 +273,6 @@ describe("phase6 — error isolation", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -294,19 +288,27 @@ describe("phase6 — error isolation", () => { }); await kernel.ready; - // Both senses go through the same worker (mock-worker responds to all compute with signal) - const received: Signal[] = []; - const unsub = kernel.bus.subscribe((s) => received.push(s)); - kernel.triggerCompute("good-sense"); - await pollUntil(() => received.length > 0, 10_000); - expect(received[0]).toMatchObject({ senseId: "good-sense" }); + await pollUntil( + () => + kernel!.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "good-sense", + }).length > 0, + 10_000, + ); kernel.triggerCompute("bad-sense"); - await pollUntil(() => received.length > 1, 10_000); - expect(received[1]).toMatchObject({ senseId: "bad-sense" }); - - unsub(); + await pollUntil( + () => + kernel!.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "bad-sense", + }).length > 0, + 10_000, + ); }, 10_000); it("error worker sends error messages, kernel still running", async () => { @@ -366,7 +368,6 @@ describe("phase6 — getHealth", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -375,7 +376,6 @@ describe("phase6 — getHealth", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -384,7 +384,6 @@ describe("phase6 — getHealth", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -420,7 +419,6 @@ describe("phase6 — getHealth", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -429,7 +427,6 @@ describe("phase6 — getHealth", () => { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -489,13 +486,16 @@ describe("phase6 — auto-respawn on worker crash", () => { expect(newPid).not.toBeNull(); expect(newPid).not.toBe(originalPid); - // Verify new worker responds - const received: Signal[] = []; - const unsub = kernel.bus.subscribe((s) => received.push(s)); kernel.triggerCompute("cpu-usage"); - await pollUntil(() => received.length > 0, 10_000); - expect(received[0]).toMatchObject({ senseId: "cpu-usage", payload: 42 }); - unsub(); + await pollUntil( + () => + kernel!.logStore.query({ + source: "sense", + type: "compute-complete", + refId: "cpu-usage", + }).length > 0, + 10_000, + ); await kernel.stop(); kernel = null; diff --git a/packages/daemon/src/__tests__/sense-runtime.test.ts b/packages/daemon/src/__tests__/sense-runtime.test.ts index 29b40fe..a7a7f22 100644 --- a/packages/daemon/src/__tests__/sense-runtime.test.ts +++ b/packages/daemon/src/__tests__/sense-runtime.test.ts @@ -1,281 +1,118 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; -import { drizzle } from "drizzle-orm/node-sqlite"; -import { integer, real, sqliteTable } from "drizzle-orm/sqlite-core"; +import type { SenseComputeFn } from "@uncaged/nerve-core"; import { describe, expect, it } from "vitest"; import { parseParentMessage } from "../ipc.js"; -import { executeCompute, openSenseDb, runMigrations } from "../sense-runtime.js"; -import type { DrizzleDB, SenseRuntime } from "../sense-runtime.js"; +import { executeCompute, readState, writeState } from "../sense-runtime.js"; +import type { SenseRuntime } from "../sense-runtime.js"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const INIT_SQL = ` -CREATE TABLE IF NOT EXISTS samples ( - ts INTEGER PRIMARY KEY, - value REAL NOT NULL -); -`; - -function makeTempMigrationsDir(sql: string): string { - const dir = mkdtempSync(join(tmpdir(), "nerve-test-")); - writeFileSync(join(dir, "0001_init.sql"), sql); - return dir; +function makeTempStatePath(): string { + const dir = mkdtempSync(join(tmpdir(), "nerve-state-")); + return join(dir, "sense.json"); } -function makeTempMigrationsDirEmpty(): string { - return mkdtempSync(join(tmpdir(), "nerve-test-empty-")); -} - -function makeTempDbPath(): string { - const dir = mkdtempSync(join(tmpdir(), "nerve-db-")); - return join(dir, "test.db"); -} - -const samples = sqliteTable("samples", { - ts: integer("ts").primaryKey(), - value: real("value").notNull(), -}); - -// --------------------------------------------------------------------------- -// runMigrations -// --------------------------------------------------------------------------- - -describe("runMigrations", () => { - it("creates table via SQL migration file", () => { - const sqlite = new DatabaseSync(":memory:"); - const migrationsDir = makeTempMigrationsDir(INIT_SQL); - const result = runMigrations(sqlite, migrationsDir); - - expect(result.ok).toBe(true); - - const row = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='samples'") - .get(); - expect(row).toBeDefined(); - - sqlite.close(); +describe("readState / writeState", () => { + it("writeState creates parent dirs and persists JSON", () => { + const base = mkdtempSync(join(tmpdir(), "nerve-write-")); + const path = join(base, "nested", "a.json"); + writeState(path, { n: 1 }); + const raw = readFileSync(path, "utf8"); + expect(JSON.parse(raw)).toEqual({ n: 1 }); + rmSync(base, { recursive: true, force: true }); }); - it("runs multiple migrations in lexicographic order", () => { - const sqlite = new DatabaseSync(":memory:"); - const dir = mkdtempSync(join(tmpdir(), "nerve-multi-")); - - writeFileSync(join(dir, "0001_init.sql"), INIT_SQL); - writeFileSync(join(dir, "0002_add_col.sql"), "ALTER TABLE samples ADD COLUMN label TEXT;"); - - const result = runMigrations(sqlite, dir); - expect(result.ok).toBe(true); - - const info = sqlite.prepare("PRAGMA table_info(samples)").all() as Array<{ name: string }>; - const cols = info.map((r) => r.name); - expect(cols).toContain("label"); - - sqlite.close(); + it("readState returns initialState when file is missing", () => { + const path = join(mkdtempSync(join(tmpdir(), "nerve-missing-")), "none.json"); + expect(readState(path, { x: 0 })).toEqual({ x: 0 }); }); - it("returns ok when migrations directory is empty", () => { - const sqlite = new DatabaseSync(":memory:"); - const dir = makeTempMigrationsDirEmpty(); - const result = runMigrations(sqlite, dir); - expect(result.ok).toBe(true); - sqlite.close(); + it("readState returns parsed JSON when file exists", () => { + const dir = mkdtempSync(join(tmpdir(), "nerve-read-")); + const path = join(dir, "s.json"); + writeFileSync(path, JSON.stringify({ count: 3 }), "utf8"); + expect(readState(path, { count: 0 })).toEqual({ count: 3 }); + rmSync(dir, { recursive: true, force: true }); }); - it("returns err when migrations directory does not exist", () => { - const sqlite = new DatabaseSync(":memory:"); - const result = runMigrations(sqlite, "/nonexistent/path/migrations"); - expect(result.ok).toBe(false); - sqlite.close(); - }); - - it("returns err when a migration SQL is invalid", () => { - const sqlite = new DatabaseSync(":memory:"); - const dir = mkdtempSync(join(tmpdir(), "nerve-bad-sql-")); - writeFileSync(join(dir, "0001_bad.sql"), "NOT VALID SQL !!!;"); - const result = runMigrations(sqlite, dir); - expect(result.ok).toBe(false); - sqlite.close(); + it("readState returns initialState on invalid JSON", () => { + const dir = mkdtempSync(join(tmpdir(), "nerve-badjson-")); + const path = join(dir, "bad.json"); + writeFileSync(path, "not json {{{", "utf8"); + expect(readState(path, { fallback: true })).toEqual({ fallback: true }); + rmSync(dir, { recursive: true, force: true }); }); }); -// --------------------------------------------------------------------------- -// openSenseDb -// --------------------------------------------------------------------------- - -describe("openSenseDb", () => { - it("creates the db file and runs migrations", () => { - const dbPath = makeTempDbPath(); - const migrationsDir = makeTempMigrationsDir(INIT_SQL); - - const result = openSenseDb(dbPath, migrationsDir); - expect(result.ok).toBe(true); - - if (!result.ok) return; - const { sqlite } = result.value; - const row = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='samples'") - .get(); - expect(row).toBeDefined(); - sqlite.close(); - }); - - it("returns err when migrations dir is missing", () => { - const dbPath = makeTempDbPath(); - const result = openSenseDb(dbPath, "/nonexistent/migrations"); - expect(result.ok).toBe(false); - }); - - it("prunes _signals to retention after every 100 inserts", () => { - const dbPath = makeTempDbPath(); - const migrationsDir = makeTempMigrationsDir(INIT_SQL); - const result = openSenseDb(dbPath, migrationsDir, 5); - expect(result.ok).toBe(true); - if (!result.ok) return; - - const { sqlite, persistSignal } = result.value; - for (let i = 0; i < 100; i++) { - persistSignal({ n: i }); - } - - const count = sqlite.prepare("SELECT COUNT(*) AS c FROM _signals").get() as { c: number }; - expect(count.c).toBe(5); - - sqlite.close(); - }); -}); - -// --------------------------------------------------------------------------- -// executeCompute -// --------------------------------------------------------------------------- - describe("executeCompute", () => { function makeRuntime( - computeFn: SenseRuntime["compute"], - sqlite?: DatabaseSync, - ): { runtime: SenseRuntime; sqlite: DatabaseSync } { - const db_sqlite = sqlite ?? new DatabaseSync(":memory:"); - if (!sqlite) db_sqlite.exec(INIT_SQL); - const db = drizzle({ client: db_sqlite }) as DrizzleDB; + compute: SenseComputeFn<{ n: number }>, + state: { n: number }, + statePath?: string, + ): SenseRuntime { return { - runtime: { - name: "test-sense", - db, - compute: computeFn, - table: samples, - persistSignal: () => {}, - }, - sqlite: db_sqlite, + name: "test-sense", + compute: compute as SenseComputeFn, + state, + statePath: statePath ?? makeTempStatePath(), }; } - it("returns non-null and inserts into table when compute returns data", async () => { - const { runtime, sqlite } = makeRuntime(async () => ({ - signal: { ts: 1000, value: 0.5 }, - workflow: null, - })); + it("passes state into compute and persists returned state", async () => { + const path = makeTempStatePath(); + const runtime = makeRuntime( + async (s) => ({ state: { n: s.n + 1 }, workflow: null }), + { n: 0 }, + path, + ); const result = await executeCompute(runtime); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value).toEqual({ signal: { ts: 1000, value: 0.5 }, workflow: null }); - - const rows = sqlite.prepare("SELECT * FROM samples").all(); - expect(rows).toHaveLength(1); - sqlite.close(); - }); - - it("returns null and does not insert when compute returns null", async () => { - const { runtime, sqlite } = makeRuntime(async () => null); - - const result = await executeCompute(runtime); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.value).toBeNull(); - - const rows = sqlite.prepare("SELECT * FROM samples").all(); - expect(rows).toHaveLength(0); - sqlite.close(); + expect(result.value).toEqual({ state: { n: 1 }, workflow: null }); + expect(runtime.state).toEqual({ n: 1 }); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ n: 1 }); }); it("returns err when compute throws", async () => { - const { runtime, sqlite } = makeRuntime(async () => { - throw new Error("something went wrong"); - }); + const runtime = makeRuntime( + async () => { + throw new Error("boom"); + }, + { n: 0 }, + ); const result = await executeCompute(runtime); expect(result.ok).toBe(false); if (result.ok) return; - expect(result.error.message).toContain("something went wrong"); - sqlite.close(); - }); - - it("inserts correctly into the sense db from openSenseDb", async () => { - const dbPath = makeTempDbPath(); - const migrationsDir = makeTempMigrationsDir(INIT_SQL); - const dbResult = openSenseDb(dbPath, migrationsDir); - expect(dbResult.ok).toBe(true); - if (!dbResult.ok) return; - - mkdirSync(join(dbPath, "..", "migrations"), { recursive: true }); - - const { sqlite: dbSqlite, db } = dbResult.value; - const runtime: SenseRuntime = { - name: "cpu-usage", - db, - compute: async () => ({ signal: { ts: 1000, value: 1.23 }, workflow: null }), - table: samples, - persistSignal: () => {}, - }; - - const result = await executeCompute(runtime); - expect(result.ok).toBe(true); - - const rows = dbSqlite.prepare("SELECT * FROM samples").all() as Array<{ - ts: number; - value: number; - }>; - expect(rows).toHaveLength(1); - expect(rows[0].ts).toBe(1000); - expect(rows[0].value).toBe(1.23); - dbSqlite.close(); + expect(result.error.message).toContain("boom"); }); it("returns err when compute exceeds timeoutMs", async () => { - const { runtime, sqlite } = makeRuntime( - () => new Promise((resolve) => setTimeout(() => resolve(null), 5_000)), + const runtime = makeRuntime( + async (s) => + new Promise((resolve) => setTimeout(() => resolve({ state: s, workflow: null }), 5_000)), + { n: 0 }, ); const result = await executeCompute(runtime, 50); expect(result.ok).toBe(false); if (result.ok) return; expect(result.error.message).toMatch(/timed out/i); - sqlite.close(); }); it("completes within timeout when compute is fast", async () => { - const { runtime, sqlite } = makeRuntime(async () => ({ - signal: { ts: 1, value: 42 }, - workflow: null, - })); + const runtime = makeRuntime(async (s) => ({ state: { n: s.n }, workflow: null }), { n: 42 }); const result = await executeCompute(runtime, 5_000); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value).toEqual({ signal: { ts: 1, value: 42 }, workflow: null }); - sqlite.close(); + expect(result.value.state).toEqual({ n: 42 }); }); }); -// --------------------------------------------------------------------------- -// parseParentMessage (IPC validation) -// --------------------------------------------------------------------------- - -describe("parseParentMessage", () => { +describe("parseParentMessage (IPC validation)", () => { it("accepts a valid compute message", () => { const result = parseParentMessage({ type: "compute", sense: "cpu" }); expect(result.ok).toBe(true); @@ -310,47 +147,3 @@ describe("parseParentMessage", () => { expect(result.error.message).toMatch(/unknown/i); }); }); - -// --------------------------------------------------------------------------- -// runMigrations – journal (idempotency) -// --------------------------------------------------------------------------- - -describe("runMigrations journal", () => { - it("does not re-run an already-applied migration", () => { - const sqlite = new DatabaseSync(":memory:"); - const dir = mkdtempSync(join(tmpdir(), "nerve-journal-")); - writeFileSync(join(dir, "0001_init.sql"), INIT_SQL); - - const first = runMigrations(sqlite, dir); - expect(first.ok).toBe(true); - - sqlite.exec("INSERT INTO samples (ts, value) VALUES (1, 1.0)"); - - const nonIdempotentSql = "CREATE TABLE samples2 (id INTEGER PRIMARY KEY)"; - writeFileSync(join(dir, "0002_samples2.sql"), nonIdempotentSql); - - const second = runMigrations(sqlite, dir); - expect(second.ok).toBe(true); - - const third = runMigrations(sqlite, dir); - expect(third.ok).toBe(true); - - sqlite.close(); - }); - - it("tracks migrations in _migrations table", () => { - const sqlite = new DatabaseSync(":memory:"); - const dir = mkdtempSync(join(tmpdir(), "nerve-journal2-")); - writeFileSync(join(dir, "0001_init.sql"), INIT_SQL); - - runMigrations(sqlite, dir); - - const rows = sqlite.prepare("SELECT name FROM _migrations ORDER BY name").all() as Array<{ - name: string; - }>; - expect(rows).toHaveLength(1); - expect(rows[0].name).toBe("0001_init.sql"); - - sqlite.close(); - }); -}); diff --git a/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts b/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts index 0a02152..35382a8 100644 --- a/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts +++ b/packages/daemon/src/__tests__/sense-scheduler-throttle-pending.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { NerveConfig, Signal } from "@uncaged/nerve-core"; +import type { NerveConfig } from "@uncaged/nerve-core"; import { createSenseScheduler } from "../sense-scheduler.js"; -import { createSignalBus } from "../signal-bus.js"; function makeConfig(overrides: Partial = {}): NerveConfig { return { @@ -12,7 +11,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -25,10 +23,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }; } -function makeSignal(senseId: string, payload: unknown = 1): Signal { - return { id: 1, senseId, payload, timestamp: Date.now() }; -} - describe("SenseScheduler — throttle + pending deferred trigger", () => { beforeEach(() => { vi.useFakeTimers(); @@ -47,27 +41,21 @@ describe("SenseScheduler — throttle + pending deferred trigger", () => { throttle: 2000, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: ["cpu-usage"], }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - // First trigger fires immediately (outside throttle window) - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); scheduler.onComputeComplete("cpu-usage"); expect(triggered.length).toBe(1); - // Second trigger arrives 500ms later — still within throttle window (2000ms) vi.advanceTimersByTime(500); - bus.emit(makeSignal("cpu-usage")); - // Should not fire yet (throttled) + scheduler.onSenseCompleted("cpu-usage"); expect(triggered.length).toBe(1); - // Advance past the throttle window end; deferred trigger should now fire vi.advanceTimersByTime(1600); expect(triggered.length).toBe(2); @@ -83,30 +71,25 @@ describe("SenseScheduler — throttle + pending deferred trigger", () => { throttle: 2000, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: ["cpu-usage"], }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - // First trigger fires - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); scheduler.onComputeComplete("cpu-usage"); expect(triggered.length).toBe(1); - // Multiple triggers within throttle window — should not stack vi.advanceTimersByTime(300); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); vi.advanceTimersByTime(300); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); vi.advanceTimersByTime(300); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); expect(triggered.length).toBe(1); - // Advance past window — exactly one deferred trigger fires vi.advanceTimersByTime(1200); scheduler.onComputeComplete("cpu-usage"); expect(triggered.length).toBe(2); @@ -123,28 +106,23 @@ describe("SenseScheduler — throttle + pending deferred trigger", () => { throttle: 2000, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: ["cpu-usage"], }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); scheduler.onComputeComplete("cpu-usage"); expect(triggered.length).toBe(1); - // Trigger during throttle window — schedules deferred vi.advanceTimersByTime(500); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); expect(triggered.length).toBe(1); - // Stop before window ends scheduler.stop(); - // Advance past window — deferred timer should have been cleared vi.advanceTimersByTime(2000); expect(triggered.length).toBe(1); }); diff --git a/packages/daemon/src/__tests__/sense-scheduler.test.ts b/packages/daemon/src/__tests__/sense-scheduler.test.ts index 9d9cf35..9041638 100644 --- a/packages/daemon/src/__tests__/sense-scheduler.test.ts +++ b/packages/daemon/src/__tests__/sense-scheduler.test.ts @@ -1,12 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { NerveConfig, Signal } from "@uncaged/nerve-core"; +import type { NerveConfig } from "@uncaged/nerve-core"; import { createSenseScheduler } from "../sense-scheduler.js"; -import { createSignalBus } from "../signal-bus.js"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- function makeConfig(overrides: Partial = {}): NerveConfig { return { @@ -16,7 +11,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -25,7 +19,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -34,7 +27,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { throttle: null, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: [], }, @@ -47,14 +39,6 @@ function makeConfig(overrides: Partial = {}): NerveConfig { }; } -function makeSignal(senseId: string, payload: unknown = 1): Signal { - return { id: 1, senseId, payload, timestamp: Date.now() }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("SenseScheduler — interval schedule", () => { beforeEach(() => { vi.useFakeTimers(); @@ -72,14 +56,11 @@ describe("SenseScheduler — interval schedule", () => { "cpu-usage": { ...base.senses["cpu-usage"], interval: 1000, on: [] }, }, }); - const bus = createSignalBus(); - // Use a ref so the triggerFn can call back into the scheduler const ref: { scheduler: ReturnType | null } = { scheduler: null, }; - const scheduler = createSenseScheduler(config, bus, (name) => { + const scheduler = createSenseScheduler(config, (name) => { triggered.push(name); - // Immediately complete compute so the scheduler is not blocked by in-flight state ref.scheduler?.onComputeComplete(name); }); ref.scheduler = scheduler; @@ -101,11 +82,10 @@ describe("SenseScheduler — interval schedule", () => { "cpu-usage": { ...base.senses["cpu-usage"], interval: 500, on: [] }, }, }); - const bus = createSignalBus(); const ref: { scheduler: ReturnType | null } = { scheduler: null, }; - const scheduler = createSenseScheduler(config, bus, (name) => { + const scheduler = createSenseScheduler(config, (name) => { triggered.push(name); ref.scheduler?.onComputeComplete(name); }); @@ -128,10 +108,8 @@ describe("SenseScheduler — interval schedule", () => { "cpu-usage": { ...base.senses["cpu-usage"], interval: 1000, on: [] }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - // Only advance 500ms — should be 0 triggers (not catching up) vi.advanceTimersByTime(500); expect(triggered.length).toBe(0); @@ -140,7 +118,7 @@ describe("SenseScheduler — interval schedule", () => { }); describe("SenseScheduler — event (on) schedule", () => { - it("triggers target sense when watched sense emits a signal", () => { + it("triggers target sense when watched sense completes", () => { const triggered: string[] = []; const base = makeConfig(); const config = makeConfig({ @@ -153,12 +131,11 @@ describe("SenseScheduler — event (on) schedule", () => { }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); scheduler.onComputeComplete("system-health"); - bus.emit(makeSignal("disk-usage")); + scheduler.onSenseCompleted("disk-usage"); scheduler.onComputeComplete("system-health"); expect(triggered.length).toBe(2); @@ -167,7 +144,7 @@ describe("SenseScheduler — event (on) schedule", () => { scheduler.stop(); }); - it("does not trigger for signals from non-watched senses", () => { + it("does not trigger for completions of non-watched senses", () => { const triggered: string[] = []; const base = makeConfig(); const config = makeConfig({ @@ -176,10 +153,9 @@ describe("SenseScheduler — event (on) schedule", () => { "system-health": { ...base.senses["system-health"], interval: null, on: ["cpu-usage"] }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - bus.emit(makeSignal("disk-usage")); + scheduler.onSenseCompleted("disk-usage"); expect(triggered.length).toBe(0); @@ -195,11 +171,10 @@ describe("SenseScheduler — event (on) schedule", () => { "system-health": { ...base.senses["system-health"], interval: null, on: ["cpu-usage"] }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); scheduler.stop(); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); expect(triggered.length).toBe(0); }); @@ -222,20 +197,17 @@ describe("SenseScheduler — throttle", () => { throttle: 2000, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: ["cpu-usage"], }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); scheduler.onComputeComplete("cpu-usage"); - // Immediately trigger again — within throttle window - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); expect(triggered.length).toBe(1); @@ -251,20 +223,18 @@ describe("SenseScheduler — throttle", () => { throttle: 1000, timeout: null, gracePeriod: null, - retention: 10_000, interval: null, on: ["cpu-usage"], }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); scheduler.onComputeComplete("cpu-usage"); vi.advanceTimersByTime(1500); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); scheduler.onComputeComplete("cpu-usage"); expect(triggered.length).toBe(2); @@ -283,24 +253,19 @@ describe("SenseScheduler — merge/coalesce", () => { "system-health": { ...base.senses["system-health"], interval: null, on: ["cpu-usage"] }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - // First trigger starts compute - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); expect(triggered.length).toBe(1); - // Three more arrive while first is in-flight — all should coalesce to one pending - bus.emit(makeSignal("cpu-usage")); - bus.emit(makeSignal("cpu-usage")); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); + scheduler.onSenseCompleted("cpu-usage"); + scheduler.onSenseCompleted("cpu-usage"); expect(triggered.length).toBe(1); - // Complete first compute → pending drains as exactly one more run scheduler.onComputeComplete("system-health"); expect(triggered.length).toBe(2); - // Complete second compute → no more pending scheduler.onComputeComplete("system-health"); expect(triggered.length).toBe(2); @@ -316,13 +281,11 @@ describe("SenseScheduler — merge/coalesce", () => { "system-health": { ...base.senses["system-health"], interval: null, on: ["cpu-usage"] }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); expect(triggered.length).toBe(1); - // Complete with no pending scheduler.onComputeComplete("system-health"); expect(triggered.length).toBe(1); @@ -351,14 +314,11 @@ describe("SenseScheduler — interval + on combined", () => { }, }, }); - const bus = createSignalBus(); - const scheduler = createSenseScheduler(config, bus, (name) => triggered.push(name)); + const scheduler = createSenseScheduler(config, (name) => triggered.push(name)); - // Event trigger - bus.emit(makeSignal("cpu-usage")); + scheduler.onSenseCompleted("cpu-usage"); scheduler.onComputeComplete("system-health"); - // Interval trigger vi.advanceTimersByTime(1000); scheduler.onComputeComplete("system-health"); diff --git a/packages/daemon/src/__tests__/signal-bus.test.ts b/packages/daemon/src/__tests__/signal-bus.test.ts deleted file mode 100644 index 149feec..0000000 --- a/packages/daemon/src/__tests__/signal-bus.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { Signal } from "@uncaged/nerve-core"; -import { createSignalBus } from "../signal-bus.js"; - -function makeSignal(senseId: string, payload: unknown = 1): Signal { - return { id: 1, senseId, payload, timestamp: Date.now() }; -} - -describe("createSignalBus", () => { - it("delivers emitted signal to a subscriber", () => { - const bus = createSignalBus(); - const received: Signal[] = []; - bus.subscribe((s) => received.push(s)); - - const sig = makeSignal("cpu-usage", 42); - bus.emit(sig); - - expect(received).toHaveLength(1); - expect(received[0]).toBe(sig); - }); - - it("delivers to multiple subscribers", () => { - const bus = createSignalBus(); - const a: Signal[] = []; - const b: Signal[] = []; - bus.subscribe((s) => a.push(s)); - bus.subscribe((s) => b.push(s)); - - bus.emit(makeSignal("cpu-usage")); - - expect(a).toHaveLength(1); - expect(b).toHaveLength(1); - }); - - it("unsubscribe stops delivery", () => { - const bus = createSignalBus(); - const received: Signal[] = []; - const unsub = bus.subscribe((s) => received.push(s)); - - bus.emit(makeSignal("cpu-usage")); - unsub(); - bus.emit(makeSignal("cpu-usage")); - - expect(received).toHaveLength(1); - }); - - it("remaining subscribers still receive after one unsubscribes", () => { - const bus = createSignalBus(); - const a: Signal[] = []; - const b: Signal[] = []; - const unsubA = bus.subscribe((s) => a.push(s)); - bus.subscribe((s) => b.push(s)); - - unsubA(); - bus.emit(makeSignal("cpu-usage")); - - expect(a).toHaveLength(0); - expect(b).toHaveLength(1); - }); - - it("emit with no subscribers does nothing", () => { - const bus = createSignalBus(); - expect(() => bus.emit(makeSignal("cpu-usage"))).not.toThrow(); - }); - - it("dispatch is synchronous", () => { - const bus = createSignalBus(); - const order: string[] = []; - bus.subscribe(() => order.push("handler")); - order.push("before"); - bus.emit(makeSignal("cpu-usage")); - order.push("after"); - expect(order).toEqual(["before", "handler", "after"]); - }); - - it("handler exceptions don't prevent other handlers from running", () => { - const bus = createSignalBus(); - const received: Signal[] = []; - bus.subscribe(() => { - throw new Error("boom"); - }); - bus.subscribe((s) => received.push(s)); - - expect(() => bus.emit(makeSignal("cpu-usage"))).toThrow("boom"); - expect(received).toHaveLength(1); - }); - - it("same handler can be subscribed once and fires once per emit", () => { - const bus = createSignalBus(); - const handler = vi.fn(); - bus.subscribe(handler); - - bus.emit(makeSignal("cpu-usage")); - bus.emit(makeSignal("cpu-usage")); - - expect(handler).toHaveBeenCalledTimes(2); - }); -}); diff --git a/packages/daemon/src/__tests__/worker-pool.test.ts b/packages/daemon/src/__tests__/worker-pool.test.ts index 3f1347e..04a50d4 100644 --- a/packages/daemon/src/__tests__/worker-pool.test.ts +++ b/packages/daemon/src/__tests__/worker-pool.test.ts @@ -80,8 +80,18 @@ describe("createSenseWorkerPool", () => { await startWorkerWithReady(pool, "g1"); expect(mockChildren).toHaveLength(1); const child = mockChildren[0]; - child.emit("message", { type: "signal", sense: "s", payload: 1 }); - expect(onWorkerMessage).toHaveBeenCalledWith({ type: "signal", sense: "s", payload: 1 }); + child.emit("message", { + type: "compute-result", + sense: "s", + state: 1, + workflow: null, + }); + expect(onWorkerMessage).toHaveBeenCalledWith({ + type: "compute-result", + sense: "s", + state: 1, + workflow: null, + }); }); it("sendCompute delivers to the worker for that group", async () => { diff --git a/packages/daemon/src/dashboard.html b/packages/daemon/src/dashboard.html index 35ba1ec..6132981 100644 --- a/packages/daemon/src/dashboard.html +++ b/packages/daemon/src/dashboard.html @@ -191,7 +191,6 @@ Name Type Triggers - Last signal @@ -343,7 +342,7 @@ if (!list.length) { var tr = document.createElement('tr'); var td = document.createElement('td'); - td.colSpan = 5; + td.colSpan = 4; td.style.color = 'var(--muted)'; td.textContent = 'No senses registered.'; tr.appendChild(td); @@ -353,12 +352,10 @@ list.forEach(function (s) { var tr = document.createElement('tr'); var triggers = (s.triggers && s.triggers.length) ? s.triggers.join('; ') : '—'; - var last = s.lastSignalTimestamp != null ? fmtTime(s.lastSignalTimestamp) : '—'; tr.innerHTML = '' + escapeHtml(s.name) + '' + '' + escapeHtml(String(s.group || '—')) + '' + '' + escapeHtml(triggers) + '' + - '' + escapeHtml(last) + '' + ''; var tdBtn = tr.querySelector('td:last-child'); var b = document.createElement('button'); diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index e418cfe..0d88a47 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -4,7 +4,7 @@ export type { HealthRequestMessage, HealthResponseMessage, ParentToWorkerMessage, - SignalMessage, + ComputeResultMessage, ErrorMessage, ReadyMessage, WorkerToParentMessage, @@ -15,16 +15,7 @@ export type { ThreadWorkflowMessageMessage, } from "./ipc.js"; -export type { SignalBus, SignalHandler, Unsubscribe } from "./signal-bus.js"; - -export type { DrizzleDB, SenseRuntime } from "./sense-runtime.js"; - -export { - runMigrations, - openSenseDb, - loadSenseModule, - executeCompute, -} from "./sense-runtime.js"; +export { loadSenseModule, executeCompute, readState, writeState } from "./sense-runtime.js"; export { createKernel } from "./kernel.js"; export type { Kernel, KernelOptions, KernelHealth } from "./kernel.js"; diff --git a/packages/daemon/src/ipc.ts b/packages/daemon/src/ipc.ts index bd6d38d..fc53d1e 100644 --- a/packages/daemon/src/ipc.ts +++ b/packages/daemon/src/ipc.ts @@ -4,7 +4,7 @@ */ import type { Result, WorkflowTrigger } from "@uncaged/nerve-core"; -import { err, isPlainRecord, ok } from "@uncaged/nerve-core"; +import { err, isPlainRecord, ok, parseWorkflowTrigger } from "@uncaged/nerve-core"; /** Parent → Worker: trigger one compute cycle for a sense */ export type ComputeMessage = { @@ -65,11 +65,12 @@ export type ParentToWorkerMessage = | ResumeThreadMessage | KillThreadMessage; -/** Worker → Parent: compute produced a signal */ -export type SignalMessage = { - type: "signal"; +/** Worker → Parent: sense compute finished (state persisted in worker; workflow optional). */ +export type ComputeResultMessage = { + type: "compute-result"; sense: string; - payload: unknown; + state: unknown; + workflow: WorkflowTrigger | null; }; /** Worker → Parent: sense compute result includes a workflow to start */ @@ -140,7 +141,7 @@ export type ThreadWorkflowMessageMessage = { /** Union of all messages a worker sends to the parent */ export type WorkerToParentMessage = - | SignalMessage + | ComputeResultMessage | ErrorMessage | ReadyMessage | HealthResponseMessage @@ -247,17 +248,33 @@ export function parseParentMessage(raw: unknown): Result } } -function parseSignalMsg(obj: Record): Result { +function parseComputeResultMsg(obj: Record): Result { if (typeof obj.sense !== "string") { - return err(new Error("Worker 'signal' message missing string 'sense' field")); + return err(new Error("Worker 'compute-result' message missing string 'sense' field")); } - if (!("payload" in obj)) { - return err(new Error("Worker 'signal' message missing 'payload' field")); + if (!("state" in obj)) { + return err(new Error("Worker 'compute-result' message missing 'state' field")); + } + if (!("workflow" in obj)) { + return err(new Error("Worker 'compute-result' message missing 'workflow' field")); + } + const wfRaw = obj.workflow; + if (wfRaw !== null && !isPlainRecord(wfRaw)) { + return err(new Error("Worker 'compute-result' workflow must be an object or null")); + } + let workflow: WorkflowTrigger | null; + if (wfRaw === null) { + workflow = null; + } else { + const parsed = parseWorkflowTrigger(wfRaw); + if (!parsed.ok) return err(parsed.error); + workflow = parsed.value; } return ok({ - type: "signal", + type: "compute-result", sense: obj.sense, - payload: obj.payload, + state: obj.state, + workflow, }); } @@ -341,7 +358,7 @@ function parseWorkflowErrorMsg(obj: Record): Result if (!WORKER_MSG_TYPES.has(obj.type)) { return err(new Error(`Unknown worker IPC message type: "${obj.type}"`)); } - if (obj.type === "signal") return parseSignalMsg(obj); + if (obj.type === "compute-result") return parseComputeResultMsg(obj); if (obj.type === "error") return parseErrorMsg(obj); if (obj.type === "health-response") return parseHealthResponseMsg(obj); if (obj.type === "thread-event") return parseThreadEventMsg(obj); diff --git a/packages/daemon/src/kernel.ts b/packages/daemon/src/kernel.ts index d48f2fd..148bfa0 100644 --- a/packages/daemon/src/kernel.ts +++ b/packages/daemon/src/kernel.ts @@ -1,5 +1,5 @@ /** - * Kernel — ties sense workers, signal bus, sense scheduler, workflow manager, + * Kernel — ties sense workers, sense scheduler, workflow manager, * optional file watcher, and daemon IPC. */ @@ -12,10 +12,9 @@ import { type HealthInfo, type NerveConfig, type SenseInfo, - type Signal, + type WorkflowTrigger, senseTriggerLabels, } from "@uncaged/nerve-core"; -import { routeSenseComputeOutput } from "@uncaged/nerve-core"; import { createLogStore } from "@uncaged/nerve-store"; import type { LogStore } from "@uncaged/nerve-store"; @@ -36,8 +35,6 @@ import { } from "./kernel-sense-groups.js"; import { createSenseScheduler } from "./sense-scheduler.js"; import type { SenseScheduler } from "./sense-scheduler.js"; -import { createSignalBus } from "./signal-bus.js"; -import type { SignalBus } from "./signal-bus.js"; import { createSenseWorkerPool, resolveWorkerScript } from "./worker-pool.js"; import { createWorkflowManager } from "./workflow-manager.js"; import type { WorkflowManager } from "./workflow-manager.js"; @@ -55,7 +52,6 @@ export type Kernel = { stop: () => Promise; groups: Set; senseCount: number; - bus: SignalBus; logStore: LogStore; workflowManager: WorkflowManager; ready: Promise; @@ -110,7 +106,6 @@ export function createKernel( nerveRoot: string, options: KernelOptions = defaultKernelOptions(), ): Kernel { - const bus: SignalBus = createSignalBus(); const workerScript = options.workerScript ?? resolveWorkerScript(); const startTime = Date.now(); const startedAtIso = new Date(startTime).toISOString(); @@ -127,12 +122,6 @@ export function createKernel( let config = initialConfig; - let _signalIdCounter = 0; - function nextSignalId(): number { - _signalIdCounter += 1; - return _signalIdCounter; - } - const workflowManager = createWorkflowManager(nerveRoot, config, logStore); const groups = new Set(); @@ -156,38 +145,14 @@ export function createKernel( } } - function handleSenseWorkerSignal(senseName: string, payload: unknown): void { - const routeResult = routeSenseComputeOutput(payload); - if (!routeResult.ok) { - process.stderr.write( - `[kernel] sense "${senseName}" invalid compute payload: ${routeResult.error.message}\n`, - ); - logStore.append({ - source: "sense", - type: "error", - refId: senseName, - payload: JSON.stringify({ error: routeResult.error.message }), - timestamp: Date.now(), - }); - scheduler.onComputeComplete(senseName); - return; - } - const { signal: signalPayload, workflow } = routeResult.value; - - const signal: Signal = { - id: nextSignalId(), - senseId: senseName, - payload: signalPayload, - timestamp: Date.now(), - }; + function handleComputeResult(senseName: string, workflow: WorkflowTrigger | null): void { logStore.append({ source: "sense", - type: "signal", + type: "compute-complete", refId: senseName, - payload: JSON.stringify(signalPayload), - timestamp: signal.timestamp, + payload: null, + timestamp: Date.now(), }); - bus.emit(signal); if (workflow !== null) { workflowManager.startWorkflow(workflow.name, { @@ -204,6 +169,7 @@ export function createKernel( }); } scheduler.onComputeComplete(senseName); + scheduler.onSenseCompleted(senseName); } function handleWorkerMessage(raw: unknown): void { @@ -235,8 +201,8 @@ export function createKernel( return; } - if (msg.type === "signal") { - handleSenseWorkerSignal(msg.sense, msg.payload); + if (msg.type === "compute-result") { + handleComputeResult(msg.sense, msg.workflow); } } @@ -270,7 +236,7 @@ export function createKernel( senseWorkerPool.sendCompute(group, senseName); } - scheduler = createSenseScheduler(config, bus, triggerFn, { + scheduler = createSenseScheduler(config, triggerFn, { logStore, }); @@ -306,7 +272,7 @@ export function createKernel( const oldWorkflows = config.workflows; config = newConfig; scheduler.stop(); - scheduler = createSenseScheduler(config, bus, triggerFn, { + scheduler = createSenseScheduler(config, triggerFn, { logStore, }); workflowManager.updateConfig(newConfig); @@ -388,22 +354,13 @@ export function createKernel( workflowManager, triggerSense, listSenses(): SenseInfo[] { - return Object.entries(config.senses).map(([name, senseConfig]) => { - const entries = logStore.query({ - source: "sense", - type: "signal", - refId: name, - }); - const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null; - return { - name, - group: senseConfig.group, - throttle: senseConfig.throttle, - timeout: senseConfig.timeout, - triggers: senseTriggerLabels(name, config.senses), - lastSignalTimestamp: lastEntry !== null ? lastEntry.timestamp : null, - }; - }); + return Object.entries(config.senses).map(([name, senseConfig]) => ({ + name, + group: senseConfig.group, + throttle: senseConfig.throttle, + timeout: senseConfig.timeout, + triggers: senseTriggerLabels(name, config.senses), + })); }, getHealthInfo: getDaemonHealth, getDefaultMaxRounds: () => config.maxRounds, @@ -468,7 +425,6 @@ export function createKernel( stop, groups, senseCount, - bus, logStore, workflowManager, ready, diff --git a/packages/daemon/src/sense-runtime.ts b/packages/daemon/src/sense-runtime.ts index 3977b57..6b38303 100644 --- a/packages/daemon/src/sense-runtime.ts +++ b/packages/daemon/src/sense-runtime.ts @@ -1,172 +1,38 @@ -import { mkdirSync, readFileSync, readdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; -import { drizzle } from "drizzle-orm/node-sqlite"; -import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"; -import type { SQLiteTable } from "drizzle-orm/sqlite-core"; - -import type { ComputeResult, Result, SenseComputeFn } from "@uncaged/nerve-core"; -import { DEFAULT_SENSE_SIGNAL_RETENTION, err, isPlainRecord, ok } from "@uncaged/nerve-core"; - -/** A Drizzle DB instance (schema-generic) */ -export type DrizzleDB = NodeSQLiteDatabase>; +import type { Result, SenseComputeFn, WorkflowTrigger } from "@uncaged/nerve-core"; +import { err, isPlainRecord, ok } from "@uncaged/nerve-core"; /** All state held for one sense inside a worker */ export type SenseRuntime = { name: string; - db: DrizzleDB; compute: SenseComputeFn; - table: SQLiteTable; - persistSignal: (payload: unknown) => void; + state: unknown; + statePath: string; }; -function ensureMigrationsTable(sqlite: DatabaseSync): Result { +export function readState(statePath: string, initialState: unknown): unknown { try { - sqlite.exec( - `CREATE TABLE IF NOT EXISTS _migrations ( - name TEXT PRIMARY KEY, - applied_at INTEGER NOT NULL - )`, - ); - return ok(undefined); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return err(new Error(`Failed to create _migrations table: ${msg}`)); + const raw = readFileSync(statePath, "utf8"); + return JSON.parse(raw); + } catch { + return initialState; } } -function listMigrationFiles(migrationsDir: string): Result { - try { - const files = readdirSync(migrationsDir) - .filter((f) => f.endsWith(".sql")) - .sort(); - return ok(files); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return err(new Error(`Failed to read migrations directory "${migrationsDir}": ${msg}`)); - } -} - -function applyMigrationFile(sqlite: DatabaseSync, file: string, filePath: string): Result { - let sql: string; - try { - sql = readFileSync(filePath, "utf8"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return err(new Error(`Failed to read migration file "${filePath}": ${msg}`)); - } - - const insertJournal = sqlite.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)"); - sqlite.exec("BEGIN IMMEDIATE"); - try { - sqlite.exec(sql); - insertJournal.run(file, Date.now()); - sqlite.exec("COMMIT"); - return ok(undefined); - } catch (e) { - try { - sqlite.exec("ROLLBACK"); - } catch { - // ignore secondary errors during rollback - } - const msg = e instanceof Error ? e.message : String(e); - return err(new Error(`Migration "${file}" failed: ${msg}`)); - } +export function writeState(statePath: string, state: unknown): void { + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync(statePath, JSON.stringify(state, null, 2)); } /** - * Run all *.sql migration files in the given directory against a - * `node:sqlite` DatabaseSync, in lexicographic order. - * Tracks applied migrations in _migrations table to avoid re-running. - */ -export function runMigrations(sqlite: DatabaseSync, migrationsDir: string): Result { - const tableResult = ensureMigrationsTable(sqlite); - if (!tableResult.ok) return tableResult; - - const filesResult = listMigrationFiles(migrationsDir); - if (!filesResult.ok) return filesResult; - - const migrationRows = sqlite.prepare("SELECT name FROM _migrations").all(); - const applied = new Set( - migrationRows - .filter((r): r is { name: string } => isPlainRecord(r) && typeof r.name === "string") - .map((r) => r.name), - ); - - for (const file of filesResult.value) { - if (applied.has(file)) continue; - const result = applyMigrationFile(sqlite, file, join(migrationsDir, file)); - if (!result.ok) return result; - } - - return ok(undefined); -} - -/** Run `_signals` row prune after this many inserts (amortize DELETE cost). */ -const SIGNAL_INSERTS_PER_PRUNE = 100; - -/** - * Open (or create) the SQLite file at `dbPath`, run all migrations in - * `migrationsDir`, and wrap with Drizzle ORM. - */ -export function openSenseDb( - dbPath: string, - migrationsDir: string, - retention: number = DEFAULT_SENSE_SIGNAL_RETENTION, -): Result<{ sqlite: DatabaseSync; db: DrizzleDB; persistSignal: (payload: unknown) => void }> { - let sqlite: DatabaseSync; - - try { - mkdirSync(dirname(dbPath), { recursive: true }); - sqlite = new DatabaseSync(dbPath); - sqlite.exec("PRAGMA journal_mode=WAL"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return err(new Error(`Failed to open database "${dbPath}": ${msg}`)); - } - - const migResult = runMigrations(sqlite, migrationsDir); - if (!migResult.ok) return migResult; - - // Auto-create _signals table for signal persistence (all senses get this) - sqlite.exec( - `CREATE TABLE IF NOT EXISTS _signals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - payload TEXT NOT NULL, - timestamp INTEGER NOT NULL - )`, - ); - - const insertStmt = sqlite.prepare("INSERT INTO _signals (payload, timestamp) VALUES (?, ?)"); - const pruneStmt = sqlite.prepare( - "DELETE FROM _signals WHERE id <= (SELECT id FROM _signals ORDER BY id DESC LIMIT 1 OFFSET ?)", - ); - - let insertsSincePrune = 0; - - function persistSignal(payload: unknown): void { - const json = JSON.stringify(payload); - insertStmt.run(json, Date.now()); - insertsSincePrune += 1; - if (insertsSincePrune >= SIGNAL_INSERTS_PER_PRUNE) { - insertsSincePrune = 0; - pruneStmt.run(retention); - } - } - - // Drizzle infers a schema-specific DB type; senses are schema-agnostic at this layer. - const db = drizzle({ client: sqlite }) as DrizzleDB; - return ok({ sqlite, db, persistSignal }); -} - -/** - * Dynamically import the compute function and table from a sense's index.ts/js. - * The module must export a named `compute` function and a named `table` (SQLiteTable). + * Dynamically import `compute` and `initialState` from a sense's index.ts/js. + * The module must export named `compute` and `initialState`. */ export async function loadSenseModule( senseIndexPath: string, -): Promise> { +): Promise> { let mod: unknown; try { @@ -183,33 +49,24 @@ export async function loadSenseModule( ); } - if (!("table" in mod) || mod.table === null || typeof mod.table !== "object") { - return err( - new Error( - `Sense module "${senseIndexPath}" must export a named "table" (drizzle SQLiteTable)`, - ), - ); + if (!("initialState" in mod)) { + return err(new Error(`Sense module "${senseIndexPath}" must export a named "initialState"`)); } return ok({ compute: mod.compute as SenseComputeFn, - table: mod.table as SQLiteTable, + initialState: mod.initialState, }); } /** - * Execute a sense's compute function with an optional soft timeout. - * If timeoutMs is provided and compute takes longer, the AbortSignal is - * triggered and an error Result is returned. - * When compute returns non-null, `result.signal` is persisted to the sense's - * table via `db.insert(table).values(result.signal)` and `persistSignal` is - * called with `result.signal`. Returns the full `ComputeResult` so callers - * can inspect the `workflow` field. + * Execute a sense's compute with current runtime state and an optional soft timeout. + * On success, persists `result.state` to `runtime.statePath` and updates `runtime.state`. */ export async function executeCompute( runtime: SenseRuntime, timeoutMs?: number, -): Promise>> { +): Promise> { const controller = new AbortController(); let timer: ReturnType | undefined; @@ -224,16 +81,13 @@ export async function executeCompute( : null; try { - const computePromise = runtime.compute(); + const computePromise = runtime.compute(runtime.state); const result = timeoutPromise ? await Promise.race([computePromise, timeoutPromise]) : await computePromise; - if (result !== null) { - // Cast required: DrizzleDB is schema-agnostic; the sense module guarantees shape compatibility. - await runtime.db.insert(runtime.table).values(result.signal as Record); - runtime.persistSignal(result.signal); - } + runtime.state = result.state; + writeState(runtime.statePath, result.state); return ok(result); } catch (e) { diff --git a/packages/daemon/src/sense-scheduler.ts b/packages/daemon/src/sense-scheduler.ts index 29f9ce7..4a3a036 100644 --- a/packages/daemon/src/sense-scheduler.ts +++ b/packages/daemon/src/sense-scheduler.ts @@ -3,7 +3,7 @@ * * Supports: * - interval: periodic setInterval-based triggering - * - on[]: event-driven triggering via the signal bus + * - on[]: event-driven triggering when watched senses complete a compute cycle * - throttle: skip triggers that arrive too soon after the last compute * - merge/coalesce: if compute is in-flight, record one pending trigger; * run it once after the current compute completes (no unbounded queue) @@ -11,7 +11,6 @@ import type { NerveConfig } from "@uncaged/nerve-core"; import type { LogStore } from "@uncaged/nerve-store"; -import type { SignalBus, Unsubscribe } from "./signal-bus.js"; /** Sends a compute message to the worker responsible for the given sense. */ export type TriggerFn = (senseName: string) => void; @@ -28,6 +27,8 @@ type SenseState = { export type SenseScheduler = { /** Notify scheduler that a compute cycle finished. Drains the pending flag. */ onComputeComplete: (senseName: string) => void; + /** Notify scheduler that a sense completed so `on[]` subscribers may run. */ + onSenseCompleted: (senseName: string) => void; stop: () => void; }; @@ -43,21 +44,29 @@ export type SenseSchedulerOptions = { * Create and start a sense scheduler. * * @param config Full NerveConfig (senses for schedule + throttle). - * @param bus SignalBus to subscribe for event-driven triggers. * @param triggerFn Called with the sense name when a compute should be dispatched. * @param opts Optional: logStore for structured logging. - * @returns SenseScheduler with stop() and onComputeComplete() methods. + * @returns SenseScheduler with stop(), onComputeComplete(), and onSenseCompleted(). */ export function createSenseScheduler( config: NerveConfig, - bus: SignalBus, triggerFn: TriggerFn, opts?: SenseSchedulerOptions, ): SenseScheduler { const logStore = opts?.logStore; const intervals: ReturnType[] = []; - const unsubscribers: Unsubscribe[] = []; const states = new Map(); + let stopped = false; + + /** sense name → senses that list it in `on[]` */ + const onSubscribers = new Map(); + for (const [senseName, sense] of Object.entries(config.senses)) { + for (const watched of sense.on) { + const list = onSubscribers.get(watched) ?? []; + list.push(senseName); + onSubscribers.set(watched, list); + } + } function getState(senseName: string): SenseState { let state = states.get(senseName); @@ -89,6 +98,7 @@ export function createSenseScheduler( * If within throttle window, schedules a single deferred trigger at window end (fix #3). */ function maybeTrigger(senseName: string): void { + if (stopped) return; const senseConfig = config.senses[senseName]; const throttleMs = senseConfig?.throttle ?? null; const state = getState(senseName); @@ -118,10 +128,11 @@ export function createSenseScheduler( } /** - * Called by the kernel when a compute cycle completes (signal or error received). + * Called by the kernel when a compute cycle completes (compute-result or error received). * Drains the pending flag if set. */ function onComputeComplete(senseName: string): void { + if (stopped) return; const state = states.get(senseName); if (state === undefined) return; @@ -152,8 +163,17 @@ export function createSenseScheduler( dispatchCompute(senseName); } + function onSenseCompleted(senseName: string): void { + if (stopped) return; + const subscribers = onSubscribers.get(senseName); + if (subscribers === undefined) return; + for (const sub of subscribers) { + maybeTrigger(sub); + } + } + for (const [senseName, sense] of Object.entries(config.senses)) { - const { interval, on } = sense; + const { interval } = sense; if (interval !== null) { const id = setInterval(() => { @@ -161,25 +181,13 @@ export function createSenseScheduler( }, interval); intervals.push(id); } - - if (on.length > 0) { - const watchedSenses = new Set(on); - const unsub = bus.subscribe((signal) => { - if (watchedSenses.has(signal.senseId)) { - maybeTrigger(senseName); - } - }); - unsubscribers.push(unsub); - } } function stop(): void { + stopped = true; for (const id of intervals) { clearInterval(id); } - for (const unsub of unsubscribers) { - unsub(); - } for (const state of states.values()) { if (state.deferredTimer !== null) { clearTimeout(state.deferredTimer); @@ -187,8 +195,7 @@ export function createSenseScheduler( } } intervals.length = 0; - unsubscribers.length = 0; } - return { onComputeComplete, stop }; + return { onComputeComplete, onSenseCompleted, stop }; } diff --git a/packages/daemon/src/sense-worker.ts b/packages/daemon/src/sense-worker.ts index 8457bc6..f22ffdf 100644 --- a/packages/daemon/src/sense-worker.ts +++ b/packages/daemon/src/sense-worker.ts @@ -8,8 +8,7 @@ * * Layout assumptions (nerve user config at `~/.uncaged-nerve/`): * dist/senses//index.js ← bundled compute (esbuild) - * senses//migrations/ ← SQL migration files - * data/senses/.db ← SQLite data file + * data/senses/.json ← persisted sense state * nerve.yaml ← config */ @@ -19,11 +18,11 @@ import { readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { parseNerveConfig } from "@uncaged/nerve-core"; -import type { NerveConfig } from "@uncaged/nerve-core"; +import type { NerveConfig, WorkflowTrigger } from "@uncaged/nerve-core"; import type { WorkerToParentMessage } from "./ipc.js"; import { parseParentMessage } from "./ipc.js"; -import { executeCompute, loadSenseModule, openSenseDb } from "./sense-runtime.js"; +import { executeCompute, loadSenseModule, readState } from "./sense-runtime.js"; import type { SenseRuntime } from "./sense-runtime.js"; import { ignoreSessionBroadcastSignals } from "./worker-signals.js"; @@ -41,8 +40,11 @@ function sendReady(): void { send({ type: "ready" }); } -function sendSignal(sense: string, payload: unknown): void { - send({ type: "signal", sense, payload }); +function sendComputeResult( + sense: string, + value: { state: unknown; workflow: WorkflowTrigger | null }, +): void { + send({ type: "compute-result", sense, state: value.state, workflow: value.workflow }); } function sendError(sense: string, error: string): void { @@ -72,31 +74,23 @@ function readConfig(nerveRoot: string): NerveConfig { return configResult.value; } -async function initSense( - nerveRoot: string, - senseName: string, - retention: number, -): Promise { - const dbPath = join(nerveRoot, "data", "senses", `${senseName}.db`); - const migrationsDir = join(nerveRoot, "senses", senseName, "migrations"); +async function initSense(nerveRoot: string, senseName: string): Promise { + const statePath = join(nerveRoot, "data", "senses", `${senseName}.json`); const senseIndexPath = resolve(join(nerveRoot, "dist", "senses", senseName, "index.js")); - const dbResult = openSenseDb(dbPath, migrationsDir, retention); - if (!dbResult.ok) { - throw new Error(`Failed to init DB for "${senseName}": ${dbResult.error.message}`); - } - const moduleResult = await loadSenseModule(senseIndexPath); if (!moduleResult.ok) { throw new Error(`Failed to load module for "${senseName}": ${moduleResult.error.message}`); } + const { compute, initialState } = moduleResult.value; + const state = readState(statePath, initialState); + return { name: senseName, - db: dbResult.value.db, - compute: moduleResult.value.compute, - table: moduleResult.value.table, - persistSignal: dbResult.value.persistSignal, + compute, + state, + statePath, }; } @@ -149,10 +143,7 @@ async function runCompute( return; } clearGracePeriodTimer(senseName); - if (result.value != null) { - // Single IPC message: kernel uses routeSenseComputeOutput(payload) for signal + optional workflow. - sendSignal(senseName, result.value); - } + sendComputeResult(senseName, result.value); } catch (e: unknown) { const errMsg = e instanceof Error ? e.message : String(e); sendError(senseName, errMsg); @@ -236,8 +227,7 @@ async function bootstrap(nerveRoot: string, group: string): Promise { for (const senseName of groupSenses) { try { - const retention = config.senses[senseName].retention; - const runtime = await initSense(nerveRoot, senseName, retention); + const runtime = await initSense(nerveRoot, senseName); runtimes.set(senseName, runtime); } catch (e: unknown) { const eMsg = e instanceof Error ? e.message : String(e); diff --git a/packages/daemon/src/signal-bus.ts b/packages/daemon/src/signal-bus.ts deleted file mode 100644 index c8499a3..0000000 --- a/packages/daemon/src/signal-bus.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * In-memory signal bus for routing signals between sense workers and sense-scheduler subscribers. - * Synchronous dispatch — no persistence, no async queuing. - * - * If a handler throws, the error is logged and remaining handlers still run. - * The first error is re-thrown after all handlers complete so callers can observe it. - */ - -import type { Signal } from "@uncaged/nerve-core"; - -export type SignalHandler = (signal: Signal) => void; -export type Unsubscribe = () => void; - -export type SignalBus = { - emit: (signal: Signal) => void; - subscribe: (handler: SignalHandler) => Unsubscribe; -}; - -export function createSignalBus(): SignalBus { - const handlers = new Set(); - - function emit(signal: Signal): void { - let firstError: unknown = null; - let hasError = false; - - for (const handler of handlers) { - try { - handler(signal); - } catch (e) { - console.error("[signal-bus] handler error:", e); - if (!hasError) { - firstError = e; - hasError = true; - } - } - } - - if (hasError) { - throw firstError; - } - } - - function subscribe(handler: SignalHandler): Unsubscribe { - handlers.add(handler); - return () => { - handlers.delete(handler); - }; - } - - return { emit, subscribe }; -} diff --git a/packages/daemon/src/worker-pool.ts b/packages/daemon/src/worker-pool.ts index 9769b1e..d93560f 100644 --- a/packages/daemon/src/worker-pool.ts +++ b/packages/daemon/src/worker-pool.ts @@ -21,7 +21,7 @@ export function resolveWorkerScript(): string { export type SenseWorkerPoolOptions = { nerveRoot: string; workerScript: string; - /** Invoked for every IPC message from a worker (including ready / signal / error). */ + /** Invoked for every IPC message from a worker (including ready / compute-result / error). */ onWorkerMessage: (raw: unknown) => void; /** Sense names in a group — reserved for scheduler-aligned cleanup (kernel passes current config). */ sensesForGroup: (group: string) => string[]; diff --git a/packages/workflow-meta/src/develop-sense/roles/planner.ts b/packages/workflow-meta/src/develop-sense/roles/planner.ts index ac3672b..bf75bac 100644 --- a/packages/workflow-meta/src/develop-sense/roles/planner.ts +++ b/packages/workflow-meta/src/develop-sense/roles/planner.ts @@ -17,6 +17,8 @@ Also look at existing senses in the \`senses/\` directory for patterns. Pick a good kebab-case name for this sense. Produce a PLAN (not code) in markdown: +**Naming:** Sense names describe **what is observed or derived** (often noun-style compounds, e.g. \`linux-system-health\`, \`git-workspace-status\`). That differs from **workflow** names, which must be **verb-first** action phrases (\`extract-knowledge\`, \`develop-sense\`). + ## Sense Design ### Name — kebab-case ### Fields — name, type (integer/real/text), description diff --git a/packages/workflow-utils/src/__tests__/create-llm-adapter.test.ts b/packages/workflow-utils/src/__tests__/create-llm-adapter.test.ts index 5504b1a..f0d3a90 100644 --- a/packages/workflow-utils/src/__tests__/create-llm-adapter.test.ts +++ b/packages/workflow-utils/src/__tests__/create-llm-adapter.test.ts @@ -51,4 +51,28 @@ describe("createLlmAdapter", () => { { role: "user", content: "trigger text" }, ]); }); + + it("throws on non-ok fetch response", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: async () => "Internal Server Error", + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; + const adapter = createLlmAdapter(provider); + + await expect(adapter(makeCtx("t1", "hi"), "sys")).rejects.toThrow("llm:"); + }); + + it("throws on fetch network failure", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + vi.stubGlobal("fetch", fetchMock); + + const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; + const adapter = createLlmAdapter(provider); + + await expect(adapter(makeCtx("t1", "hi"), "sys")).rejects.toThrow(); + }); }); diff --git a/packages/workflow-utils/src/shared/context.ts b/packages/workflow-utils/src/shared/context.ts index 6eabeb1..c08a719 100644 --- a/packages/workflow-utils/src/shared/context.ts +++ b/packages/workflow-utils/src/shared/context.ts @@ -41,7 +41,7 @@ export function readNerveYaml(options: ReadNerveYamlOptions): Result= 10'} - '@azure-rest/core-client@2.6.0': - resolution: {integrity: sha512-iuFKDm8XPzNxPfRjhyU5/xKZmcRDzSuEghXDHHk4MjBV/wFL34GmYVBZnn9wmuoLBeS1qAw9ceMdaeJBPcB1QQ==} - engines: {node: '>=20.0.0'} - - '@azure/abort-controller@2.1.2': - resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} - engines: {node: '>=18.0.0'} - - '@azure/core-auth@1.10.1': - resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} - engines: {node: '>=20.0.0'} - - '@azure/core-client@1.10.1': - resolution: {integrity: sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==} - engines: {node: '>=20.0.0'} - - '@azure/core-http-compat@2.4.0': - resolution: {integrity: sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@azure/core-client': ^1.10.0 - '@azure/core-rest-pipeline': ^1.22.0 - - '@azure/core-lro@2.7.2': - resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} - engines: {node: '>=18.0.0'} - - '@azure/core-paging@1.6.2': - resolution: {integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==} - engines: {node: '>=18.0.0'} - - '@azure/core-rest-pipeline@1.23.0': - resolution: {integrity: sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==} - engines: {node: '>=20.0.0'} - - '@azure/core-tracing@1.3.1': - resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==} - engines: {node: '>=20.0.0'} - - '@azure/core-util@1.13.1': - resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} - engines: {node: '>=20.0.0'} - - '@azure/identity@4.13.1': - resolution: {integrity: sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==} - engines: {node: '>=20.0.0'} - - '@azure/keyvault-common@2.1.0': - resolution: {integrity: sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==} - engines: {node: '>=20.0.0'} - - '@azure/keyvault-keys@4.10.0': - resolution: {integrity: sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag==} - engines: {node: '>=18.0.0'} - - '@azure/logger@1.3.0': - resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} - engines: {node: '>=20.0.0'} - - '@azure/msal-browser@5.8.0': - resolution: {integrity: sha512-X7IZV77bN56l7sbLjkcbQJX1t3U4tgxqztDr/XFbUcUfKk+z2FavcLgKP+OYUNj0wl/pEEtV9lldW9siY8BuHQ==} - engines: {node: '>=0.8.0'} - - '@azure/msal-common@16.5.1': - resolution: {integrity: sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==} - engines: {node: '>=0.8.0'} - - '@azure/msal-node@5.1.4': - resolution: {integrity: sha512-G4LXGGggok1QC48uKu64/SV2DPRDlddmV8EieK8pflsNYMj9/Zz+Y9OHoEBhT15h+zpdwXXLYA/7PJCR/yZ8aw==} - engines: {node: '>=20'} - '@biomejs/biome@1.9.4': resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} engines: {node: '>=14.21.3'} @@ -1052,9 +975,6 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@js-joda/core@5.7.0': - resolution: {integrity: sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg==} - '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1275,15 +1195,9 @@ packages: '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} - '@tediousjs/connection-string@0.5.0': - resolution: {integrity: sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==} - '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@types/better-sqlite3@7.6.13': - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1293,22 +1207,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/mssql@9.1.11': - resolution: {integrity: sha512-vcujgrDbDezCxNDO4KY6gjwduLYOKfrexpRUwhoysRvcXZ3+IgZ/PMYFDgh8c3cQIxZ6skAwYo+H6ibMrBWPjQ==} - '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - '@types/readable-stream@4.0.23': - resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==} - - '@typespec/ts-http-runtime@0.3.5': - resolution: {integrity: sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==} - engines: {node: '>=20.0.0'} - '@vitest/expect@4.1.5': resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} @@ -1338,66 +1242,23 @@ packages: '@vitest/utils@4.1.5': resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - better-sqlite3@11.10.0: - resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} - - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - bl@6.1.6: - resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==} - blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - - bundle-name@4.1.0: - resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} - engines: {node: '>=18'} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1409,164 +1270,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - - default-browser-id@5.0.1: - resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} - engines: {node: '>=18'} - - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} - engines: {node: '>=18'} - - define-lazy-prop@3.0.0: - resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} - engines: {node: '>=12'} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - drizzle-orm@1.0.0-beta.23-c10d10c: - resolution: {integrity: sha512-l7KNyUoBLlB3SiSg00VmjpvtDBIztjHXWj7+AkgbNGtsyZpLxdr6orecIUAx0GxDNYIpBp3nicMjpCks1uK69A==} - peerDependencies: - '@aws-sdk/client-rds-data': '>=3' - '@cloudflare/workers-types': '>=4' - '@effect/sql': ^0.48.5 - '@effect/sql-pg': ^0.49.7 - '@electric-sql/pglite': '>=0.2.0' - '@libsql/client': '>=0.10.0' - '@libsql/client-wasm': '>=0.10.0' - '@neondatabase/serverless': '>=0.10.0' - '@op-engineering/op-sqlite': '>=2' - '@opentelemetry/api': ^1.4.1 - '@planetscale/database': '>=1.13' - '@sinclair/typebox': '>=0.34.8' - '@sqlitecloud/drivers': '>=1.0.653' - '@tidbcloud/serverless': '*' - '@tursodatabase/database': '>=0.2.1' - '@tursodatabase/database-common': '>=0.2.1' - '@tursodatabase/database-wasm': '>=0.2.1' - '@types/better-sqlite3': '*' - '@types/mssql': ^9.1.4 - '@types/pg': '*' - '@types/sql.js': '*' - '@upstash/redis': '>=1.34.7' - '@vercel/postgres': '>=0.8.0' - '@xata.io/client': '*' - arktype: '>=2.0.0' - better-sqlite3: '>=9.3.0' - bun-types: '*' - expo-sqlite: '>=14.0.0' - gel: '>=2' - mssql: ^11.0.1 - mysql2: '>=2' - pg: '>=8' - postgres: '>=3' - sql.js: '>=1' - sqlite3: '>=5' - typebox: '>=1.0.0' - valibot: '>=1.0.0-beta.7' - zod: ^3.25.0 || ^4.0.0 - peerDependenciesMeta: - '@aws-sdk/client-rds-data': - optional: true - '@cloudflare/workers-types': - optional: true - '@effect/sql': - optional: true - '@effect/sql-pg': - optional: true - '@electric-sql/pglite': - optional: true - '@libsql/client': - optional: true - '@libsql/client-wasm': - optional: true - '@neondatabase/serverless': - optional: true - '@op-engineering/op-sqlite': - optional: true - '@opentelemetry/api': - optional: true - '@planetscale/database': - optional: true - '@sinclair/typebox': - optional: true - '@sqlitecloud/drivers': - optional: true - '@tidbcloud/serverless': - optional: true - '@tursodatabase/database': - optional: true - '@tursodatabase/database-common': - optional: true - '@tursodatabase/database-wasm': - optional: true - '@types/better-sqlite3': - optional: true - '@types/mssql': - optional: true - '@types/pg': - optional: true - '@types/sql.js': - optional: true - '@upstash/redis': - optional: true - '@vercel/postgres': - optional: true - '@xata.io/client': - optional: true - arktype: - optional: true - better-sqlite3: - optional: true - bun-types: - optional: true - expo-sqlite: - optional: true - gel: - optional: true - mssql: - optional: true - mysql2: - optional: true - pg: - optional: true - postgres: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - typebox: - optional: true - valibot: - optional: true - zod: - optional: true - - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -1586,18 +1293,6 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1611,85 +1306,24 @@ packages: picomatch: optional: true - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - hono@4.12.15: resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==} engines: {node: '>=16.9.0'} - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - - is-wsl@3.1.1: - resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} - engines: {node: '>=16'} - - js-md4@0.3.2: - resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} - jsonata@2.1.0: resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==} engines: {node: '>= 8'} - jsonwebtoken@9.0.3: - resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} - engines: {node: '>=12', npm: '>=6'} - - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -1771,34 +1405,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lodash.includes@4.3.0: - resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} - - lodash.isboolean@3.0.3: - resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} - - lodash.isinteger@4.0.4: - resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} - - lodash.isnumber@3.0.3: - resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.isstring@4.0.1: - resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} - - lodash.once@4.1.1: - resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - miniflare@4.20260421.0: resolution: {integrity: sha512-7ZkNQ7brgQ2hh5ha9iQCDUjxBkLvuiG2VdDns9esRL8O8lXg+MoP6E0dO1rtp+ZY2I+vV1tPWr6td5IojkewLw==} engines: {node: '>=18.0.0'} @@ -1809,45 +1418,14 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mssql@11.0.1: - resolution: {integrity: sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w==} - engines: {node: '>=18'} - hasBin: true - nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - - native-duplexpair@1.0.0: - resolution: {integrity: sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==} - - node-abi@3.89.0: - resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} - engines: {node: '>=10'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - open@10.2.0: - resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} - engines: {node: '>=18'} - path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -1865,34 +1443,6 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - - process@0.11.10: - resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} - engines: {node: '>= 0.6.0'} - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - readable-stream@4.7.0: - resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rolldown@1.0.0-rc.16: resolution: {integrity: sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1914,16 +1464,6 @@ packages: typescript: optional: true - run-applescript@7.1.0: - resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} - engines: {node: '>=18'} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -1936,58 +1476,20 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - - sql.js@1.14.1: - resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - - tarn@3.0.2: - resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} - engines: {node: '>=8.0.0'} - - tedious@18.6.2: - resolution: {integrity: sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg==} - engines: {node: '>=18'} - - tedious@19.2.1: - resolution: {integrity: sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA==} - engines: {node: '>=18.17'} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2006,9 +1508,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2031,14 +1530,6 @@ packages: unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - vite@8.0.9: resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2158,9 +1649,6 @@ packages: '@cloudflare/workers-types': optional: true - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -2173,10 +1661,6 @@ packages: utf-8-validate: optional: true - wsl-utils@0.1.0: - resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} - engines: {node: '>=18'} - yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -2235,167 +1719,6 @@ snapshots: '@ast-grep/napi-win32-ia32-msvc': 0.37.0 '@ast-grep/napi-win32-x64-msvc': 0.37.0 - '@azure-rest/core-client@2.6.0': - dependencies: - '@azure/abort-controller': 2.1.2 - '@azure/core-auth': 1.10.1 - '@azure/core-rest-pipeline': 1.23.0 - '@azure/core-tracing': 1.3.1 - '@typespec/ts-http-runtime': 0.3.5 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - - '@azure/abort-controller@2.1.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@azure/core-auth@1.10.1': - dependencies: - '@azure/abort-controller': 2.1.2 - '@azure/core-util': 1.13.1 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - - '@azure/core-client@1.10.1': - dependencies: - '@azure/abort-controller': 2.1.2 - '@azure/core-auth': 1.10.1 - '@azure/core-rest-pipeline': 1.23.0 - '@azure/core-tracing': 1.3.1 - '@azure/core-util': 1.13.1 - '@azure/logger': 1.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - - '@azure/core-http-compat@2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0)': - dependencies: - '@azure/abort-controller': 2.1.2 - '@azure/core-client': 1.10.1 - '@azure/core-rest-pipeline': 1.23.0 - optional: true - - '@azure/core-lro@2.7.2': - dependencies: - '@azure/abort-controller': 2.1.2 - '@azure/core-util': 1.13.1 - '@azure/logger': 1.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - - '@azure/core-paging@1.6.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@azure/core-rest-pipeline@1.23.0': - dependencies: - '@azure/abort-controller': 2.1.2 - '@azure/core-auth': 1.10.1 - '@azure/core-tracing': 1.3.1 - '@azure/core-util': 1.13.1 - '@azure/logger': 1.3.0 - '@typespec/ts-http-runtime': 0.3.5 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - - '@azure/core-tracing@1.3.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@azure/core-util@1.13.1': - dependencies: - '@azure/abort-controller': 2.1.2 - '@typespec/ts-http-runtime': 0.3.5 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - - '@azure/identity@4.13.1': - dependencies: - '@azure/abort-controller': 2.1.2 - '@azure/core-auth': 1.10.1 - '@azure/core-client': 1.10.1 - '@azure/core-rest-pipeline': 1.23.0 - '@azure/core-tracing': 1.3.1 - '@azure/core-util': 1.13.1 - '@azure/logger': 1.3.0 - '@azure/msal-browser': 5.8.0 - '@azure/msal-node': 5.1.4 - open: 10.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - - '@azure/keyvault-common@2.1.0': - dependencies: - '@azure-rest/core-client': 2.6.0 - '@azure/abort-controller': 2.1.2 - '@azure/core-auth': 1.10.1 - '@azure/core-rest-pipeline': 1.23.0 - '@azure/core-tracing': 1.3.1 - '@azure/core-util': 1.13.1 - '@azure/logger': 1.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - - '@azure/keyvault-keys@4.10.0(@azure/core-client@1.10.1)': - dependencies: - '@azure-rest/core-client': 2.6.0 - '@azure/abort-controller': 2.1.2 - '@azure/core-auth': 1.10.1 - '@azure/core-http-compat': 2.4.0(@azure/core-client@1.10.1)(@azure/core-rest-pipeline@1.23.0) - '@azure/core-lro': 2.7.2 - '@azure/core-paging': 1.6.2 - '@azure/core-rest-pipeline': 1.23.0 - '@azure/core-tracing': 1.3.1 - '@azure/core-util': 1.13.1 - '@azure/keyvault-common': 2.1.0 - '@azure/logger': 1.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@azure/core-client' - - supports-color - optional: true - - '@azure/logger@1.3.0': - dependencies: - '@typespec/ts-http-runtime': 0.3.5 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - - '@azure/msal-browser@5.8.0': - dependencies: - '@azure/msal-common': 16.5.1 - optional: true - - '@azure/msal-common@16.5.1': - optional: true - - '@azure/msal-node@5.1.4': - dependencies: - '@azure/msal-common': 16.5.1 - jsonwebtoken: 9.0.3 - uuid: 8.3.2 - optional: true - '@biomejs/biome@1.9.4': optionalDependencies: '@biomejs/cli-darwin-arm64': 1.9.4 @@ -2784,9 +2107,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@js-joda/core@5.7.0': - optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2947,19 +2267,11 @@ snapshots: dependencies: tslib: 2.8.1 - '@tediousjs/connection-string@0.5.0': - optional: true - '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true - '@types/better-sqlite3@7.6.13': - dependencies: - '@types/node': 22.19.17 - optional: true - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2969,16 +2281,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/mssql@9.1.11(@azure/core-client@1.10.1)': - dependencies: - '@types/node': 22.19.17 - tarn: 3.0.2 - tedious: 19.2.1(@azure/core-client@1.10.1) - transitivePeerDependencies: - - '@azure/core-client' - - supports-color - optional: true - '@types/node@22.19.17': dependencies: undici-types: 6.21.0 @@ -2988,20 +2290,6 @@ snapshots: undici-types: 7.19.2 optional: true - '@types/readable-stream@4.0.23': - dependencies: - '@types/node': 22.19.17 - optional: true - - '@typespec/ts-http-runtime@0.3.5': - dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - optional: true - '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 @@ -3051,134 +2339,26 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - optional: true - - agent-base@7.1.4: - optional: true - assertion-error@2.0.1: {} - base64-js@1.5.1: - optional: true - - better-sqlite3@11.10.0: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - optional: true - - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - optional: true - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - optional: true - - bl@6.1.6: - dependencies: - '@types/readable-stream': 4.0.23 - buffer: 6.0.3 - inherits: 2.0.4 - readable-stream: 4.7.0 - optional: true - blake3-wasm@2.1.5: {} - buffer-equal-constant-time@1.0.1: - optional: true - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - optional: true - - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - optional: true - - bundle-name@4.1.0: - dependencies: - run-applescript: 7.1.0 - optional: true - chai@6.2.2: {} - chownr@1.1.4: - optional: true - citty@0.1.6: dependencies: consola: 3.4.2 cjs-module-lexer@1.4.3: {} - commander@11.1.0: - optional: true - consola@3.4.2: {} convert-source-map@2.0.0: {} cookie@1.1.1: {} - debug@4.4.3: - dependencies: - ms: 2.1.3 - optional: true - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - optional: true - - deep-extend@0.6.0: - optional: true - - default-browser-id@5.0.1: - optional: true - - default-browser@5.5.0: - dependencies: - bundle-name: 4.1.0 - default-browser-id: 5.0.1 - optional: true - - define-lazy-prop@3.0.0: - optional: true - detect-libc@2.1.2: {} - drizzle-orm@1.0.0-beta.23-c10d10c(@cloudflare/workers-types@4.20260425.1)(@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: - '@cloudflare/workers-types': 4.20260425.1 - '@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: - safe-buffer: 5.2.1 - optional: true - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - optional: true - error-stack-parser-es@1.0.5: {} es-module-lexer@2.0.0: {} @@ -3246,117 +2426,21 @@ snapshots: dependencies: '@types/estree': 1.0.8 - event-target-shim@5.0.1: - optional: true - - events@3.3.0: - optional: true - - expand-template@2.0.3: - optional: true - expect-type@1.3.0: {} fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 - file-uri-to-path@1.0.0: - optional: true - - fs-constants@1.0.0: - optional: true - fsevents@2.3.3: optional: true - github-from-package@0.0.0: - optional: true - hono@4.12.15: {} - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - optional: true - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - optional: true - husky@9.1.7: {} - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - optional: true - - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - optional: true - - ieee754@1.2.1: - optional: true - - inherits@2.0.4: - optional: true - - ini@1.3.8: - optional: true - - is-docker@3.0.0: - optional: true - - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - optional: true - - is-wsl@3.1.1: - dependencies: - is-inside-container: 1.0.0 - optional: true - - js-md4@0.3.2: - optional: true - jsonata@2.1.0: {} - jsonwebtoken@9.0.3: - dependencies: - jws: 4.0.1 - lodash.includes: 4.3.0 - lodash.isboolean: 3.0.3 - lodash.isinteger: 4.0.4 - lodash.isnumber: 3.0.3 - lodash.isplainobject: 4.0.6 - lodash.isstring: 4.0.1 - lodash.once: 4.1.1 - ms: 2.1.3 - semver: 7.7.4 - optional: true - - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - optional: true - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 - optional: true - kleur@4.1.5: {} layerr@3.0.0: {} @@ -3410,34 +2494,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lodash.includes@4.3.0: - optional: true - - lodash.isboolean@3.0.3: - optional: true - - lodash.isinteger@4.0.4: - optional: true - - lodash.isnumber@3.0.3: - optional: true - - lodash.isplainobject@4.0.6: - optional: true - - lodash.isstring@4.0.1: - optional: true - - lodash.once@4.1.1: - optional: true - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - mimic-response@3.1.0: - optional: true - miniflare@4.20260421.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -3462,56 +2522,10 @@ snapshots: - bufferutil - utf-8-validate - minimist@1.2.8: - optional: true - - mkdirp-classic@0.5.3: - optional: true - - ms@2.1.3: - optional: true - - mssql@11.0.1(@azure/core-client@1.10.1): - dependencies: - '@tediousjs/connection-string': 0.5.0 - commander: 11.1.0 - debug: 4.4.3 - rfdc: 1.4.1 - tarn: 3.0.2 - tedious: 18.6.2(@azure/core-client@1.10.1) - transitivePeerDependencies: - - '@azure/core-client' - - supports-color - optional: true - nanoid@3.3.11: {} - napi-build-utils@2.0.0: - optional: true - - native-duplexpair@1.0.0: - optional: true - - node-abi@3.89.0: - dependencies: - semver: 7.7.4 - optional: true - obug@2.1.1: {} - once@1.4.0: - dependencies: - wrappy: 1.0.2 - optional: true - - open@10.2.0: - dependencies: - default-browser: 5.5.0 - define-lazy-prop: 3.0.0 - is-inside-container: 1.0.0 - wsl-utils: 0.1.0 - optional: true - path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -3526,58 +2540,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.89.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - optional: true - - process@0.11.10: - optional: true - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - optional: true - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - optional: true - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - optional: true - - readable-stream@4.7.0: - dependencies: - abort-controller: 3.0.0 - buffer: 6.0.3 - events: 3.3.0 - process: 0.11.10 - string_decoder: 1.3.0 - optional: true - - rfdc@1.4.1: - optional: true - rolldown@1.0.0-rc.16: dependencies: '@oxc-project/types': 0.126.0 @@ -3606,15 +2568,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - run-applescript@7.1.0: - optional: true - - safe-buffer@5.2.1: - optional: true - - safer-buffer@2.1.2: - optional: true - semver@7.7.4: {} sharp@0.34.5: @@ -3650,92 +2603,14 @@ snapshots: siginfo@2.0.0: {} - simple-concat@1.0.1: - optional: true - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - optional: true - source-map-js@1.2.1: {} - sprintf-js@1.1.3: - optional: true - - sql.js@1.14.1: - optional: true - stackback@0.0.2: {} std-env@4.1.0: {} - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - optional: true - - strip-json-comments@2.0.1: - optional: true - supports-color@10.2.2: {} - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.4 - tar-stream: 2.2.0 - optional: true - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - optional: true - - tarn@3.0.2: - optional: true - - tedious@18.6.2(@azure/core-client@1.10.1): - dependencies: - '@azure/core-auth': 1.10.1 - '@azure/identity': 4.13.1 - '@azure/keyvault-keys': 4.10.0(@azure/core-client@1.10.1) - '@js-joda/core': 5.7.0 - '@types/node': 22.19.17 - bl: 6.1.6 - iconv-lite: 0.6.3 - js-md4: 0.3.2 - native-duplexpair: 1.0.0 - sprintf-js: 1.1.3 - transitivePeerDependencies: - - '@azure/core-client' - - supports-color - optional: true - - tedious@19.2.1(@azure/core-client@1.10.1): - dependencies: - '@azure/core-auth': 1.10.1 - '@azure/identity': 4.13.1 - '@azure/keyvault-keys': 4.10.0(@azure/core-client@1.10.1) - '@js-joda/core': 5.7.0 - '@types/node': 22.19.17 - bl: 6.1.6 - iconv-lite: 0.7.2 - js-md4: 0.3.2 - native-duplexpair: 1.0.0 - sprintf-js: 1.1.3 - transitivePeerDependencies: - - '@azure/core-client' - - supports-color - optional: true - tinybench@2.9.0: {} tinyexec@1.1.1: {} @@ -3749,11 +2624,6 @@ snapshots: tslib@2.8.1: {} - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - optional: true - typescript@5.9.3: {} ulidx@2.4.1: @@ -3771,12 +2641,6 @@ snapshots: dependencies: pathe: 2.0.3 - util-deprecate@1.0.2: - optional: true - - uuid@8.3.2: - optional: true - vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -3912,16 +2776,8 @@ snapshots: - bufferutil - utf-8-validate - wrappy@1.0.2: - optional: true - ws@8.18.0: {} - wsl-utils@0.1.0: - dependencies: - is-wsl: 3.1.1 - optional: true - yaml@2.8.3: {} youch-core@0.3.3: