This repository has been archived on 2026-06-01. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
nerve/docs/rfc-001-observation-engine.md
xiaoju b269f76b33 refactor(daemon): rename reflex-scheduler → sense-scheduler
Rename ReflexScheduler to SenseScheduler, update all file names,
imports, comments, test descriptions, and log source values.

Fixes #202
2026-04-27 12:07:22 +00:00

29 KiB

RFC-001: Nerve — Observation Engine

Author: 主人 & 小橘 🍊(NEKO Team) Date: 2026-04-21 Status: Draft Repo: @uncaged/nerve


1. 动机

现有的 agent 架构中,事件处理、状态投影、工作流触发、角色调度等概念交织在一起,边界模糊。我们需要一组正交的、最小化的抽象,使得 agent 的能力可以声明式地组合和扩展。

2. 核心洞察

2.1 两类事件

系统中流通的事件分为两类:

  • Signal:Sense compute 返回非 null 值时发出的通知。纯事实,无意图,不期待响应。例:CPU 占用 95%。compute 返回 null 时不产生 Signal。
  • Command Event:Workflow Thread 内部的流转事件。有明确因果链,必须被响应。例:reviewer 返回 rejected。 两者不在同一层级。Signal 驱动系统的前半部(感知),Command Event 驱动后半部(行动)。

2.2 Projection 不是独立概念

传统 Event Sourcing 中 Projection 是一等公民。在本设计中,Projection 只是"一个 compute 从存储中拉数据的 Sense"。它的输出和原始采样一样,都是 Signal。不需要单独的机制。

2.3 触发条件与计算逻辑解耦

一个 Sense 怎么算(compute)和什么时候算(reflex)是两个独立的关注点。同一个 Sense 可以被定时触发、被事件触发、被按需查询触发。

2.4 Log 是数据资产,不是触发源

系统运行过程中产生的各种记录(reflex 执行记录、workflow 状态变迁、错误日志等)统称为 Log。Log 是有价值的数据资产,用于审计、溯源、分析。

Log 与 Signal 的本质区别:

  • Signal — 来自外部世界的感知数据,可以触发 Reflex
  • Log — 系统内部产出的副作用记录,不能触发 Reflex

因果链是单向的:

外部世界 → Sense → Signal → Reflex → Action + Log
                                          ↑
                                    Reflex 可以读 Log(查询/聚合)
                                    Log 不能触发 Reflex ✗

禁止 Log 触发 Reflex 是防止雪崩的关键约束——如果 reflex 执行产生的 log 又能触发 reflex,系统会形成无限循环。Log 是因果链的终点,不是起点。

3. 术语表

术语 隐喻 含义
Sense 感官 定义怎么感知——compute 函数,每种 Sense 有自己的数据类型和独立存储
Reflex 反射 定义什么时候感知——声明式触发条件
Signal 信号 Sense compute 返回非 null 时发出的通知,其他 Reflex 可以监听
Log 日志 系统内部产出的记录(执行记录、状态变迁、错误等),数据资产,不触发 Reflex
Workflow 行动 定义怎么做——内含 Moderator(调度)和 Role(执行)
Moderator 协调者 Workflow 内部概念,在 Role 之间递话筒
Role 执行者 Workflow 内部概念,执行具体动作,有副作用
Thread 工作流实例 一个 Workflow 的一次执行上下文

4. 三个扩展点

整个系统只有三个用户可扩展的概念:

扩展点 回答的问题 性质
Sense 是什么(怎么算) compute 函数
Reflex 什么时候算 声明式 YAML
Workflow 怎么做 Role + Moderator

三者职责完全正交。Reflex 不知道 compute 的内容,Sense 不知道自己什么时候被触发,Workflow 不知道自己为什么被发起。

4.1 Sense

Sense 是系统中唯一的一等公民。一个 Sense 定义一个 compute 函数。

Sense 是多态的——每种 Sense 有自己的 Payload 类型。

所有 Sense 的 compute 返回 T | null。返回值时发 signal,返回 null 时静默——不写存储、不发 signal、不触发下游 reflex。

// 原始采样:读物理世界,每次都有值
// senses/cpu-usage.ts
export async function compute(): Promise<number | null> {
  return os.loadavg()[0]  // 实际上总是有值
}

