- Cold archival: meta table with archived_up_to watermark for crash-safe recovery - Workflow state: workflow_runs materialized table (UPSERT in same txn as log write) - O(active) queries instead of full table scan - Derivable from logs if lost 小橘 <xiaoju@shazhou.work>
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.ts 与 migrations/ 是否同步。这是开发时的职责——创建/修改 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 的运行时属性(group、throttle、timeout)在 nerve.yaml 的 senses 字段中声明,完整示例见 §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, -- "reflex", "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-orm,drizzle-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. 设计原则
- Sense 是唯一的一等公民 — 原始采样和派生计算统一为 Sense,Sense 不知道下游
- 计算与触发解耦 — compute 不知道自己什么时候被调用
- Reflex 是 Event Mesh — 所有事件路由都声明在 Reflex 中,Sense 和 Workflow 之间无直连
- Log 是终点不是起点 — Log 是数据资产,Reflex 可读但不被 Log 触发,防止雪崩
- 存储去中心化 — 每个 Sense 自管存储,Log 统一一张表,全部 append-only
- 不删除只关停 — 历史不可变,生命周期通过 enabled 控制
- Workflow 是可选扩展 — MVP 不需要,后续按需加入
- 引擎极简 — 只做调度,不做业务逻辑