跳到主要内容

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/TSsettings.sourceCode(packageJson + code)
LOOP_ON_ITEMS对一个数组逐项循环firstLoopAction(循环体子树)
ROUTER条件分支(if/else、switch)children[](每个分支一棵子树)

最简单的情况——一条没有循环没有分支的直线流——就是一条单向链表:

trigger ──nextAction──▶ step_1 ──nextAction──▶ step_2 ──nextAction──▶ (undefined 结束)

每个节点用 nextAction? 指下一个,最后一个的 nextActionundefined

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)

PIECECODE 这两类"叶子动作"还能挂一对失败处理子树(actions/action.ts:50-53):

// 示意,非源码
type ContinueOnFailureBranches = {
onSuccess?: FlowAction // 这步成功后走这条子链
onFailure?: FlowAction // 这步失败后走这条子链
}

它由 settings.errorHandlingOptions.continueOnFailure.value 开关控制(见 flow-canvas-util.ts:172-177hasContinueOnFailureBranches)。所以一个 piece 步骤可以同时有:nextAction(正常往下)、continueOnFailureBranches.onSuccesscontinueOnFailureBranches.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.tsFlowVersionLATEST_FLOW_SCHEMA_VERSIONFlowVersionState
Trigger 联合 + 公共字段packages/core/execution/src/lib/flows/triggers/trigger.tsFlowTriggerEmptyTriggerPieceTriggerFlowTriggerType
Action 联合 + 四种类型packages/core/execution/src/lib/flows/actions/action.tsFlowActionFlowActionTypeRouterActionLoopOnItemsAction
失败分支packages/core/execution/src/lib/flows/actions/action.tsContinueOnFailureBranches
分支条件 + 运算符packages/core/execution/src/lib/flows/actions/action.tsRouterBranchesSchemaBranchOperatorBranchExecutionTypeRouterExecutionType