// 派生计算(即"Projection"):读其他 Sense 的存储
// senses/active-tasks.ts
export async function compute(): Promise<Task[] | null> {
  const prev = myDb.prepare('SELECT * FROM tasks').all()
  const newEvents = taskEventsDb.prepare(
    'SELECT * FROM events WHERE ts > ?'
  ).all(lastSync)
  const result = applyChanges(prev, newEvents)
  return hasChanges(result, prev) ? result : null  // 无变化则静默
}

关键设计:每个 Sense 拥有自己独立的存储。 没有统一的 Event Store。每种 Sense 按自己的数据结构定义自己的表、自己的库文件。Sense 之间需要查询时,以只读方式打开对方的库。

这是合理的,因为 Signal 本质是 append-only 的时序数据,不会跨 Sense join。每个 Sense 最了解自己的查询模式。

Schema 管理:Drizzle 作为标准工具链

每个 Sense 用 Drizzle ORM 定义 schema,schema.ts 是 single source of truth

// senses/cpu-usage/schema.ts
import { sqliteTable, integer, real } from 'drizzle-orm/sqlite-core'

export const samples = sqliteTable('samples', {
  ts: integer('ts').primaryKey(),
  value: real('value').notNull(),
})

Migration 由 drizzle-kit generate 自动生成,提交进 git:

senses/
  cpu-usage/
    schema.ts              ← 开发者(agent)写这个
    index.ts               ← compute,查询有类型推导
    migrations/            ← drizzle-kit 自动生成
      0001_init.sql

compute 拿到的 db 实例是 Drizzle 包装过的,查询全部 type-safe:

// senses/cpu-usage/index.ts
import { samples } from './schema'

// db 和 peers 由 engine runtime 注入,不需要用户创建
export async function compute(
  db: DrizzleDB,
  peers: Readonly<Record<string, DrizzleDB>>
): Promise<number | null> {
  const load = os.loadavg()[0]
  await db.insert(samples).values({ ts: Date.now(), value: load })
  return load
}

Cross-sense 类型安全读取: compute 通过 peers 参数拿到其他 Sense 的只读 db 实例。类型来自 import 对方的 schema:

// senses/active-tasks/index.ts
import { tasks } from './schema'
import { samples } from '../cpu-usage/schema'  // 只导入类型定义

export async function compute(
  db: DrizzleDB,
  peers: Readonly<Record<string, DrizzleDB>>
): Promise<TaskSummary | null> {
  // 自己的 db:读写
  const activeTasks = await db.select().from(tasks).where(...)
  // peer 的 db:只读,类型安全
  const cpuLoad = await peers['cpu-usage'].select().from(samples).orderBy(desc(samples.ts)).limit(1)
  return { activeTasks, cpuLoad }
}

为什么用 Drizzle 而不是手写 SQL migration:

Nerve 的 Sense 开发者是机器上的 Coding Agent(通过 Nerve 自身的 Workflow 自举开发)。Agent 行为的正确性应靠确定性工具保证,不应依赖概率模型的"自律"。Drizzle 让 agent 只写一个东西(schema.ts),migration 和类型都是机械派生,消除了 TS 与 SQL 不一致的风险。

引擎职责: 运行时只执行 migration SQL(drizzle migrate),不依赖 drizzle-kit。生成 migration 是开发时(workflow role action)的事。

Migration rollback: Drizzle 不支持 down migration,但 Sense 的 SQLite 是派生数据——坏了删 .db 文件,engine 重启时自动重跑 migration 重建。不需要 rollback 机制。

drizzle.config: 不需要每个 Sense 各一份。Engine runtime 统一参数化——根据 sense name 确定 .db 路径和 migrations/ 目录,调用 drizzle-kit generate 时动态传入。Sense 开发者只写 schema.ts

Schema 新鲜度: Engine 启动时不校验 schema.tsmigrations/ 是否同步。这是开发时的职责——创建/修改 Sense 的 Workflow 负责跑 drizzle-kit generate 并提交 migration。运行时只管执行已有的 migration SQL。

Sense 不知道 Workflow

Sense 只感知世界并产出 Signal,永远不关心"谁在听"或"听了之后要做什么"。Sense 不引用 Workflow、不返回 ThreadStart、不知道自己的 Signal 会触发什么动作。

// senses/disk-usage.ts — 只关心磁盘状态
export async function compute(): Promise<DiskUsage | null> {
  const usage = await getDiskUsage()
  return usage  // 纯事实,不关心下游
}

