01 · 数据模型:工作流是一棵递归树
本章讲清楚"一条工作流在代码里到底是什么形状"。读完你能凭记忆画出这棵树,并知道每个字段的意义。
1.1 最外层:FlowVersion
一条工作流不是直接被编辑的——被编辑的是它的某个版本(FlowVersion)。一条流(Flow)有多个版本,通常一个 DRAFT(草稿,可改)和若干 LOCKED(已锁定/已发布,只读)。
FlowVersion 的核心字段很少:
// 示意,非源码:抓住要点
FlowVersion = {
id, flowId, displayName,
trigger: FlowTrigger, // 唯一入口,整棵树的根
valid: boolean, // 整条流是否合法(每步都 valid)
schemaVersion: string, // 结构 schema 版本号,用于迁移(见 04 章)
state: 'DRAFT' | 'LOCKED',
connectionIds: string[], // 从树里抽出来的、用到的连接
agentIds: string[], // 从树里抽出来的、用到的 AI agent
notes: Note[], // 画布上的便利贴
}
真实定义在 packages/core/execution/src/lib/flows/flow-version.ts:14-27(FlowVersion zod schema)。注意 LATEST_FLOW_SCHEMA_VERSION = '22'(flow-version.ts:7)——这个常量是迁移系统的锚点。
关键观察: FlowVersion 里没有"步骤数 组"也没有"边数组"。整条流的全部步骤都藏在 trigger 这一个字段的递归嵌套里。connectionIds / agentIds 是派生字段——每次保存时从树里重新抽出来(见 flowStructureUtil.extractConnectionIds,flow-structure-util.ts:255)。
1.2 唯一入口:FlowTrigger
每条流有且仅有一个 trigger,它就是树根。trigger 只有两种(triggers/trigger.ts:28-31):
| 类型 | 含义 |
|---|---|
EMPTY | 占位:新建的空流,还没选触发器 |
PIECE_TRIGGER | 真触发器:绑定某个 piece 的某个 trigger(如 Gmail 的"新邮件") |
两者都带一组公共字段(triggers/trigger.ts:33-39),其中最重要的是 nextAction:
// 示意,非源码
commonProps = {
name, // 唯一步骤名,如 'trigger' / 'step_1',受 STEP_NAME_REGEX 约束
displayName,
valid,
nextAction?, // ← 指向第一个 action;整条链从这里开始
lastUpdatedDate,
}
所以 trigger 既是"什么时候开始",也是"链表的头节点"。
1.3 主干:FlowAction 链表
Action 有四种类型(actions/action.ts:7-12):
| 类型 | 干什么 | 特有字段 |
|---|---|---|
PIECE | 调一个 piece 的 action(发 Slack、写 Sheet) | settings.pieceName / actionName / input |
CODE | 跑一段用户写的 JS/TS | settings.sourceCode(packageJson + code) |
LOOP_ON_ITEMS | 对一个数组逐项循环 | firstLoopAction(循环体子树) |
ROUTER | 条件分支(if/else、switch) | children[](每个分支一棵子树) |
最简单的情况——一条没有循环没有分支的直线流——就是一条单向链表:
trigger ──nextAction──▶ step_1 ──nextAction──▶ step_2 ──nextAction──▶ (undefined 结束)
每个节点用 nextAction? 指下一个,最后一个的 nextAction 是 undefined。
1.4 让链表变成树:loop 和 router
两种 action 通过"内嵌子树"把链表撑成树。
LOOP(循环) 用 firstLoopAction 挂一条子 链表作为循环体;nextAction 仍然指循环结束后继续走的步骤:
┌─ firstLoopAction ─▶ (循环体: child_1 ─▶ child_2 ─▶ …)
loop ──┤
└─ nextAction ──────▶ (循环结束后继续: step_after_loop ─▶ …)
ROUTER(路由) 用 children[] 数组,每个元素是一个分支的头节点(或 null 表示空分支):
children[0] ─▶ (分支1: …)
router ──┤ children[1] ─▶ (分支2: …)
│ └ children[2] ─▶ null (空分支)
└─ nextAction ─────────▶ (所有分支汇合后继续: …)
这棵树为什么不会无限套自己把 TypeScript 编译器搞爆?因为类型是手写的递归联合,而不是 z.infer 自动推导。看 actions/action.ts:341-345:
// 真实源码节选 actions/action.ts:341-345 —— FlowAction 联合类型
export type FlowAction =
| (BaseActionProps & { type: FlowActionType.CODE, ..., nextAction?: FlowAction, continueOnFailureBranches?: ContinueOnFailureBranches })
| (BaseActionProps & { type: FlowActionType.PIECE, ..., nextAction?: FlowAction, continueOnFailureBranches?: ContinueOnFailureBranches })
| (BaseActionProps & { type: FlowActionType.LOOP_ON_ITEMS, ..., nextAction?: FlowAction, firstLoopAction?: FlowAction })
| (BaseActionProps & { type: FlowActionType.ROUTER, ..., nextAction?: FlowAction, children: (FlowAction | null)[] })
旁边的注释点破了原因(actions/action.ts:332):"Manually defined to avoid z.infer in recursive types (causes TypeScript OOM)" —— zod 的 z.lazy schema(action.ts:290-312)用于运行时校验,但编译期类型是手写的,以免递归推导把编译器内存撑爆。这是一个真实踩坑后的妥协。
1.5 第三种分叉:失败分支(continueOnFailureBranches)
PIECE 和 CODE 这两类"叶子动作"还能挂一对失败处理子树(actions/action.ts:50-53):
// 示意,非源码
type ContinueOnFailureBranches = {
onSuccess?: FlowAction // 这步成功后走这条子链
onFailure?: FlowAction // 这步失败后走这条子链
}
它由 settings.errorHandlingOptions.continueOnFailure.value 开关控制(见 flow-canvas-util.ts:172-177 的 hasContinueOnFailureBranches)。所以一个 piece 步骤可以同时有:nextAction(正常往下)、continueOnFailureBranches.onSuccess、continueOnFailureBranches.onFailure —— 三个出口。
1.6 ROUTER 分支条件的数据结构
router 的"哪个分支命中"由 settings.branches 描述(actions/action.ts:258-277)。每个分支要么是 CONDITION(带条件),要么是 FALLBACK(兜底/otherwise):
// 示意,非源码:一个 CONDITION 分支
{
branchType: 'CONDITION',
branchName: 'Branch 1',
conditions: [[ cond_a, cond_b ], [ cond_c ]], // 外层 OR、内层 AND
}
注意 conditions 是二维数组:外层之间是 OR,内层之间是 AND(执行语义见 04 章 evaluateConditions)。条件的运算符是 BranchOperator 枚举(actions/action.ts:113-136),覆盖文本/数字/日期/列表/布尔/存在性,共 20+ 种。
executionType(actions/action.ts:14-17)决定 router 跑"第一个命中的分支"(EXECUTE_FIRST_MATCH,像 if/else)还是"所有命中的分支"(EXECUTE_ALL_MATCH)。
1.7 巧妙之处
- 结构即数据,无边表。 拖拽连线在很多构建器里要维护
nodes[]+edges[]两张表并保持一致;Activepieces 把连接关系编码进嵌套本身(nextAction/children/firstLoopAction)。复制一条流 = 一次JSON.parse(JSON.stringify(...)),导入导出 = 直接序列化。代价是"画成图"需要一个布局算法把树翻译成坐标(见 03 章)。 - 派生字段在保存时重算,不手维护。
connectionIds/agentIds每次applyOperation后从树里重新抽(flow-version.service.ts:103-104),杜绝"删了步骤 但连接列表还残留"的脏数据。 - 编译期与运行期分家。 运行期用 zod
z.lazy校验真实数据;编译期用手写联合类型——一句注释挡住了 TS OOM 这个真实的工程坑(actions/action.ts:332)。
1.8 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| FlowVersion schema + 最新版本号 | packages/core/execution/src/lib/flows/flow-version.ts | FlowVersion、LATEST_FLOW_SCHEMA_VERSION、FlowVersionState |
| Trigger 联合 + 公共字段 | packages/core/execution/src/lib/flows/triggers/trigger.ts | FlowTrigger、EmptyTrigger、PieceTrigger、FlowTriggerType |
| Action 联合 + 四种类型 | packages/core/execution/src/lib/flows/actions/action.ts | FlowAction、FlowActionType、RouterAction、LoopOnItemsAction |
| 失败分支 | packages/core/execution/src/lib/flows/actions/action.ts | ContinueOnFailureBranches |
| 分支条件 + 运算符 | packages/core/execution/src/lib/flows/actions/action.ts | RouterBranchesSchema、BranchOperator、BranchExecutionType、RouterExecutionType |