数据截至 (上游 commit e741923f72c3)
从画布到 DAG:数据模型、块注册表、序列化与图编译
30 秒导读: 用户在 Sim 的可视化编辑器里拖出来的那张画布,要变成执行器能跑的东西,得先过四道关:存进数据库的三张表 → 内存里的
BlockState→ 扁平的SerializedWorkflow→ 带哨兵节点的DAG。本章讲透这四道关,尤其是最后一步:循环和并行怎么被编译掉,变成同一张图上的普通节点。
本章不涉及运行期怎么调度这张图(那是 调度引擎 的事),也不涉及循环变量怎么解析(见 子流程与变量)。
路径约定: 所有源码路径相对克隆根,首次出现给全路径(
apps/sim/…或packages/…);同一文件在同一节内再次出现时用文件名简写(例:edges.ts:284=apps/sim/executor/dag/construction/edges.ts:284)。§10 代码地图给全路径与符号名。行号锚定 frontmatter 里的sourceCommit。
1. 这条链在干什么(零基础也能懂)
Sim 是一个可视化的 AI 工作流编辑器:你在画布上拖几个块(Agent、条件、API 调用),用线连起来,点运行。
问题在于——画布上的东西不能直接跑。画布关心的是"这个框在哪、长什么样、用户填了什么";执行器关心的是"哪个节点 先跑、跑完之后哪条边亮、参数是什么"。这两件事的数据形状完全不同。
所以 Sim 让同一份工作流依次换四种形态:
| 形态 | 长什么样 | 谁在用 | 定义在哪 |
|---|---|---|---|
| 数据库行 | 三张表:块一行、边一行、子流程一行 | 协作服务器、持久化 | packages/db/schema.ts:300 |
BlockState | 内存对象,带 subBlocks 用户填值 | 编辑器 UI、Zustand store | packages/workflow-types/src/workflow.ts:174 |
SerializedWorkflow | 扁平数组:blocks + connections + loops + parallels | 序列化器输出、执行快照 | apps/sim/serializer/types.ts:5 |
DAG | 节点 Map + 出入边集合,含哨兵假节点 | 执行器调度 | apps/sim/executor/dag/builder.ts:30 |
整条链走一遍:
画布 (React Flow)
│ 每次编辑发一个 socket op
▼
Postgres 三张表
workflow_blocks / workflow_edges / workflow_subflows
│ loadWorkflowFromNormalizedTablesRaw + 合并 subblock 值
▼
Record<string, BlockState> ← UI 真值
│ Serializer.serializeWorkflow
▼
SerializedWorkflow ← 执行用的扁平快照
│ DAGBuilder.build
▼
DAG(含 sentinel 节点) → 交给调度器
一句话直觉: 把这条链当编译器。BlockState 是源码,SerializedWorkflow 是中间表示(IR),DAG 是目标代码——循环这种"高级语法"在编译到 DAG 时被展开掉了,就像 for 循环被编译成跳转指令。
2. 存储层:块和边是独立的表行,不是一坨 JSON
2.1 四张表 各管什么
Sim 没有把工作流存成一个 state JSON 大字段,而是拆成规范化的三张表(外加一张部署快照表):
| 表 | 一行是什么 | 关键列 | 定义 |
|---|---|---|---|
workflow | 一个工作流的元信息 | isDeployed、variables、archivedAt | packages/db/schema.ts:239 |
workflow_blocks | 画布上的一个块 | type、positionX/Y、subBlocks(jsonb)、data(jsonb) | packages/db/schema.ts:300 |
workflow_edges | 画布上的一根连线 | sourceBlockId、targetBlockId、sourceHandle | packages/db/schema.ts:338 |
workflow_subflows | 一个循环/并行容器的配置 | type('loop' 或 'parallel')、config(jsonb,含 nodes 列表) | packages/db/schema.ts:370 |
workflow_edges 的两个外键都指向 workflowBlocks.id 并带 onDelete: 'cascade'——删块自动带走它的所有连线,不需要应用层清理。
2.2 为什么要拆行
因为 Sim 的画布是多人实时协作的。
如果整份工作流是一个 JSON blob,两个人同时拖两个不 同的块,后写的那次会整份覆盖前一次。拆成行以后,"A 移动块 X"和"B 改块 Y 的参数"落到两条不同的 UPDATE,天然不冲突。这就是 apps/realtime 那一整套 socket 操作能存在的前提(见 §8)。
2.3 唯一的例外:部署版本是 JSON 快照
workflow_deployment_version 反其道而行,把整份状态塞进一个 state json 列(packages/db/schema.ts:3274),按 (workflowId, version) 唯一,并用 isActive 标出当前生效版本。
道理很直白:草稿要能被逐行改,快照要能被整份冻住。 部署版本一旦生成就不再编辑,拆行反而是负担。
2.4 从行拼回内存对象
loadWorkflowFromNormalizedTablesRaw(packages/workflow-persistence/src/load.ts:73)一次并发查四份数据,然后按块 ID 拼成 Record<string, BlockState>,把边转成 React Flow 的 Edge[],把 subflow 的 config 还原成 Loop / Parallel。
注意它的名字里有个 Raw:它故意不做块迁移(凭证重写、subblock ID 迁移、canonical 模式回填),因为迁移要读块注册表,而注册表住在 Next 应用里,不能被拖进这个叶子包。这是 monorepo 里一条硬边界。
执行前还有一步补值:mergeSubblockStateWithValues(packages/workflow-persistence/src/subblocks.ts:60)把额外的 subblock 值合并进块结构,null/undefined 不覆盖已有值。调用点在 apps/sim/lib/workflows/executor/execution-core.ts:537。
3. 块注册表:一个块"是什么"的静态定义
3.1 BlockConfig:块的说明书
画布上每种块(Agent、Slack、条件……)都有一份静态定义,类型是 BlockConfig(apps/sim/blocks/types.ts:571)。它同时喂三方:UI 渲染表单、序列化器挑工具、执行器读输入输出。
关键字段:
| 字段 | 干什么 | 谁读 |
|---|---|---|
type | 注册表的键,也是块行的 type 列 | 全链路 |
category | 'blocks' / 'tools' / 'triggers',见 BlockCategory(types.ts:17) | 序列化器判触发块、UI 分组 |
subBlocks | 表单字段列表,每项是 SubBlockConfig | UI 渲染 + 序列化 |
tools.access |