启动 Workflow 是 Reflex 的职责,见 §4.2。

4.2 Reflex

Reflex 是纯声明式的 YAML 配置,定义引擎对 Signal 的反应。Reflex 是连接 Sense 与 Sense、Sense 与 Workflow 的唯一纽带。

Reflex 有两种 action:

action 含义
触发 compute 让某个 Sense 重新计算(默认行为)
启动 workflow 创建一个 Thread(Post-MVP)
# nerve.yaml
senses:
  cpu-usage:
    group: system
    throttle: 5s
    timeout: 3s
  disk-usage:
    group: system
    throttle: 30s
  active-tasks:
    group: tasks
    throttle: 10s
    timeout: 30s

reflexes:
  # Sense → Sense(触发 compute,默认 action)
  - sense: cpu-usage
    interval: 10s

  - sense: disk-usage
    interval: 5m

  - sense: active-tasks
    on: ["task.created", "task.completed"]
    interval: 10m

  # Sense → Workflow(启动 thread)
  - workflow: cleanup
    on: ["disk-usage"]

两种触发条件:

条件 含义
interval 定时触发
on: [signals] 当指定的 Sense 发出新 Signal 时触发

一个 Reflex 可以有多个触发条件(如 active-tasks 同时有事件触发和定时兜底)。

OnDemand(按需触发)不需要声明——引擎内置提供,任何 Sense 都可以被外部 API 调用触发。

Reflex 是 Event Mesh

所有的 Reflex 声明合在一起,构成了一个声明式的事件路由网格(Event Mesh)。引擎启动时解析所有 Reflex,构建出完整的事件流拓扑:

Signal Bus ──→ Reflex Mesh ──→ Sense compute
                             ──→ Workflow thread

每个 Signal 进入 bus 后,Reflex Mesh 决定它的去向——触发哪些 Sense 重算、启动哪些 Workflow。这不是 Sense 主动"发消息"给下游,而是引擎根据声明式规则自动路由。

进程视角: Sense Worker 只负责执行 compute 并把 Signal 交给 Kernel。Kernel 里的 Reflex Mesh 决定下一步——该触发谁的 compute、该起哪个 Workflow Worker。Worker 之间永远不直连,所有路由决策都在 Kernel 完成。

Reflex 语义规则

Interval 起点:以库中记录的上次 compute 完成时间为准,不是 daemon 启动时间。daemon 重启时,若已过期则立即执行一次,然后恢复正常节奏。不会对停机期间的缺失进行逐次补偿。

Event 补偿:OnEvent 的语义是"有新数据了,该重新算一下",不是"每个 event 都要处理一次"。daemon 重启时最多触发一次 compute,compute 内部通过 pullSince(lastTimestamp) 拉取所有积压数据。

合并与幂等:同一个 Sense 同一时刻最多一个 compute 在执行。多个触发条件同时满足时合并为一次调用。compute 执行期间收到新 trigger,标记为 pending,当前完成后再执行一次。

┌─ OnInterval 到了 ──┐
├─ OnEvent 来了 ─────┤──→ 合并 → 一次 compute
└─ OnEvent 又来了 ──┘

compute 执行中 → 新 trigger 到达 → pending → 当前完成后再执行一次

这些规则与 Sense 的语义一致——compute 是"重新感知当前状态",不是"处理某个具体事件"。无论触发几次,做的是同一个动作。

4.3 Workflow(Post-MVP)

Workflow 定义一个有状态的工作流执行上下文(Thread)。内部包含:

  • Role:执行具体动作的角色,有副作用
  • Moderator:在 Role 之间递话筒的调度逻辑

Role 和 Moderator 是 Workflow 的内部细节,不跨 Workflow 共享,不作为顶层扩展点。

Workflow 配置

# nerve.yaml
workflows:
  cleanup:
    concurrency: 1          # 同时最多 1 个 thread
    overflow: drop           # 已有活跃 thread 时丢弃新请求
  execute-task:
    concurrency: 10          # 可并行 10 个 thread
    overflow: queue           # 超出时排队
  code-review:
    concurrency: 3
    overflow: queue
    max_queue: 20               # 队列上限,超出丢弃最旧请求
  • concurrency:同时允许的最大活跃 Thread 数
  • overflow:达到上限时的策略
    • drop:丢弃,适用于幂等操作(如 cleanup)
    • queue:排队等待,适用于每次都需要执行的操作(如 deploy)
  • max_queue(仅 overflow: queue 时生效):队列上限,默认 100。超出时丢弃最旧的请求

