Files
DevFlow/docs/02-架构设计/条件表达式引擎-2026-06-15.md
绝尘 04032a2a8d 重构: 文档汇总+进度看板+孤儿任务清理脚本+gitignore 噪音排除
- docs/02 架构设计: 新增 aichat审查/异步审批构想/流式渲染调研/generating状态机/密钥迁移健壮性/工作流脚本执行边界/条件表达式引擎/F-07 trait下沉/Agent架构说明/任务推进构想/功能创意池;更新功能决策记录+归档/对抗论证/文档记录规范/经验记录
- docs/03 模块文档: 新增 AI对话引擎/DAG引擎详解;更新 df-knowledge/df-nodes/df-storage/df-workflow/df-ai
- docs/05 代码审查: 新增 全栈审查/全局review/架构审查/近期改动审查/工作区多角度走查/自研memo流式渲染审查
- docs/09 问题排查: 新增 aichat-apikey-401
- docs/INDEX+README 索引同步;docs/todo 待办看板(2026-06-15 汇总)
- PROGRESS.md Sprint 22-25;URGENT.md 加急清单快照(5 项 P0 已全修)
- scripts/cleanup_orphan_tasks.{py,sh} 孤儿任务清理工具
- .gitignore 补 *.broken.bak + tmp/ 噪音排除
2026-06-15 05:14:21 +08:00

16 KiB
Raw Blame History

条件表达式引擎设计R-PD-3 / T-260614-11

来源:全局代码 review docs/05-代码审查/全局代码review-2026-06-15.md §🔴 P1 需设计 R-PD-3 + 架构洞察第 4 条 性质:设计文档(供用户核对方案),不含实现 关联todo T-260614-11 条件表达式引擎升级、R-P2-13set_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.conditionregistry.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 的入度照常 +1BFS 分层照常把它放入某层。

1.4 复现:add_edge_with_condition("a","c","false") 实际 c 永远执行

构造 a → ccondition="false")的 DAG

  • topological_layersa 入度 0c 入度 1分层为 [[a],[c]]
  • 第 0 层执行 a输出存入 outputs["a"]
  • 第 1 层执行 cadjacency_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 的入度照常 +1BFS 照常分层调度

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 为前驱节点 idoutput 为其 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_contextMITcrates.io 下载量稳定 新增依赖df-workflow 当前 0 expr 库workspace 也无);语义需对齐(其变量访问语法 $var vs 我们要的 pred.output.x 点路径);引入超出条件边需求的算术/函数能力,扩大攻击面(前端可构造任意表达式)
第三方 jsonpath_lib + 自写比较 JSON Path 标准成熟,路径表达力强 仍需自写比较/逻辑层;两套语法拼装复杂度高于纯手写
手写最小递归下降求值器 零新依赖;语法完全自定(直接支持 pred.output.x);能力边界可控(拒绝算术/函数,只留比较+逻辑+包含);~150 行可覆盖 §3.2 全部语法 自负维护(但语法面小,测试可固化)

取舍(推荐):手写最小求值器

理由:

  1. 条件边诉求面窄(比较 + 逻辑 + 包含 + 前驱输出访问),不需要通用表达式语言的算术/函数能力。
  2. 前端可构造任意条件表达式run_workflow IPC 接 DagDef手写小语法面比引通用 expr 库的攻击面更可控——通用 expr 库默认支持函数调用/算术,需额外配置禁用。
  3. df-workflow 当前是零外部表达式依赖的薄 crate引入 evalexpr 对一个"条件分支"单一能力偏重。
  4. 若后续诉求扩张(如需要正则/数学函数),再评估切换第三方库,届时手写求值器的测试可作迁移回归基准。

决策点 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:243as_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_cancelledtarget 终态为 Cancelled 语义污染Cancelled 现专指"人工审批取消",条件跳过混入会让 is_cancelled 判断与前端"取消"语义混乱。不推荐
C. 走 transition 合法转换 is_legal(Running, Skipped),调度短路前先 set_runningtransition(Skipped) 走正门最干净,但需 target 先进 Running 再转 Skipped两步且 NodeStarted 已发→语义噪声(节点"启动后立即跳过")。或扩 (Pending, Skipped) 直接转换,但破坏"Pending 必经 Running"的不变量

推荐 A:复活 set_skipped 旁路,注释明确区分两种"非正常终态"语义:

  • set_cancelled:人工审批取消(外部 IPC 触发,节点可能已 Running
  • set_skipped:条件分支跳过(调度层求值条件为 falsetarget 从未进入 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.evaluatefalse 不灌入
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) 表达式内显式写前驱 ida.output.status == 'ok'——context 拼成所有前驱 { "a":..., "b":... },求值器按 a.output.x 路径取值
  • (b) target 的所有入边条件独立求值,各用单前驱上下文——不支持"跨前驱联合判断"

推荐 (a)context 拼全前驱,求值器路径访问。pred.output.xxx 在 review 第 49 行已示意。

R2条件求值失败的兜底。求值出错(语法错 / 路径不存在 / 类型不匹配)时返 Errexecutor 怎么处理?

选项 语义 取舍
默认 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"(推荐)还是"报错中止工作流"

R3topological_layers 计入条件边入度的交互。Phase1 数据流过滤不动 topological_layers条件边仍计入入度、target 仍分层调度——这与"条件边语义"不冲突Phase1 只过滤数据不拦调度。Phase2 调度短路有两种实现路径:

  • (a) topological_layers 内部按条件过滤无效边(需把前驱输出传进 topological_layers但分层时前驱尚未执行条件无法求值——不可行,条件依赖运行时输出
  • (b) 分层照常(含条件边入度),执行时层调度前求值条件短路 target——可行条件求值发生在前驱已完成、target 将执行的边界

推荐 (b)。topological_layers 保持纯结构(不掺运行时),条件求值在 executor 调度边界。

R4循环依赖风险。条件边若构成 target 的所有入边均条件 falsetarget 永不执行。这是用户 DAG 的逻辑,引擎照常短路即可,不需特殊处理(与"用户写了死代码节点"同类)。

R5与 R-PD-2ScriptNode 任意 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数据流过滤独立可 shipPhase2调度短路 + 终态机制)依赖决策点 B。