b269f76b33
Rename ReflexScheduler to SenseScheduler, update all file names, imports, comments, test descriptions, and log source values. Fixes #202
777 lines
29 KiB
Markdown
777 lines
29 KiB
Markdown
# 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。
|
|
|
|
```typescript
|
|
// 原始采样:读物理世界,每次都有值
|
|
// 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**:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// 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 会触发什么动作。
|
|
|
|
```typescript
|
|
// 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) |
|
|
|
|
```yaml
|
|
# 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 配置
|
|
|
|
```yaml
|
|
# 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 进程。用户决定隔离粒度。
|
|
|
|
```yaml
|
|
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 数。
|
|
|
|
```yaml
|
|
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 与主进程通信,协议极简:
|
|
|
|
```typescript
|
|
// 主进程 → 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`,单表:
|
|
|
|
```sql
|
|
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:
|
|
|
|
```sql
|
|
-- 也在 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:
|
|
|
|
```sql
|
|
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);
|
|
```
|
|
|
|
写入流程(同一事务):
|
|
|
|
```sql
|
|
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(活跃数):
|
|
|
|
```sql
|
|
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 表中,确保任一步崩溃都能安全恢复:
|
|
|
|
```sql
|
|
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 机制监测自身健康:
|
|
|
|
```typescript
|
|
// 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 描述核心类型和函数:
|
|
|
|
```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. 设计原则
|
|
|
|
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. **引擎极简** — 只做调度,不做业务逻辑
|