不需要 throttle——Workflow 的触发频率由上游 Sense 的 throttle 控制,Workflow 层只管并发。

Signal 系统与 Thread 的关系

Signal 和 Thread 是两个独立的循环,单向桥接:

Signal 循环 ──→ ThreadStart ──→ Thread 循环
(无状态,幂等,可合并)       (有状态,顺序,Command Event 驱动)
                                    │
                               Thread 产出 Log
                              (执行日志,供 retrospection)

Signal 只负责 kickoff Thread。Thread 启动后,由自己的事件循环驱动——Moderator 递话筒、Role 执行、Command Event 流转。Thread 内部不走 Signal 系统。

Thread 产出的 Log 是执行日志,记录 Thread 的中间状态和最终结果。这些 Log 可以被 Sense 的 compute 查询用于 retrospection(如统计成功率、平均耗时),但 Log 不能触发 Reflex(见 §2.4)。

这保证了两个循环的性质不被污染:

  • Signal 循环:无状态、幂等、可合并、可丢弃
  • Thread 循环:有状态、严格顺序、每个 Command Event 必须响应

5. 运行时模型

5.1 进程架构

Engine 主进程是整个系统的 kernel 和 event hub。它持有 Signal Bus、Scheduler、Process Manager,是所有 worker 的唯一通信对象。Worker 之间永远不直连——所有信息流都经过 engine 中转。

Engine 与用户代码完全分离。主进程永远不加载用户代码,所有用户代码在独立子进程中运行。

systemd / pm2
  └─ nerve-engine                              (永驻主进程,纯引擎代码)
       ├─ Scheduler        ← nerve.yaml
       ├─ Signal Bus        ← worker 产出的 signal
       ├─ File Watcher      ← 监听 ~/.uncaged-nerve/ 变化
       └─ Process Manager
            ├─ nerve-worker group=system         (永驻,用户 sense 代码)
            ├─ nerve-worker group=tasks          (永驻,用户 sense 代码)
            ├─ nerve-worker workflow=cleanup     (按需启动,用户 workflow 代码)
            └─ nerve-worker workflow=code-review (按需启动,用户 workflow 代码)

隔离理由

worker_thread 的隔离是假的——用户代码的 process.exit()、native module segfault、OOM 都会杀死主进程。只有进程边界才是真正的隔离墙。

Worker 架构:Engine Runtime + 用户代码

Worker 不是用户代码直接跑的进程,而是 engine 提供的 runtime 加载用户代码。用户代码是被 import 的模块,不是入口。

nerve-engine (kernel)
  └─ nerve worker sense --group system
       ├─ runtime bootstrap(engine 代码)
       │    ├─ 建立 IPC
       │    ├─ 读 nerve.yaml,找到 group 里有哪些 sense
       │    ├─ 对每个 sense:打开 .db → 跑 migration → drizzle 包装
       │    ├─ 构建 peers 只读连接
       │    └─ 发 { type: 'ready' }
       │
       └─ 用户代码(被 import 进来)
            ├─ cpu-usage/schema.ts   ← 纯类型定义
            └─ cpu-usage/index.ts    ← compute 函数,拿到注入的 db

这意味着:

  • 用户代码不操心基础设施——IPC、db 初始化、migration 都是 runtime 的事
  • compute 函数签名简单——engine 注入 db(自己的)和 peers(只读),用户只写业务逻辑
  • 隔离天然成立——用户代码跑在 engine 控制的沙箱里,crash 只影响同 group

Sense Worker

Sense 按 group 分组,同 group 共享一个 worker 进程。用户决定隔离粒度。

senses:
  cpu-usage:
    group: system
  disk-usage:
    group: system
  memory-usage:
    group: system
  active-tasks:
    group: tasks
  • 同 group 的 sense 一个出问题会影响同组,但不影响其他组和引擎
  • Worker 长驻,跟 engine 一起活
  • 崩溃后 engine 自动 respawn

Workflow Worker

同一个 workflow 的所有 thread 共享一个 worker 进程。concurrency 控制进程内的并发 async task 数。

workflows:
  cleanup:
    concurrency: 1
    overflow: drop
  code-review:
    concurrency: 3
    overflow: queue
  • 进程数 = workflow 种类数,不会膨胀
  • 有活跃 thread 时启动,所有 thread 完成后退出(或保持待命一段时间)
  • 崩溃后 engine respawn worker,从持久化状态恢复 thread

