squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
16 KiB
条件表达式引擎设计(R-PD-3 / T-260614-11)
来源:全局代码 review
docs/05-代码审查/全局代码review-2026-06-15.md§🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条 性质:设计文档(供用户核对方案),不含实现 关联:todoT-260614-11 条件表达式引擎升级、R-P2-13(set_skipped/set_waiting 已删)
一、现状
1.1 ConditionEngine 从未被接线
crates/df-workflow/src/conditions.rs:8 定义了 ConditionEngine::evaluate(expr, context) -> Result<bool>,全仓 grep 确认:除自身定义与单元测试外,零调用。executor 从不调它,build_dag 只把 EdgeDef.condition 原样写入 runtime Edge.condition(registry.rs:62-69),写入后无人消费。
1.2 executor 不区分条件边,无条件灌入前驱输出
executor.rs:62-97 构建 adjacency_in: target → Vec<source> 时丢掉 edge.condition,只保留 source/target;随后 inputs 收集处(executor.rs:91-97):
let mut inputs = HashMap::new();
if let Some(preds) = adjacency_in.get(node_id) {
for pred_id in preds {
if let Some(out) = outputs.get(pred_id) {
inputs.insert(pred_id.clone(), out.clone());
}
}
}
逐前驱无条件灌入。条件边与普通边行为完全相同。
1.3 topological_layers 把条件边计入入度
dag.rs:81-90 单次遍历边构建入度表,不检查 edge.condition。条件边 target 的入度照常 +1,BFS 分层照常把它放入某层。
1.4 复现:add_edge_with_condition("a","c","false") 实际 c 永远执行
构造 a → c(condition="false")的 DAG:
topological_layers:a 入度 0,c 入度 1;分层为[[a],[c]]- 第 0 层执行 a,输出存入
outputs["a"] - 第 1 层执行 c:
adjacency_in["c"] = ["a"],outputs["a"]存在 →inputs = {"a": <a 的输出>},c 照常execute ConditionEngine::evaluate("false", ...)本应返Ok(false),但从无调用点
即条件分支这一 DAG 核心能力整体失效,且对用户静默——前端编了条件边,运行结果与无条件等价,没有任何报错或告警。
二、根因
三个缺口叠加:
| 缺口 | 位置 | 表现 |
|---|---|---|
| ① expressions 仅 true/false 字面量 | conditions.rs:16-33 |
TODO 列了 JSON Path / 比较 / contains / and-or-not,全未实现;只认 "true"/"false" 两字面量 |
| ② executor 无 evaluate 调用 | executor.rs:91-97 |
inputs 收集不读 edge.condition,条件边与普通边行为相同 |
| ③ topological_layers 计入条件边入度 | dag.rs:81-90 |
条件边 target 的入度照常 +1,BFS 照常分层调度 |
review 第 49 行给的修复方向("executor inputs 收集处用 ConditionEngine.evaluate 过滤;topological_layers 前过滤无效边或执行时按条件短路 target 为 Skipped")指向 ②③,本文档补全 ①(表达式能力)与 ③ 短路后的终态机制(set_skipped 已删,需设计替代,见 §五)。
三、表达式引擎方案
3.1 现状能力
ConditionEngine::evaluate 当前支持:
"true"/"false"字面量(区分大小写,先trim()去首尾空白)- 其余一律
Ok(false)+ warn(保守拒绝,B-260614-02 已修,默认 true→false)
3.2 支持范围(目标语法)
工作流条件边的实际诉求是"据前驱节点输出决定下游是否执行"。最小可用集:
| 语法 | 示例 | 说明 |
|---|---|---|
| 字面量 | true / false |
已有,保留 |
| 前驱输出访问 | pred.output.status == 'completed' |
pred 为前驱节点 id,output 为其 NodeOutput 序列化后字段;多前驱时需指定哪个前驱 |
| 比较 | == != > >= < <= |
字符串等值 + 数值大小 |
| 包含 | pred.output.tags contains 'ai' |
数组包含 / 字符串子串 |
| 逻辑组合 | and or not |
括号分组 |
| 真值判断 | pred.output.flag |
布尔字段直接判真(无比较运算符) |
context: &Value 入参签名已就位(当前 _context 未用)。扩展时把当前节点所有前驱输出按 { "<pred_id>": <NodeOutput serde> } 拼成 context 传入即可。
3.3 引第三方 expr 库 vs 手写最小求值器
| 方案 | 优点 | 缺点 |
|---|---|---|
第三方 evalexpr |
成熟、支持算术/逻辑/函数/变量;API 简单(eval_with_context);MIT;crates.io 下载量稳定 |
新增依赖(df-workflow 当前 0 expr 库,workspace 也无);语义需对齐(其变量访问语法 $var vs 我们要的 pred.output.x 点路径);引入超出条件边需求的算术/函数能力,扩大攻击面(前端可构造任意表达式) |
第三方 jsonpath_lib + 自写比较 |
JSON Path 标准成熟,路径表达力强 | 仍需自写比较/逻辑层;两套语法拼装复杂度高于纯手写 |
| 手写最小递归下降求值器 | 零新依赖;语法完全自定(直接支持 pred.output.x);能力边界可控(拒绝算术/函数,只留比较+逻辑+包含);~150 行可覆盖 §3.2 全部语法 |
自负维护(但语法面小,测试可固化) |
取舍(推荐):手写最小求值器。
理由:
- 条件边诉求面窄(比较 + 逻辑 + 包含 + 前驱输出访问),不需要通用表达式语言的算术/函数能力。
- 前端可构造任意条件表达式(run_workflow IPC 接 DagDef),手写小语法面比引通用 expr 库的攻击面更可控——通用 expr 库默认支持函数调用/算术,需额外配置禁用。
- df-workflow 当前是零外部表达式依赖的薄 crate,引入
evalexpr对一个"条件分支"单一能力偏重。 - 若后续诉求扩张(如需要正则/数学函数),再评估切换第三方库,届时手写求值器的测试可作迁移回归基准。
决策点 A(需用户确认):表达式引擎走手写最小求值器,还是引
evalexpr?本文档默认推荐手写。若用户倾向引库,§五的接线方案不变,仅 §三的"实现"段替换。
四、接线方案
review 给了两种短路粒度,本文档详析:
4.1 方案一:数据流过滤(Phase1)
改动点:executor inputs 收集处(executor.rs:91-97)。
// 伪码
let mut inputs = HashMap::new();
if let Some(preds_with_cond) = adjacency_in.get(node_id) {
for (pred_id, cond_opt) in preds_with_cond {
if let Some(out) = outputs.get(pred_id) {
// 边有条件 → 求值;条件 false 则不灌入此条边的数据
if let Some(cond) = cond_opt {
let ctx = json!({ pred_id: out }); // 单前驱上下文
match ConditionEngine::evaluate(cond, &ctx) {
Ok(true) => { inputs.insert(pred_id.clone(), out.clone()); }
Ok(false) => { /* 跳过此边,不灌入 */ }
Err(e) => { /* 求值失败兜底,见 §六 */ }
}
} else {
inputs.insert(pred_id.clone(), out.clone());
}
}
}
}
需配套:adjacency_in 的 value 从 Vec<NodeId> 改为 Vec<(NodeId, Option<String>)>,携带 edge.condition(构建处 executor.rs:62-68 同步改)。
语义:target 照常执行,只是某些前驱的数据不灌入。适合"多前驱汇聚、按条件选择部分输入"的场景。
局限:target 仍被执行。若用户意图是"a 条件不满足时 c 整个不跑"(单条件边、target 唯一前驱),数据流过滤做不到——target 会以空 inputs 执行,语义错位。
4.2 方案二:调度短路(Phase2)
改动点:层调度处(executor.rs:70-119 的 for 循环)。
执行某层前,对层内每个 target 检查:若所有入边条件求值均为 false(或其唯一条件边为 false),则 target 标记"条件跳过"终态、不进入 node_futures、不发 NodeStarted。
关键约束:set_skipped 已删。R-P2-13 删了 set_waiting/set_skipped(全仓零调用,误导状态机认知),保留 set_cancelled 作"唯一受控旁路"。NodeStatus::Skipped 枚举值仍在(types.rs:243,as_str 能输出 "skipped"),但无 setter。调度短路需要一个"条件不满足、target 不执行"的终态,必须解决这个缺口。
4.3 两种短路粒度对比
| 维度 | 方案一 数据流过滤 | 方案二 调度短路 |
|---|---|---|
| target 是否执行 | 执行(部分输入被过滤) | 不执行(直接跳过) |
| 适用场景 | 多前驱汇聚、按条件选输入 | 单条件边、条件不满足则 target 整个不跑 |
| 用户意图匹配 | 部分 | 完整(用户编条件边的典型意图) |
| 终态机制 | 不需要(target 走 Completed) | 需要新终态(set_skipped 已删,见 §五) |
| 改动面 | inputs 收集 + adjacency_in 携带 condition | 层调度 + 终态机制 + 事件(NodeSkipped?) |
| 风险 | 低(数据流层面,不影响调度) | 中(动调度循环 + 状态机) |
五、推荐分阶段
Phase1(数据流过滤,先打通):
- 范围:§四.1,仅 inputs 收集处接线 ConditionEngine。
- 效果:多前驱汇聚场景立即可用;单条件边场景 target 仍执行(空 inputs),需文档标注"Phase1 已知局限"。
- 改动面小、零终态机制冲突、可独立 ship。
Phase2(调度短路,补完整语义):
- 范围:§四.2,层调度处短路 target。
- 前置:解决 set_skipped 删除后的终态机制——见下。
5.1 set_skipped 删除后的短路机制设计(Phase2 前置)
R-P2-13 删 set_skipped/set_waiting 时,"条件跳过"这一用例尚未接线(ConditionEngine 从未调用,无消费方),删除合理。现在 Phase2 要用"条件跳过"终态,三个选项:
| 选项 | 做法 | 取舍 |
|---|---|---|
| A. 复活 set_skipped 旁路 | state.rs 加回 set_skipped,与 set_cancelled 同型(不经 transition 校验,直接置 NodeStatus::Skipped),注释说明"条件跳过专用,区别于 set_cancelled 的人工取消语义" |
最直接;但与 R-P2-13 删除动机("全仓零调用、误导状态机认知")冲突——需明确这是新用例落地后的复活,非反复 |
| B. 复用 set_cancelled | 条件短路也走 set_cancelled,target 终态为 Cancelled |
语义污染:Cancelled 现专指"人工审批取消",条件跳过混入会让 is_cancelled 判断与前端"取消"语义混乱。不推荐 |
| C. 走 transition 合法转换 | 扩 is_legal 加 (Running, Skipped),调度短路前先 set_running 再 transition(Skipped) |
走正门最干净,但需 target 先进 Running 再转 Skipped(两步),且 NodeStarted 已发→语义噪声(节点"启动后立即跳过")。或扩 (Pending, Skipped) 直接转换,但破坏"Pending 必经 Running"的不变量 |
推荐 A:复活 set_skipped 旁路,注释明确区分两种"非正常终态"语义:
set_cancelled:人工审批取消(外部 IPC 触发,节点可能已 Running)set_skipped:条件分支跳过(调度层求值条件为 false,target 从未进入 Running)
两者均不经 transition 校验(条件短路时 target 在 Pending 态,Pending→Skipped 走 transition 会被 is_legal 拒,与 set_cancelled 同理需旁路)。
决策点 B(需用户确认):Phase2 的条件跳过终态走选项 A(复活 set_skipped 旁路)?本文档默认推荐 A。若用户倾向 C(扩 transition 合法转换),需同步评估 NodeStarted/NodeSkipped 事件序列与"Pending 必经 Running"不变量的取舍。
5.2 Phase2 配套事件
df-core/events::WorkflowEvent 当前有 NodeStarted/NodeCompleted/NodeFailed。Phase2 条件短路需补 NodeSkipped { node_id, reason }(reason = 哪条边的条件为 false + 表达式),前端可据 reason 渲染"跳过原因",闭环"对用户非静默"(R-PD-3 原诉求)。
六、改动面 + 风险
6.1 改动面(行号基于当前 HEAD)
| Phase | 文件:行 | 改动 |
|---|---|---|
| 1 | crates/df-workflow/src/conditions.rs:16-33 |
evaluate 扩展为手写最小求值器(§三.3) |
| 1 | crates/df-workflow/src/executor.rs:62-68 |
adjacency_in value 改 Vec<(NodeId, Option<String>)>,携带 condition |
| 1 | crates/df-workflow/src/executor.rs:91-97 |
inputs 收集处调 ConditionEngine.evaluate,false 不灌入 |
| 2 | crates/df-workflow/src/executor.rs:70-119 |
层调度前求值各 target 入边条件,全 false 则短路 |
| 2 | crates/df-workflow/src/state.rs |
复活 set_skipped 旁路(选项 A) |
| 2 | crates/df-core/src/events.rs |
加 WorkflowEvent::NodeSkipped |
| 2 | crates/df-workflow/src/executor.rs |
短路时发 NodeSkipped + set_skipped,不进 node_futures |
6.2 风险
R1:多前驱 context 拼装。§四.1 伪码用 json!({ pred_id: out }) 单前驱上下文。多前驱汇聚时,条件表达式需访问哪个前驱?两种设计:
- (a) 表达式内显式写前驱 id:
a.output.status == 'ok'——context 拼成所有前驱{ "a":..., "b":... },求值器按a.output.x路径取值 - (b) target 的所有入边条件独立求值,各用单前驱上下文——不支持"跨前驱联合判断"
推荐 (a),context 拼全前驱,求值器路径访问。pred.output.xxx 在 review 第 49 行已示意。
R2:条件求值失败的兜底。求值出错(语法错 / 路径不存在 / 类型不匹配)时返 Err,executor 怎么处理?
| 选项 | 语义 | 取舍 |
|---|---|---|
| 默认 true | 求值失败 = 放行 | 与 ConditionEngine 当前的"未识别默认 false"(B-260614-02)相反,破坏保守拒绝原则。不推荐 |
| 默认 false | 求值失败 = 拒绝(条件边不灌入 / target 跳过) | 与 B-260614-02 的保守拒绝一致;但用户表达式写错时 target 静默不跑,需配套告警 |
| 报错中止工作流 | 求值失败 = 工作流 Failed | 最显式,但单个条件边语法错炸整条工作流,可能过激 |
推荐:默认 false + warn 日志 + Phase2 的 NodeSkipped.reason 透出表达式。条件边本质是"用户声明的过滤规则",规则写错应保守拒绝(不执行)而非放行,与现有保守拒绝原则对齐;非静默靠 warn + reason 闭环。区别于 B-260614-02 的"引擎未实现"(那是 TODO 完全未做),这里是"用户表达式语法错"——两者都走 false,但 warn 文案区分。
决策点 C(需用户确认):条件求值失败兜底走"默认 false + warn"(推荐)还是"报错中止工作流"?
R3:topological_layers 计入条件边入度的交互。Phase1 数据流过滤不动 topological_layers,条件边仍计入入度、target 仍分层调度——这与"条件边语义"不冲突(Phase1 只过滤数据,不拦调度)。Phase2 调度短路有两种实现路径:
- (a) topological_layers 内部按条件过滤无效边(需把前驱输出传进 topological_layers,但分层时前驱尚未执行,条件无法求值)——不可行,条件依赖运行时输出
- (b) 分层照常(含条件边入度),执行时层调度前求值条件短路 target——可行,条件求值发生在前驱已完成、target 将执行的边界
推荐 (b)。topological_layers 保持纯结构(不掺运行时),条件求值在 executor 调度边界。
R4:循环依赖风险。条件边若构成 target 的所有入边均条件 false,target 永不执行。这是用户 DAG 的逻辑,引擎照常短路即可,不需特殊处理(与"用户写了死代码节点"同类)。
R5:与 R-PD-2(ScriptNode 任意 shell)的边界。条件表达式本身不经 shell,纯内存求值,无 R-PD-2 的 shell 注入面。但若 Phase2 引第三方 expr 库(§三.3 若用户改选 evalexpr),其函数调用能力需配置禁用(前端可构造任意表达式)。
七、待用户确认的决策点
| # | 决策 | 推荐 | 备选 |
|---|---|---|---|
| A | 表达式引擎实现 | 手写最小求值器(零依赖、语法可控) | 引 evalexpr(成熟、能力全、攻击面大) |
| B | Phase2 条件跳过终态机制 | 复活 set_skipped 旁路(与 set_cancelled 同型,语义区分) |
扩 is_legal 走 transition 正门(破坏 Pending→Running 不变量) |
| C | 条件求值失败兜底 | 默认 false + warn + NodeSkipped.reason 透出 | 报错中止整条工作流 |
确认后即可按 §五分阶段推进:Phase1(数据流过滤)独立可 ship,Phase2(调度短路 + 终态机制)依赖决策点 B。