跳到主要内容

02 · 操作引擎:一切编辑都是纯函数 reducer

本章是整个构建器的心脏。讲清楚:编辑流的唯一通道是什么、那个 reducer 怎么写、它靠哪个递归遍历器干活、复杂操作如何被拆成简单操作。

2.1 它要解决的小问题

树是嵌套的,直接"在第 3 层第 2 个分支里插一个节点"很容易写出深一脚浅一脚、改坏别处的代码。Activepieces 的答案是:你不许直接改树。你只能提交一个描述意图的对象(FlowOperationRequest),由一个纯函数负责把意图变成一棵新树。

2.2 编辑的唯一词汇:FlowOperationRequest

所有可能的编辑被收敛成一个枚举 FlowOperationType(operations/index.ts:28-55),约 30 种。常见的几种:

操作含义
ADD_ACTION在某父步骤的某位置插入一个 action
UPDATE_ACTION / UPDATE_TRIGGER改某步骤/触发器的设置
DELETE_ACTION删一个或多个 action(并重新缝合链表)
MOVE_ACTION把一个 action 搬到别处
DUPLICATE_ACTION复制一个 action 子树
ADD_BRANCH / DELETE_BRANCH / MOVE_BRANCHrouter 分支增删移
IMPORT_FLOW用一整棵 trigger 树替换当前流
CHANGE_NAME / LOCK_FLOW / SET_SKIP_ACTION元数据类

每个操作是 { type, request },request 的形状由对应的 zod schema 校验(整个联合在 operations/index.ts:217-322FlowOperationRequest)。

"插在哪"由一个枚举精确表达 —— StepLocationRelativeToParent(operations/index.ts:106-112):

插到父步骤的哪里
AFTER紧跟在父步骤后面(主干)
INSIDE_LOOP作为循环体的第一步(firstLoopAction)
INSIDE_BRANCH作为某个 router 分支的第一步(配 branchIndex)
INSIDE_ON_SUCCESS_BRANCH / INSIDE_ON_FAILURE_BRANCH作为失败分支子树的头

这个枚举 + parentStep(父步骤名) + 可选 branchIndex,就能唯一定位树里任何一个插入点。

2.3 reducer:flowOperations.apply

核心入口是 flowOperations.apply(flowVersion, operation)(operations/index.ts:331)。它的骨架:

// 真实源码节选 operations/index.ts:331-432 —— 重点看深拷贝 + switch + 末尾重算 valid
apply(flowVersion, operation) {
let clonedVersion = JSON.parse(JSON.stringify(flowVersion)) // ① 永不原地改
switch (operation.type) {
case ADD_ACTION: clonedVersion = _addAction(clonedVersion, operation.request); break
case DELETE_ACTION: clonedVersion = _deleteAction(clonedVersion, operation.request); break
case MOVE_ACTION: { // ② 宏:展开成多步
const ops = _moveAction(clonedVersion, operation.request)
ops.forEach(op => { clonedVersion = flowOperations.apply(clonedVersion, op) })
break
}
// … 其余 case
}
clonedVersion.valid = getAllSteps(clonedVersion.trigger) // ③ 每次都重算整流是否合法
.every(step => step.valid || isSkipped(step))
return clonedVersion
}

三个要点:

  1. 永不原地改。 第一行就深拷贝,所有 _xxx 帮手在拷贝上操作,返回新树。这让乐观更新和回滚都简单(前端持有旧树即可回退)。
  2. 宏操作自递归。 MOVE_ACTIONDUPLICATE_ACTIONDUPLICATE_BRANCHIMPORT_FLOWUSE_AS_DRAFT 这些复杂操作,不自己手写树变换,而是生成一串基本操作,再 forEachapply 依次落地(operations/index.ts:334-339 等)。
  3. valid 是派生的,每次重算。 末尾 getAllSteps(...).every(...)(operations/index.ts:428-431)遍历全树,只要有一步不合法且没被 skip,整条流就 valid: false。这驱动 UI 上的"发布"按钮能不能点。

心智模型: apply 就是 Redux 里的 reducer——(state, action) => newState,纯函数、不可变。只不过这里的 state 是一棵工作流树。

2.4 干活的递归遍历器:transferFlow / transferStep

几乎所有树变换都不手写递归,而是复用一个通用遍历器 transferStep(util/flow-structure-util.ts:79-132)。它接收"对单个步骤做什么"的函数,自己负责把这个函数递归地作用到树里每一个节点(包括 loop 体、router 各分支、失败分支、以及 nextAction)。

// 示意,非源码:transferStep 的本质
function transferStep(step, fn) {
step = fn(step) // 先变换当前节点
if (step.type === LOOP) step.firstLoopAction = transferStep(step.firstLoopAction, fn)
if (step.type === ROUTER) step.children = step.children.map(c => c && transferStep(c, fn))
// …失败分支 onSuccess/onFailure 同理…
if (step.nextAction) step.nextAction = transferStep(step.nextAction, fn)
return step
}

transferFlow(flow-structure-util.ts:135-145)是它在整条流上的封装:深拷贝后从 trigger 开始 transfer。

这个抽象的威力: 很多看似不同的功能,其实是"给 transfer 喂不同的函数":