进程对比

Sense Worker Workflow Worker
粒度 按 group 按 workflow 种类
内部并发 多 sense 的 compute 多 thread(async)
生命周期 永驻 有活跃 thread 时存活
崩溃恢复 respawn,继续调度 respawn,从持久化状态恢复
状态 持久化状态机

5.2 主进程 ↔ Worker 通信

所有 worker 只与 engine 通信,worker 之间无任何直接通道。这是 hub-and-spoke 拓扑——engine 是 hub,worker 是 spoke。

子进程通过 stdio/IPC 与主进程通信,协议极简:

// 主进程 → worker
{ type: 'compute', sense: 'cpu-usage' }   // 触发一次 compute
{ type: 'shutdown' }                       // 优雅退出

// worker → 主进程
{ type: 'signal', sense: 'cpu-usage', payload: ... }  // compute 完成
{ type: 'error', sense: 'cpu-usage', error: ... }     // compute 失败
{ type: 'ready' }                                      // worker 启动完成

主进程不关心 payload 的内容,只负责转发 signal 到 bus。

Signal Bus 是纯内存结构,不持久化。 Engine 崩溃重启后 bus 中的 signal 丢失,但这不影响正确性——reflex 的语义是"重新感知当前状态"而非"处理每个历史事件"。重启后 scheduler 按 interval 和 lastComputeTime 自然恢复节奏,不需要回放丢失的 signal。

5.3 Sense 运行时配置

Sense 的运行时属性(groupthrottletimeout)在 nerve.yamlsenses 字段中声明,完整示例见 §4.2。

  • group:隔离分组,同 group 共享 worker 进程
  • throttle:最小触发间隔,防止高频 signal 导致的无意义重算
  • timeout:compute 超时上限(soft timeout),超时后 abort 当前 compute,记录错误 signal
  • grace_period:soft timeout 后的宽限期(默认 timeout × 3),超过后 hard kill 整个 group worker 并 respawn。防止跑飞的 compute 堵住同 group

5.4 存储架构

系统有两大类持久化数据,全部 append-only:

Signal 存储

每个 Sense 独立一个 SQLite 文件(见 §8),由 Sense 自行管理 schema。这部分不变。

Log 存储

所有 Log 写入统一的 SQLite 文件 data/logs.db,单表:

CREATE TABLE logs (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  source    TEXT NOT NULL,    -- "sense_scheduler", "sense", "workflow", "system"
  type      TEXT NOT NULL,    -- "run_start", "run_complete", "error", "state_change"
  ref_id    TEXT,             -- 关联的 reflex name / workflow run_id
  payload   TEXT,             -- JSON
  ts        INTEGER NOT NULL  -- unix ms
);

CREATE INDEX idx_logs_source_type ON logs(source, type);
CREATE INDEX idx_logs_ts ON logs(ts);
CREATE INDEX idx_logs_ref_id ON logs(ref_id);
  • 统一一张表,通过 source + type 区分 log 来源和类型
  • Reflex 可以查询 logs 表(只读),但 log 不能触发 reflex(见 §2.4)

Workflow 状态:事件溯源 + 物化表

Workflow Thread 的状态以 append-only 事件流为 source of truth:

-- 也在 logs 表中,source = "workflow"
-- type 取值:queued, started, step_complete, completed, failed, crashed

当前状态 = 该 run_id 最后一条 log entry。例:

source=workflow, type=queued,    ref_id=run-7, ts=1000
source=workflow, type=started,   ref_id=run-7, ts=1001
source=workflow, type=completed, ref_id=run-7, ts=1005

为避免每次查活跃 workflow 都扫描全表,引擎维护一张 物化表,在写 log 的同一事务中 UPSERT:

CREATE TABLE workflow_runs (
  run_id    TEXT PRIMARY KEY,
  workflow  TEXT NOT NULL,       -- workflow 名
  status    TEXT NOT NULL,       -- 最新状态:queued, started, completed, failed, crashed
  ts        INTEGER NOT NULL     -- 最新状态的时间戳
);

CREATE INDEX idx_workflow_runs_status ON workflow_runs(status);

写入流程(同一事务):

