跳到主要内容

数据截至 (上游 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 storepackages/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一个工作流的元信息isDeployedvariablesarchivedAtpackages/db/schema.ts:239
workflow_blocks画布上的一个块typepositionX/YsubBlocks(jsonb)、data(jsonb)packages/db/schema.ts:300
workflow_edges画布上的一根连线sourceBlockIdtargetBlockIdsourceHandlepackages/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表单字段列表,每项是 SubBlockConfigUI 渲染 + 序列化
tools.access这个块能用哪些工具 ID序列化器
tools.config.tool(params)按参数动态挑一个工具 ID序列化器
tools.config.params(params)执行期改写参数执行器
inputs / outputs参数类型表 / 输出字段表序列化器、变量解析
integrationTypeIntegrationType 枚举(types.ts:19),16 个集成品类目录页 UI,执行器不读

SubBlockConfig(apps/sim/blocks/types.ts:258)是本章后面反复出现的主角,先记住三个字段:mode(basic / advanced / both / trigger / trigger-advanced)、canonicalParamId(双形态字段的配对键)、condition(按其它字段的值决定是否出现)。

ParamConfig(apps/sim/blocks/types.ts:241)则简单得多——只有 type 和可选的 JSON Schema,用于 BlockConfig.inputs

3.2 注册表就是一个大 Map

// apps/sim/blocks/registry.ts:20 —— 真实源码,精简了注释
export function getBlock(type: string): BlockConfig | undefined {
return BLOCK_REGISTRY[type] ?? BLOCK_REGISTRY[normalizeType(type)]
}

BLOCK_REGISTRY 是一个手写的字面量对象(apps/sim/blocks/registry-maps.ts:370),从 blocks/blocks/ 下 276 个 .ts 文件(其中 5 个是测试)里 import 每个块的配置,拼出 302 个注册键——比文件数多,因为同一集成可以有 _v2 版本变体同时在册。

normalizeType 把连字符换成下划线,所以 api-triggerapi_trigger 都能查到。版本变体由 getLatestBlock(registry.ts:65)按 ^{base}_v(\d+)$ 挑最大号解析。

apps/sim/blocks/index.ts 只是个 barrel,转出 getBlock 等六个访问器——注册表本身不对外直接暴露。

3.3 三个样本块,看清定义的三种极端

文件:行subBlockstools特点
conditionblocks/blocks/condition.ts:171 个(condition-input)access: []纯控制流,没有工具,输出 selectedPath 供边裁剪用
api_triggerblocks/blocks/api_trigger.ts:41 个(input-format)access: []category: 'triggers',outputs 是空的——输出在运行时按 inputFormat 动态生成
agentblocks/blocks/agent.ts:7620+ 个,含 canonical 对6 个 provider 工具 + config.tool最复杂的块:动态挑工具、双形态字段、参数改写

Agent 块的挑工具逻辑值得单看:

// apps/sim/blocks/blocks/agent.ts:504 —— 真实源码,精简
tool: (params: Record<string, any>) => {
const model = params.model || 'claude-sonnet-4-6'
const tool = getBaseModelProviders()[model]
if (!tool) throw new Error(`Invalid model selected: ${model}`)
return tool
}

用户选的模型名决定最终调用哪个 provider 工具(openai_chat / anthropic_chat / …)。

这里有个真实的坑。 tools.config.tool序列化期执行(见下节 selectToolId),那时变量引用 <Block.output> 还没解析,值还是字面字符串。所以这个函数里绝不能做 Number() 之类的类型转换——转换要放到 tools.config.params,那个在执行期跑。仓库的集成开发规范把这条列为硬规则。


4. 可见性:用户填的哪些字段才配进序列化

这是整条链上最不显然的一段。先说它要解决的问题。

4.1 问题:同一个参数,两种填法

拿 Agent 块的"文件"参数举例。用户可能想:

  • 直接上传几个文件 → 需要一个 file-upload 控件;
  • 引用上游块的输出(比如 <extract1.files>)→ 需要一个能写表达式的 short-input

这是同一个逻辑参数 files,但 UI 上必须是两个不同的控件。Sim 的做法是给它们同一个 canonicalParamId:

// apps/sim/blocks/blocks/agent.ts:138-155 —— 真实源码,精简
{ id: 'attachmentFiles', type: 'file-upload', canonicalParamId: 'files', mode: 'basic' },
{ id: 'files', type: 'short-input', canonicalParamId: 'files', mode: 'advanced' },

这一对叫 canonical pair(规范参数对):两个 subBlock ID,一个逻辑参数名。序列化时必须二选一,并把值写到 files 这个 canonical 名下——否则执行器会同时看到两个来源,不知道听谁的。

4.2 canonical pair 的工作方式

用户填的原始值 canonical 组 序列化输出
────────────────── ──────────── ──────────
attachmentFiles: [f1] ─┐
├──► canonicalId: files ──► params.files = <选中那个>
files: "<a.out>" ─┘ basicId: attachmentFiles
advancedIds: [files]

模式怎么定? ──┴──► ① block.data.canonicalModes 里的显式覆盖
② 否则:basic 空且 advanced 有值 → advanced
③ 否则 → basic

对应四个函数,都在 apps/sim/lib/workflows/subblocks/visibility.ts:

函数干什么
buildCanonicalIndex:98扫一遍 subBlocks,按 canonicalParamId 分组,产出 groupsById 和反查表 canonicalIdBySubBlockId
isCanonicalPair:130组里既有 basic 又有 advanced 才算"对"
resolveCanonicalMode:154定这一组当前用哪一边(覆盖优先,否则按"谁有值")
evaluateSubBlockCondition:175condition:字段等值/包含判断,支持 not 和一层 and
isSubBlockHidden:398环境级隐藏:hideWhenHosted(托管版藏 API Key 字段)、hideWhenEnvSet

buildCanonicalIndex 里有个细节值得记:触发器的 subBlocks 是被块展开(spread)在自己的之后的,所以分组时特意规定"trigger 模式的 subBlock 不得覆盖已被非 trigger subBlock 占住的 basicId"(visibility.ts:117)。

4.3 判定顺序:shouldSerializeSubBlock

apps/sim/serializer/index.ts:46shouldSerializeSubBlock 是最终裁判。它按固定顺序过闸,任何一关不过就出局:

① 特性开关关着? isSubBlockFeatureEnabled → false 出局
② 环境要求隐藏? isSubBlockHidden → true 出局
③ trigger 模式对不上? mode==='trigger' 但不在触发上下文 → 出局
④ 属于 canonical 组? → 只留下当前模式那一边,再看 condition
⑤ mode='advanced' 但没开高级? → 只有"填了值"才留(容错)
⑥ mode='basic' 但开了高级? → 出局
⑦ 最后看 condition

第 ⑤ 关是故意的容错:用户先在高级模式里填了值,又把开关关掉,值不会被悄悄丢掉(serializer/index.ts:91-93)。

4.4 三阶段收集:extractBlockParams

apps/sim/serializer/index.ts:489extractBlockParams 把 UI 的 subBlock 状态压成执行器看到的扁平 params,分三步走:

阶段干什么
收集:452-485遍历用户实际填过的 subBlock,过 shouldSerializeSubBlock;另开两个后门:starter 块的 inputFormat、agent 块的三个遗留字段(systemPrompt/userPrompt/memories)无条件放行
补默认值:487-504配置里带 value(params) 函数的字段,若还没值就算出来填上
坍缩:506-521对每个 canonical 组,选出模式对应的值,删掉所有成员原始 ID,只写 params[canonicalId]

第三步是关键:执行器永远看不到 attachmentFiles 这个 ID,只看到 files

坑在哪: 如果活跃的那一边是空的,而不活跃的那边有值,值就被静默丢弃了。所以 collectBlockFieldIssues(serializer/index.ts:630)专门检出这种情况,返回结构化的 InactiveModeValue[](serializer/index.ts:452)给 copilot 的工作流 lint 用。同一个函数被 serializeBlock 包一层用来抛错,两边共用一份判定,保证 lint 和真实执行永不漂移。


5. 序列化:从 UI 状态到 SerializedWorkflow

5.1 输出长什么样

// apps/sim/serializer/types.ts:4 —— 真实源码
export interface SerializedWorkflow {
version: string
blocks: SerializedBlock[]
connections: SerializedConnection[]
loops: Record<string, SerializedLoop>
parallels?: Record<string, SerializedParallel>
}

对比一下 UI 侧的形状,差别一眼可见:

维度BlockState(UI)SerializedBlock(执行)
用户填的值subBlocks: Record<id, {id,type,value}>config.params: Record<string, unknown> 扁平
工具没有;由 BlockConfigconfig.tool: string 已定死
容器关系data.parentId 指向父容器不在块上;抽到 loops / parallels
展示信息完整只留 metadata(名字、类别、颜色)

SerializedBlock 定义在 serializer/types.ts:24,SerializedLoop / SerializedParallel:45 / :55

5.2 容器块被抽走:generateLoopBlocks / generateParallelBlocks

画布上的"循环"是个可以往里拖块的框,父子关系记在子块的 data.parentId 上。序列化时这层关系要被翻过来——变成容器持有一份子节点 ID 列表。

// apps/sim/stores/workflows/workflow/utils.ts:144 —— 真实源码
export function findChildNodes(containerId: string, blocks: Record<string, BlockState>): string[] {
return Object.values(blocks)
.filter((block) => block.data?.parentId === containerId)
.map((block) => block.id)
}

convertLoopBlockToLoop(utils.ts:75)在此基础上读出 loopTypeiterations(默认 5)、forEachItems 等,generateLoopBlocks(utils.ts:208)把画布上所有 type === 'loop' 的块过一遍。并行侧对称:convertParallelBlockToParallel(utils.ts:106)+ generateParallelBlocks(utils.ts:229),并行的 batchSizeclampParallelBatchSize 夹在 1–20 之间(utils.ts:12)——夹的是每批同时跑几个分支,不是分支总数,详见 §9。

serializeWorkflow 里有一条容易看漏的优先级规则:

// apps/sim/serializer/index.ts:155-159 —— 真实源码
const canonicalLoops = generateLoopBlocks(blocks)
const safeLoops = Object.keys(canonicalLoops).length > 0 ? canonicalLoops : loops || {}

从块重算出来的容器配置,永远优先于调用方传进来的那份。 传入的 loops 只在重算结果为空时兜底——这样画布上刚拖进循环的块不会因为调用方传了旧配置而被漏掉。

5.3 容器块自己也被序列化成块

serializeBlockloop / parallel 类型走特判(serializer/index.ts:229-248):config.tool 设成空串,config.params 直接放 block.data(保住 parallelTypecount 这些)。

于是同一个循环在 SerializedWorkflow 里出现两次——一次作为 blocks 里的一个块(承载连线端点),一次作为 loops 里的一条配置。这个双份表示是下一节图编译的输入前提。

5.4 执行前校验

serializeWorkflow 的第五个参数 validateRequired 为 true 时,每个块都会跑一遍必填检查(serializer/index.ts:267-275),缺字段直接抛 Error

结构化的 WorkflowValidationError(serializer/index.ts:31,带 blockId / blockType / blockName)则由 UI 侧的执行 hook 抛出,用于把错误定位到画布上具体某个块(apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts:975 起)。

真实的服务端执行入口在 apps/sim/lib/workflows/executor/execution-core.ts:644,那里是唯一一处带 validateRequired = true 的调用。


6. 图编译:DAGBuilder 六步把画布变成可跑的图

6.1 输出的数据结构

// apps/sim/executor/dag/builder.ts:22-34 —— 真实源码
export interface DAGNode {
id: string
block: SerializedBlock
incomingEdges: Set<string>
outgoingEdges: Map<string, DAGEdge>
metadata: NodeMetadata
}
export interface DAG {
nodes: Map<string, DAGNode>
loopConfigs: Map<string, SerializedLoop>
parallelConfigs: Map<string, SerializedParallel>
}

DAGEdge 只有三个字段:targetsourceHandletargetHandle(apps/sim/executor/dag/types.ts:1)。sourceHandle分支剪枝的钥匙——条件块的 condition-{id}、路由块的 router-{id}、循环的 loop_exit 都编在这里,调度器靠它决定哪条边亮(细节见 02 章)。

NodeMetadata(dag/types.ts:9)则是节点的身份标签:isSentinelsentinelTypesubflowIdisParallelBranchbranchIndexoriginalBlockId 等。

6.2 六步流水线

DAGBuilder.build(apps/sim/executor/dag/builder.ts:52)的顺序是死的,每一步都依赖前一步的产出:

SerializedWorkflow

① initializeConfigs 把 loops/parallels 深拷进 DAG(nodes 数组要复制,后面会改)

② PathConstructor 从触发块 BFS,算出"可达块集合"

③ LoopConstructor 给每个可达循环建 sentinel_start / sentinel_end 一对假节点

④ ParallelConstructor 同上,给并行建

⑤ NodeConstructor 把普通块变成 DAGNode(并行子块建 ₍0₎ 模板节点)

⑥ EdgeConstructor 接线:改写容器端点、缝哨兵、装反向边

validateSubflowStructure

DAG

注意 ③④ 在 ⑤ 之前——哨兵节点先于普通块进 dag.nodes,这样第 ⑥ 步接线时哨兵已经在了。

6.3 ② 可达性剪枝:不在路径上的块直接不存在

PathConstructor.execute(apps/sim/executor/dag/construction/paths.ts:8)先定触发块,再 BFS:

  • 显式传了 triggerBlockId → 用它;若该块被禁用,退而找另一个启用的触发块(paths.ts:40-57);
  • 没传 → 找第一个 category === 'triggers' 的启用块(findExplicitTrigger,paths.ts:90);
  • 还没有 → 找第一个没有入边、且不是"仅元数据块"的启用块当根(findRootBlock,paths.ts:99);
  • 全都没有 → 打 warn,退化为"所有启用的块"。

buildAdjacencyMap(paths.ts:123)在建邻接表时就把两端有一端被禁用的连线剔掉,再 performBFS(paths.ts:140)。画布上那些孤立的、没连上触发器的块,从这一步起就不存在于图里了。

"仅元数据块"是 loopparallelnote 三种(apps/sim/executor/constants.ts:61)——它们不是可执行单元,只是容器和便签。

6.4 ③④ 哨兵节点:本章的核心设计

先说它解决什么问题。 循环和并行是控制结构:要重复执行、要扇出扇入。最直觉的实现是给执行器加一个特殊状态机——"遇到循环节点就进入循环模式,记住迭代计数,跑完子图再回来"。

Sim 没这么做。它选择在编译期把控制结构展开成图上的普通节点:

编译前(画布语义) 编译后(DAG)

┌─ loop-1 ─────────┐ loop-1-sentinel-start
│ │ │
│ [A] ──► [B] │ [A] ──► [B]
│ │ │ │
└──────────────────┘ ▼ ▼
│ (bypass) loop-1-sentinel-end
▼ ╲ │ ╲
[C] ╲──────┘ └─loop_continue─┐
│ │
loop_exit │
▼ │
[C] ◄─────────────┘
(回到 sentinel-start)

怎么读这张图:左边是用户看到的"框住"关系;右边是执行器看到的图——容器消失了,变成一对首尾假节点,子块夹在中间,末尾有一条 loop_continue 反向边回到起点。

LoopConstructor.execute(apps/sim/executor/dag/construction/loops.ts:6)干的事很少:跳过不可达的循环;若循环里一个可达子块都没有,把 nodes 清空;然后建一对哨兵。

哨兵节点由 createSubflowSentinelNode(apps/sim/executor/dag/construction/sentinels.ts:14)造出来——它伪造一个 SerializedBlock,位置 {x:0,y:0},inputs/outputs 全空,metadata.isSentinel = true执行器看它跟看任何块没区别,只是它的 handler 做的是"记一次迭代/判断要不要再来一轮"。

ParallelConstructor(construction/parallels.ts:9)与之完全对称。

6.5 节点 ID 的编码规则

哨兵和分支节点都靠 ID 命名约定辨认。所有格式集中在 SubflowNodeIdCodec(apps/sim/executor/utils/subflow-node-id-codec.ts),外面通过 apps/sim/executor/utils/subflow-utils.ts 的薄封装用:

构造函数产出格式例子
buildSentinelStartIdsubflow-utils.ts:15loop-{id}-sentinel-startloop-abc-sentinel-start
buildSentinelEndIdsubflow-utils.ts:22loop-{id}-sentinel-endloop-abc-sentinel-end
buildParallelSentinelStartIdsubflow-utils.ts:26parallel-{id}-sentinel-startparallel-xyz-sentinel-start
buildBranchNodeIdsubflow-utils.ts:54{blockId}₍N₎(下标数字)blockA₍0₎
normalizeNodeIdsubflow-utils.ts:138剥掉分支下标和克隆后缀,还原原始块 ID

前缀和后缀常量都在 apps/sim/executor/constants.ts:106(LOOP.SENTINEL)和 :122(PARALLEL.SENTINEL);分支下标那对括号字符在 :117(PARALLEL.BRANCH)。

用 Unicode 下标 ₍0₎ 而不是 _0 是个小心思:普通块 ID 里不会出现这两个字符,所以 isBranchNodeId 的正则不会误判用户自定义的 ID。

6.6 ⑤ 建节点:并行子块变模板

NodeConstructor.execute(apps/sim/executor/dag/construction/nodes.ts:6)先把"哪些块在循环里、哪些在并行里"归类,然后逐块建节点,分两条路:

  • 在并行里createParallelTemplateNode(nodes.ts:93):建 ID 为 {blockId}₍0₎模板节点,metadata 标 isParallelBranch: true, branchIndex: 0, branchTotal: 1。真正的分支数要等运行期算出集合大小才知道,所以编译期只放一份模板。
  • 不在并行里createRegularOrLoopNode(nodes.ts:117):ID 就是块 ID,循环内的块打上 isLoopNode 和所属 subflowId

嵌套循环里一个块可能属于多个循环。findLoopIdForBlock(nodes.ts:140)挑最内层那个:候选中那个"不包含其它候选"的循环。

6.7 ⑥ 接线:EdgeConstructor 的三件事

EdgeConstructor.execute(apps/sim/executor/dag/construction/edges.ts:43)分三步:wireRegularEdgeswireLoopSentinelswireParallelSentinels

第一步:改写用户画的连线。 wireRegularEdges(edges.ts:198)逐条处理原始 connections,规则表(按代码里的判定顺序排):

原始连线情形改写成位置
两端都在同一个并行里(容器端点形态)源解析成 sentinel-end、目标解析成 sentinel-start,顺带补一条空转旁路edges.ts:226-237
源是循环容器源换成 loop-{id}-sentinel-end,handle 强设为 loop_exitedges.ts:239-248
目标是循环容器目标换成 loop-{id}-sentinel-startedges.ts:250-256
源是并行容器,且目标是它的子块直接跳过(这条由哨兵接线负责)edges.ts:259-263
源是并行容器,目标在外源换成 parallel-{id}-sentinel-end,handle 设 parallel_exitedges.ts:264-270
跨越循环边界丢弃edges.ts:284-286
两端都在同一个并行里(块到块)wireParallelTemplateEdge,连的是 ₍0₎ 模板节点edges.ts:292-297

在此之前 generateSourceHandle(edges.ts:148)还会给没带 handle 的条件/路由边补上 handle:条件块按连线顺序对上第 N 个条件,生成 condition-{条件ID};旧版路由块生成 router-{目标块ID}。handle 常量表在 apps/sim/executor/constants.ts:69(EDGE)。

第二步:缝循环哨兵。 wireLoopSentinels(edges.ts:310)先用 findLoopBoundaryNodes(edges.ts:560)找出循环内的"起点"(没有来自循环内的入边)和"终点"(没有指向循环内的出边),然后:

  • sentinel_start → 每个起点
  • 每个终点 → sentinel_end
  • sentinel_end → sentinel_start,handle loop_continue

第三步对并行同理(wireParallelSentinels,edges.ts:355),用 parallel_continue

6.8 三种"不算入度"的边

addEdge(edges.ts:755)有个 registerIncoming 选项。默认 true——边同时写进源节点的 outgoingEdges 和目标节点的 incomingEdges。但有三处传了 false:

位置handle
循环回边 sentinel_end → sentinel_startedges.ts:349-351loop_continue
并行回边 sentinel_end → sentinel_startedges.ts:390-392parallel_continue
空转旁路 sentinel_start → sentinel_endedges.ts:737(并行)、:748(循环)parallel_exit / loop_exit

回边是这张"DAG"里唯一的环。只登记出边、不登记入边,意味着从 incomingEdges 的视角看这张图仍然无环——而调度器的就绪判定正是读 incomingEdges 的。这样一来循环既能真的循环,又不会让 sentinel_start 因为"有个永远不会满足的前驱"而永不就绪 (inferred:代码只做了这件事,没写下这个理由)。

旁路边 addSubflowStartExitBypass(edges.ts:732)的用途是"这个容器这次不用跑"——比如集合为空,start 可以直接把控制权递给 end。

6.9 最后一道校验

validateSubflowStructure(apps/sim/executor/dag/builder.ts:137)对每个循环/并行检查:哨兵 start 的出边里,有没有一条指向该容器的子节点(用 normalizeNodeId 剥掉分支下标再比)。一条都没有就抛:

Loop start is not connected to any blocks. Connect a block to the loop start.

这是用户在画布上"拖了个循环框但没把里面的块连到循环起点"时会看到的报错(builder.ts:166-170)。

6.10 恢复模式:直接覆盖入边

build 还接受 savedIncomingEdges(builder.ts:83-95):从执行快照恢复时,直接用存下来的入边集合覆盖新建的。因为恢复时有些前驱已经跑完、边已经被消费掉了,重新算一遍会让节点重新变成"未就绪"。暂停/恢复的整体机制见 05 章

includeAllBlocks: true 则是"从某个块开始重跑"模式用的:跳过可达性剪枝,把所有启用块都放进来(paths.ts:15-17,调用点 apps/sim/executor/execution/executor.ts:136)。


7. 这条链上值得抄走的三个设计

① 控制结构编译成节点,而不是执行器里的状态机。 循环、并行在 DAG 里就是普通节点(construction/sentinels.ts:14)。好处是执行器只需要一套"节点就绪就跑"的逻辑,不需要为每种控制结构写一份状态机;嵌套循环嵌并行也自动成立,因为哨兵可以互相嵌套接线(edges.ts:512resolveLoopChildNode 就是在做这个转接)。代价是节点 ID 的编码规则变复杂——所以他们把所有格式收进一个 SubflowNodeIdCodec 单点。

② 环通过"只登记出边"藏起来。 同一条边在两个数据结构里的存在性不对称(edges.ts:755registerIncoming),换来"就绪判定可以简单地数入边"。

③ 校验逻辑单点化,lint 和执行不可能漂移。 collectBlockFieldIssues(serializer/index.ts:630)是不抛异常的分析函数,serializeBlock 包一层抛错做执行的真值,copilot 的工作流 lint 直接消费结构化结果。同理 extractBlockParams(serializer/index.ts:489)被导出,注释明说"让 copilot lint 用和执行完全一样的方式解析参数"。


8. 多人协作:这些表行怎么被写回

编辑器的每一次改动都不是"保存整份工作流",而是发一个细粒度操作apps/realtime 这个独立的 Bun + Socket.IO 服务。整条写回链:

浏览器 ──socket 'workflow-operation'──► operations.ts

① 校验 schema / 房间 / 会话
② 权限:按角色 + DB 复核
③ 锁:assertWorkflowMutable

persistWorkflowOperation

一个 DB 事务,按 target 分派

workflow_blocks / edges / subflows 的行级 UPDATE

④ socket.to(workflowId).emit 广播给同房间其他人

入口 setupOperationsHandlers(apps/realtime/src/handlers/operations.ts:24)监听 workflow-operation,先用 WorkflowOperationSchema 校验载荷。有一条针对拖拽的特殊处理:未提交的位置更新(commit !== true)先广播后返回、完全不落库,只有 commit: true 那一次才持久化(operations.ts:99-101:191)——拖动过程要跟手,松手才写盘。

权限 checkWorkflowOperationPermission(apps/realtime/src/middleware/permissions.ts:359)把操作按角色分三档:admin 独占的批量锁定、write 的全部编辑操作、read 只能发位置更新(permissions.ts:20:23:57)。关键在于它不信任加入房间时缓存的角色,而是通过 resolveCurrentWorkflowRole(permissions.ts:130)每 30 秒回查一次数据库(ROLE_REVALIDATION_TTL_MS),让被撤权的协作者在不断连的情况下也会失去写权限。DB 抖动时优先复用上次记录的判定(包括"已撤权"这个判定),而不是回退到加入时的旧角色。

落库 persistWorkflowOperation(apps/realtime/src/database/operations.ts:375)开一个事务,先更新 workflow.updatedAt,再按 target 分派给八个 handler(block / blocks / edge / edges / subflow / subblock / variable / workflow)。举两个有代表性的:

  • UPDATE_CANONICAL_MODE(database/operations.ts:777)——把用户在 UI 上切的 basic/advanced 选择写进 workflow_blocks.data.canonicalModes。这正是 §4.2 里 resolveCanonicalMode 读的那个"显式覆盖"。
  • updateSubflowNodeList(database/operations.ts:290)——块的 data.parentId 一变,就用 jsonb 查询找出该容器的全部子块,重写 workflow_subflows.config.nodes容器的成员表是派生数据,由服务端从子块反算,不由客户端直接写。

房间状态 在 Redis 里,RedisRoomManager(apps/realtime/src/rooms/redis-manager.ts:130)用键空间管在线用户与会话(redis-manager.ts:25KEYS)。清理与活跃度更新各走一段 Lua 脚本原子完成(redis-manager.ts:54REMOVE_ROOM_SCRIPT:90UPDATE_ACTIVITY_SCRIPT,经 scriptLoad 预载 + evalSha 执行、带 NOSCRIPT 重试,:179-180:257-260):删用户、删 socket 映射(仅当映射确实指向本房间,防误清别的房间),若房间已空就把房间键一并删掉——避免"检查再删除"之间的竞态。


9. 边界与局限

  • 序列化期不解析变量。 <Block.output> 这类引用在 params 里还是字面字符串,要到执行期才解析(见 03 章)。所以 tools.config.tool 里做类型转换会毁掉引用。
  • 并行分支数编译期不可知,运行期也没有上限。 编译期只放一个 ₍0₎ 模板节点(nodes.ts:93),真实分支数要运行期按集合长度克隆;ParallelOrchestrator.resolveBranchCount(apps/sim/executor/orchestrators/parallel.ts:202-218)对 config.countitems.length 不做任何 clamp。被夹在 1–20 的是每批同时跑几个分支——UI 侧 MAX_PARALLEL_BATCH_SIZE(apps/sim/stores/workflows/workflow/utils.ts:9)、执行期 DEFAULTS.MAX_PARALLEL_BRANCHES(apps/sim/executor/constants.ts:179,只在 resolveBatchSize,parallel.ts:254-261 里用到)。别把这个 20 读成"并行最多 20 路"。
  • 跨越循环边界的连线被静默丢弃(edges.ts:284-286),画布上并不会因此报错。
  • validateSubflowsBeforeExecution 是空实现(serializer/index.ts:212-219):空集合的 forEach 循环不在构建期报错,留给运行期跳过。
  • 调试模式没实现。 DAGExecutor.continueExecution(apps/sim/executor/execution/executor.ts:107-124)直接返回失败并打 warn。
  • 角色撤销有最长 30 秒的窗口(permissions.tsROLE_REVALIDATION_TTL_MS),这是刻意的性能取舍,注释里写明了。

10. 横向对比:同一件事,四种画布→图的取舍

本章讲的"画布怎么变成可执行结构",是每个工作流构建器都绕不开的一步。同 shelf 的兄弟项目给了四种不同答案,差别集中在循环与分支放在哪一层解决:

项目画布在代码里是什么循环/分支怎么表达代价
Sim(本章)三张规范化表 → SerializedWorkflow → 带哨兵的 DAG编译期展开sentinel_start / sentinel_end 一对假节点,和普通块同图节点 ID 要编码运行期身份(₍0₎ / __obranch-N),编解码规则复杂
Activepieces一棵递归 JSON 树,根本没有边表——连接关系编码进 nextAction / children / firstLoopAction 的嵌套循环就是树里的一个 LOOP_ON_ITEMS 节点,firstLoopAction 挂一条子链表要画成图得靠布局算法把树翻译成坐标
Langflow{nodes, edges} JSON → Graph(Vertex + Edge)不消除环:保留回边,用强连通分量识别环,给环上顶点一套单独的放行判据调度器要维护两套并存的剪枝状态机
Dify画布只产出 nodes / edges 两个数组,存成 workflows 表一行iteration / loop 节点在运行期另起一个子引擎(build_child_engine)调度内核在闭源包 graphon 里,子图策略从外部读不到

提炼出的分歧点: 控制结构到底在编译期消掉(Sim)、在数据结构里天然嵌套(Activepieces)、在调度器里特判(Langflow),还是在运行期换一台引擎(Dify)。Sim 这条路的独特收益是运行时只需理解"节点 + 边"一种结构——嵌套循环套并行不需要任何额外机制,代价全部转移到了节点 ID 的命名协议上(§6.5、§7①)。

接着读本组: 这张 DAG 怎么被跑起来见 02 调度引擎;哨兵在运行期的迭代语义与 <块.字段> 解析见 03 子流程与变量;单个 Agent 块内部见 04 Agent / Provider / 工具;savedIncomingEdges 从哪来见 05 暂停、恢复与触发;AI 怎么用 edit_workflow 改这些表行见 06 Copilot;全局导览见 index


11. 代码地图

主题文件路径关键符号
数据库四张表packages/db/schema.tsworkflowworkflowBlocksworkflowEdgesworkflowSubflowsworkflowDeploymentVersion
表行 → 内存对象packages/workflow-persistence/src/load.tsloadWorkflowFromNormalizedTablesRaw
subblock 值合并packages/workflow-persistence/src/subblocks.tsmergeSubblockStateWithValuesmergeSubBlockValues
块定义类型apps/sim/blocks/types.tsBlockConfigSubBlockConfigParamConfigBlockCategoryIntegrationType
块注册表apps/sim/blocks/registry-maps.tsBLOCK_REGISTRYBLOCK_META_REGISTRY
注册表访问器apps/sim/blocks/registry.tsgetBlockgetLatestBlockgetBlocksByCategory
样本块apps/sim/blocks/blocks/{agent,condition,api_trigger}.tsAgentBlockConditionBlockApiTriggerBlock
字段可见性 / canonical 对apps/sim/lib/workflows/subblocks/visibility.tsbuildCanonicalIndexresolveCanonicalModeevaluateSubBlockConditionisSubBlockHiddenisCanonicalPairgetCanonicalValues
序列化器apps/sim/serializer/index.tsSerializershouldSerializeSubBlockextractBlockParamsselectToolIdcollectBlockFieldIssuesWorkflowValidationError
序列化结构apps/sim/serializer/types.tsSerializedWorkflowSerializedBlockSerializedLoopSerializedParallel
容器块抽取apps/sim/stores/workflows/workflow/utils.tsgenerateLoopBlocksgenerateParallelBlocksfindChildNodesclampParallelBatchSizeMAX_PARALLEL_BATCH_SIZE
图编译入口apps/sim/executor/dag/builder.tsDAGBuilderDAGDAGNodeinitializeConfigsvalidateSubflowStructure
图边与元数据类型apps/sim/executor/dag/types.tsDAGEdgeNodeMetadata
可达性剪枝apps/sim/executor/dag/construction/paths.tsPathConstructorfindExplicitTriggerperformBFS
哨兵构造apps/sim/executor/dag/construction/{loops,parallels,sentinels}.tsLoopConstructorParallelConstructorcreateSubflowSentinelNode
节点构造apps/sim/executor/dag/construction/nodes.tsNodeConstructorcreateParallelTemplateNodefindLoopIdForBlock
边构造apps/sim/executor/dag/construction/edges.tsEdgeConstructorwireRegularEdgeswireLoopSentinelswireParallelSentinelsgenerateSourceHandleaddSubflowStartExitBypassaddEdge
节点 ID 编码apps/sim/executor/utils/subflow-utils.tssubflow-node-id-codec.tsbuildSentinelStartIdbuildParallelSentinelStartIdbuildBranchNodeIdnormalizeNodeIdSubflowNodeIdCodec
执行常量apps/sim/executor/constants.tsEDGELOOPPARALLELMETADATA_ONLY_BLOCK_TYPESisMetadataOnlyBlockTypeDEFAULTS
协作:操作入口apps/realtime/src/handlers/operations.tssetupOperationsHandlers
协作:落库apps/realtime/src/database/operations.tspersistWorkflowOperationupdateSubflowNodeListgetWorkflowState
协作:权限apps/realtime/src/middleware/permissions.tscheckWorkflowOperationPermissionresolveCurrentWorkflowRoleROLE_PERMISSIONS
协作:房间状态apps/realtime/src/rooms/redis-manager.tsRedisRoomManagerKEYSREMOVE_ROOM_SCRIPTUPDATE_ACTIVITY_SCRIPT