功能喂给 transfer 的函数干什么
getAllSteps(列出所有步骤)把每个步骤 push 进数组,原样返回(flow-structure-util.ts:147-154)
_addAction(加步骤)命中父步骤时,在对应位置插新节点(add-action.ts:138-162)
_deleteAction(删步骤)命中要删的节点的父节点时,把 nextAction 跳过它(delete-action.ts)
schema 迁移(改结构)把每个老结构节点改写成新结构(见 04 章 branch→router)

一个遍历器,服务"读"(getAllSteps)和"写"(增删改迁移)两类需求。

2.5 一个具体操作:_addAction

_addAction(operations/add-action.ts:138-162)如何用 transferFlow 实现"插入":

// 真实源码节选 add-action.ts:138-162 —— 用 transferFlow 找到父步骤再按位置插
function _addAction(flowVersion, request) {
return flowStructureUtil.transferFlow(flowVersion, (parentStep) => {
if (parentStep.name !== request.parentStep) return parentStep // 不是目标,原样返回
// 命中父步骤:按 stepLocationRelativeToParent 决定插哪
switch (parentStep.type) {
case LOOP_ON_ITEMS: return handleLoopOnItems(parentStep, request) // INSIDE_LOOP / AFTER
case ROUTER: return handleRouter(parentStep, request) // INSIDE_BRANCH / AFTER
default: // 普通步骤:只能 AFTER
parentStep.nextAction = createAction(request.action, { nextAction: parentStep.nextAction })
return parentStep
}
})
}

注意"链表插入"的经典写法:新节点的 nextAction 接上父步骤原来的 nextAction,父步骤再指向新节点——一次性把新节点缝进链中(add-action.ts:155-157)。loop/router 的处理(handleLoopOnItemshandleRouter,add-action.ts:69-112)是同样的缝法,只是缝到 firstLoopActionchildren[branchIndex]

2.6 宏操作:move = delete + add + re-import

最能体现"宏"思想的是 _moveAction(operations/move-action.ts:7-38)。"移动一个步骤"被拆成:

// 真实源码节选 move-action.ts —— 移动 = 先删、再在新位置加、再重导其子树
return [
{ type: DELETE_ACTION, request: { names: [request.name] } }, // ① 从老位置摘掉
{ type: ADD_ACTION, request: { action: sourceStepWithoutNextAction, // ② 加到新父步骤
parentStep: request.newParentStep, ... } },
..._getImportOperations(sourceStepWithoutNextAction), // ③ 把它的子步骤逐个重新接回
]

为什么这么绕?因为被移动的步骤可能自己带着一棵子树(它是个 loop 或 router)。先把它"裸搬"过去(去掉 nextAction),再用 _getImportOperations 把子树里的每个子步骤生成 ADD_ACTION 逐个接回新位置。apply 收到 MOVE_ACTION 后,就是把这串操作 forEach 依次跑一遍(operations/index.ts:334-339)。

好处: 复杂操作复用已被充分测试的基本操作,无需为每种复杂操作单独写一套易错的树手术。

2.7 复制时的隐藏难点:重命名引用

复制一个步骤不能只复制结构——步骤之间靠 {{step_1.output}} 这种mustache 引用互相取数据。复制后必须把引用里的旧步骤名改成新名,否则复制出来的步骤还指着原件。这由 addActionUtils.clone(operations/add-action-util.ts:50-79)处理:遍历 settings.input 里的字符串,把 {{ }} 里的旧名替换成新名。

这里藏着一个已知 bug 的诚实注释(add-action-util.ts:32-36):正则 /{{(.*?)}}/g 是惰性匹配,遇到内容里本身含 }} 的 token(如字符串字面量)会截断,导致尾部步骤名没被改名。注释明说"Swap deferred — needs duplicate/paste re-testing"——这是真实代码里待修的坑,不是我编的。

2.8 巧妙之处

  • 操作是数据,不是命令式代码。 一次编辑是个可序列化的 { type, request },所以它能:在前端本地 apply、序列化发给后端再 apply、被 MCP 工具生成、被测试用例直接构造。同一种"编辑"在四个场景复用一份语义。
  • 宏 = 操作的组合。 没有为 move/duplicate 单写树手术,而是"展开成基本操作 + 递归 apply"。新增一种复杂操作,往往只是写一个"返回操作数组"的函数。
  • 一个遍历器统治增删改读迁移。 transferStep 把"如何递归一棵带 loop/router/失败分支的树"这件麻烦事写一次,其余全是"喂不同的回调"。

2.9 代码地图

主题文件符号
操作类型枚举 + 请求联合 + reducerpackages/core/execution/src/lib/flows/operations/index.tsFlowOperationTypeFlowOperationRequestflowOperations(apply)
插入位置枚举packages/core/execution/src/lib/flows/operations/index.tsStepLocationRelativeToParent
递归遍历器packages/core/execution/src/lib/flows/util/flow-structure-util.tstransferSteptransferFlowgetAllSteps
加步骤packages/core/execution/src/lib/flows/operations/add-action.ts_addActionhandleLoopOnItemshandleRouter
删步骤(重缝链表)packages/core/execution/src/lib/flows/operations/delete-action.ts_deleteAction
移动(宏)packages/core/execution/src/lib/flows/operations/move-action.ts_moveAction
复制 + 引用重命名packages/core/execution/src/lib/flows/operations/add-action-util.tsaddActionUtils(clonemapToNewNamesreplaceOldStepNameWithNewOne)