BEGIN;
INSERT INTO logs (source, type, ref_id, payload, ts) VALUES ('workflow', 'started', 'run-7', '{}', 1001);
INSERT OR REPLACE INTO workflow_runs (run_id, workflow, status, ts) VALUES ('run-7', 'alert', 'started', 1001);
COMMIT;

查询当前活跃 workflow 变为 O(活跃数):

SELECT * FROM workflow_runs WHERE status IN ('queued', 'started')

物化表是 logs 的派生数据——数据丢失时可从 logs 重建。logs 表仍是 source of truth。

进程重启时从 log 重建内存状态。运行时用内存 materialized view 进一步加速。

冷归档

Engine 定期(cron 或内置 task)将超过 30 天的 log 和 signal 数据导出为按天 JSONL 文件归档:

data/
  logs.db                    # 热数据(近 30 天)
  archive/
    logs/
      2026-03-22.jsonl       # 冷数据,按天归档
      2026-03-23.jsonl
    senses/
      cpu-usage/
        2026-03-22.jsonl

导出后从主库 DELETE + VACUUM。冷数据用 grep/jq 即可查询,不需要 SQL。

水位标记:归档进度记录在 meta 表中,确保任一步崩溃都能安全恢复:

CREATE TABLE meta (
  key   TEXT PRIMARY KEY,
  value TEXT NOT NULL
);

-- 归档水位:已成功归档到哪一天
-- key = "archived_up_to", value = "2026-03-22"

归档流程:

1. 读 meta.archived_up_to,确定从哪天开始
2. 导出该天数据到 JSONL(幂等:同一天重复导出会覆盖文件)
3. 同一事务:DELETE 该天数据 + UPDATE meta.archived_up_to
4. VACUUM(可选,非事务内)

任何一步崩溃,重启后从水位标记处继续,不会丢数据也不会重复删除。

5.5 热更新

主进程 watch ~/.uncaged-nerve/ 文件变化,按类型处理:

变化 处理
sense ts 文件修改 等当前 compute 完成 → kill 对应 group worker → respawn
workflow ts 文件修改 drain(等活跃 thread 完成,drain_timeout 后 force kill + 标记 crashed)→ respawn
nerve.yaml 修改 主进程重新解析,diff 变更(见下)

nerve.yaml diff 处理:

变更 处理
新增 sense spawn worker(或加入已有 group)
删除 sense 从 worker 中移除
修改 reflex 更新 scheduler,不动 worker
修改 throttle/timeout 更新 scheduler
修改 group kill 旧 worker,按新分组 respawn

5.6 错误处理

单个 sense 或 workflow 的失败不影响其他组件。

情况 处理
compute 抛异常 写 log(source=system, type=error),下次触发重试
compute 超时 soft timeout → abort + 写 error log;grace_period 后 hard kill worker + respawn
存储写入失败 写 error log,不发 signal(未成功产出)
nerve.yaml 语法错误 daemon 拒绝加载,保持当前配置
sense ts 语法错误 该 group worker 加载失败,其他 group 正常
workflow worker 崩溃 幂等 thread 自动恢复,非幂等标记 crashed

5.7 自监测

Daemon 用自己的 Sense 机制监测自身健康:

// senses/nerve-health.ts
export async function compute(): Promise<NerveHealth | null> {
  return {
    uptime: process.uptime(),
    activeSenses: scheduler.activeSenseCount(),
    pendingComputes: scheduler.pendingCount(),
    lastErrors: errorLog.recent(10),
    memoryUsage: process.memoryUsage(),
  }
}

但 daemon 自身挂了这个 sense 也不会跑,所以外层 systemd/pm2 是最后防线:

systemd (最后防线,只管进程存活)
  └─ nerve-engine (自监测 + 丰富健康数据)
       └─ nerve-health sense → 可产出 signal 通知外部

6. 函数式性质

用 Haskell 描述核心类型和函数:

-- Sense 是多态的,compute 返回 Maybe
class Sense a where
  type Payload a
  compute :: IO (Maybe (Payload a))  -- Nothing → 静默,Just → 发 signal

-- Reflex 是纯数据,连接 Signal 与 Action
data Reflex = Reflex
  { condition :: ReflexCondition
  , action    :: ReflexAction
  , enabled   :: Bool
  }

data ReflexCondition
  = OnInterval Interval
  | OnSignal [SenseId]
-- OnDemand 是引擎内置能力,不需要声明

data ReflexAction
  = TriggerCompute SenseId           -- 触发 Sense 重算
  | StartWorkflow WorkflowId         -- 启动 Workflow Thread
-- Reflex 构成 Event Mesh:Signal → ReflexCondition → ReflexAction

-- Workflow 内部
moderate :: Thread -> CommandEvent -> (RoleId, Prompt)   -- 纯函数 ✅
execute  :: Role -> Prompt -> IO CommandEvent             -- 有副作用 ❌

核心的纯函数/副作用边界:

函数 纯/IO
compute (Sense) IO — 读世界或读存储,返回 Maybe
moderate (Workflow)
execute (Role) IO — 调 API、改文件
Reflex 条件判断 纯数据,引擎硬编码

7. 依赖关系与生命周期

依赖图

Reflex ──→ Sense ──→ Sense (复合依赖)
  │
  └──→ Workflow (Reflex 声明触发)

软删除与级联

出于历史不可变性,不删除实体,只关停(enabled: false)。

操作 级联
disable Sense disable 依赖它的 Reflex + 依赖它的复合 Sense
disable Workflow disable 触发它的 Reflex
disable Reflex 无级联

清理是可选的离线 GC 过程。

8. 用户本地配置

用户的 nerve 实例位于 ~/.uncaged-nerve/,作为一个 local git repo 管理:

~/.uncaged-nerve/
  package.json                # 依赖管理(含 drizzle-orm)
  nerve.yaml                  # 主配置(含 reflexes)
  senses/
    cpu-usage/
      schema.ts               # Drizzle schema(single source of truth)
      index.ts                # compute 逻辑
      migrations/             # drizzle-kit 自动生成
        0001_init.sql
    disk-usage/
      schema.ts
      index.ts
      migrations/
        0001_init.sql
    active-tasks/
      schema.ts
      index.ts
      migrations/
        0001_init.sql
  workflows/                  # post-MVP
    cleanup.ts
  data/                       # ⛔ gitignored
    logs.db                   # 统一 log 存储(append-only)
    senses/
      cpu-usage.db            # 每个 sense 独立的 sqlite
      disk-usage.db
      active-tasks.db
    archive/                  # 冷归档(>30天)
      logs/
        2026-03-22.jsonl
      senses/
        cpu-usage/
          2026-03-22.jsonl
    blobs/                    # CAS blob store,sha256 寻址
      ab/
        cd1234...
  node_modules/               # ⛔ gitignored
  .gitignore
  .git/

设计要点

  • local git repo — 配置和 sense 逻辑可回滚,data 不进 git
  • package.json — 标准 npm 包,npm install 管理依赖(含 drizzle-ormdrizzle-kit 为 devDependency)
  • nerve.yaml — 单一配置入口,含 senses(运行时属性如 throttle)和 reflexes(触发条件)两个字段
  • senses/{name}/ — 每个 sense 一个目录,含 schema.ts(Drizzle schema)、index.ts(compute)、migrations/(自动生成)
  • data/senses/ — 每个 sense 一个 sqlite 文件,引擎启动时自动执行 migration
  • data/blobs/ — CAS(Content-Addressable Storage),sha256 前两位分片目录,sense 存大对象时写 blob 拿 hash,db 里只存 hash 引用
  • data/ 和 node_modules/ gitignored — 只有逻辑和配置进版本控制

9. MVP 范围

范围 状态
Sense 定义(compute + 独立存储) MVP
Reflex 配置(nerve.yaml) MVP
Nerve Daemon(读 yaml,按条件调 compute) MVP
Workflow(Role + Moderator + Thread) Post-MVP

10. 设计原则

  1. Sense 是唯一的一等公民 — 原始采样和派生计算统一为 Sense,Sense 不知道下游
  2. 计算与触发解耦 — compute 不知道自己什么时候被调用
  3. Reflex 是 Event Mesh — 所有事件路由都声明在 Reflex 中,Sense 和 Workflow 之间无直连
  4. Log 是终点不是起点 — Log 是数据资产,Reflex 可读但不被 Log 触发,防止雪崩
  5. 存储去中心化 — 每个 Sense 自管存储,Log 统一一张表,全部 append-only
  6. 不删除只关停 — 历史不可变,生命周期通过 enabled 控制
  7. Workflow 是可选扩展 — MVP 不需要,后续按需加入
  8. 引擎极简 — 只做调度,不做业务逻辑