新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展 - 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通 - 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦 - AI planner/plan_hint/intent:aichat B 路线并行多轮基础 - patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环 - 压缩超时兜底(F-15 卡死根治) - F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本 - 知识注入 DRY/skills/audit 扩展 清理: - aichat 技术债(误报 allow/死导入/过时注释 30 项) - URGENT.md 删除(11 项加急全解决/迁 todo) - 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
@@ -3,6 +3,17 @@ name = "df-workflow"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
# ARC-260618-01-d 条件引擎接入执行器(默认关 = 旧行为零变更,渐进开启)
|
||||
#
|
||||
# 关闭时(默认):DagExecutor 完全忽略 edge.condition,按拓扑层全跑,
|
||||
# 与增强前行为完全一致(零行为破坏现有 HumanNode/AiNode 等所有调用方)。
|
||||
# 开启时:节点收集 inputs 前,对带 condition 的入边以 source output 为 context 求值;
|
||||
# 求值 false 则该前驱 input 不收集,且若该节点所有入边(含 condition)均被过滤,
|
||||
# 则跳过该节点执行(保持 Pending),实现条件路由。
|
||||
default = []
|
||||
conditions-eval = []
|
||||
|
||||
[dependencies]
|
||||
df-types = { path = "../df-types" }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
//! 条件表达式引擎 — 用于 DAG 边上的条件判断
|
||||
//!
|
||||
//! TODO: 实现完整的条件表达式解析与求值
|
||||
//! 支持的语法(由低到高优先级):
|
||||
//!
|
||||
//! - 逻辑或 `or`
|
||||
//! - 逻辑与 `and`
|
||||
//! - 逻辑非 `not`(一元前缀)
|
||||
//! - 括号分组 `( ... )`
|
||||
//! - 比较 `==` `!=` `>` `>=` `<` `<=` `contains`
|
||||
//! - 操作数:
|
||||
//! - 布尔字面量 `true` / `false`
|
||||
//! - 单引号字符串 `'completed'`(内部 `''` 转义为单引号)
|
||||
//! - 数字字面量 `10` / `-3.5`(支持负数、小数)
|
||||
//! - JSON Path `$.a.b` / `$.list[0]` / `$['key with space']`(从求值 context 取值)
|
||||
//!
|
||||
//! 安全约束:
|
||||
//! - **求值失败保守 false** —— 任何解析错误、JSON Path 找不到、类型不兼容,均返回 `Ok(false)`
|
||||
//! 而非 `Err`。条件分支写错或 context 缺字段时不应静默放行(默认 false = 保守拒绝)。
|
||||
//! `evaluate` 实现把所有错误路径(tokenize 失败、parse 失败)都吞成 `Ok(false)`,
|
||||
//! 永不返回 `Err`;`anyhow::Result<bool>` 签名仅作为未来扩展点保留(若日后需向调用方
|
||||
//! 区分「表达式非法」与「求值结果为 false」,可在此放开)。当前 executor 无需处理 `Err`。
|
||||
//! - `contains`:左值为字符串 → 子串匹配;左值为数组 → 元素存在性匹配;其余类型 → false。
|
||||
//! - `>` `<` `>=` `<=`:仅两端正数/负数/小数(数值)才比较;任一非数值 → false。
|
||||
//!
|
||||
//! 实现说明:手写递归下降解析器,无第三方依赖(jsonpath crate 引入会拉额外体积,
|
||||
//! 且当前需求仅需点路径 + 下标,手写更可控)。表达式按需惰性求值,短路 and/or。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -10,26 +33,468 @@ pub struct ConditionEngine;
|
||||
impl ConditionEngine {
|
||||
/// 求值条件表达式
|
||||
///
|
||||
/// 当前仅支持 "true"/"false" 字面量。未识别的表达式 **默认 false**(保守拒绝),
|
||||
/// 而非默认 true——条件分支写错或引擎未实现时不应该静默放行。
|
||||
/// TODO: 实现完整的表达式解析(JSON Path / 数值比较 / contains / 逻辑组合)。
|
||||
pub fn evaluate(expr: &str, _context: &Value) -> anyhow::Result<bool> {
|
||||
/// 详见模块级文档的语法与安全约束。**求值失败保守 false**:解析错误、JSON Path 缺失、
|
||||
/// 类型不兼容一律返回 `Ok(false)`,不静默放行(默认 false = 保守拒绝)。
|
||||
pub fn evaluate(expr: &str, context: &Value) -> anyhow::Result<bool> {
|
||||
let trimmed = expr.trim();
|
||||
if trimmed == "true" {
|
||||
return Ok(true);
|
||||
}
|
||||
if trimmed == "false" {
|
||||
if trimmed.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// TODO: 实现如下语法:
|
||||
// - "$.status == 'completed'" — JSON Path 比较
|
||||
// - "$.count > 10" — 数值比较
|
||||
// - "$.tags contains 'ai'" — 包含检查
|
||||
// - "and/or/not" — 逻辑组合
|
||||
let toks = match tokenize(trimmed) {
|
||||
Ok(t) => t,
|
||||
// tokenizer 阶段即非法(如未闭合引号):保守 false,不静默放行
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
let mut parser = Parser { toks, pos: 0, ctx: context };
|
||||
|
||||
tracing::warn!("条件表达式引擎尚未完整实现,表达式未识别默认 false: {}", expr);
|
||||
Ok(false)
|
||||
match parser.parse_or() {
|
||||
Ok(v) => Ok(v),
|
||||
// 解析错误保守 false:与历史行为「未识别表达式默认 false」一致,不破坏调用方
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON Path 求值 —— 手写,支持点路径 / 下标 / 单引号键
|
||||
// ============================================================================
|
||||
|
||||
/// 按手写 JSON Path 从 context 取值。
|
||||
///
|
||||
/// 支持语法:
|
||||
/// - `$.a.b` 点取字段
|
||||
/// - `$.a[0]` 数组下标(支持负数从尾部,-1 = 末尾)
|
||||
/// - `$['a b']` 单引号键(支持含空格/特殊字符的键名)
|
||||
///
|
||||
/// `path` 必须以 `$` 起头;解析失败或路径不存在返回 `None`(调用方据此保守 false)。
|
||||
fn eval_jsonpath(path: &str, context: &Value) -> Option<Value> {
|
||||
let p = path.strip_prefix('$')?;
|
||||
let bytes = p.as_bytes();
|
||||
// 全量 clone 而非按需借用:JSON Path 段数与深度运行时才确定,逐段借用需把
|
||||
// `&Value` 在循环中重绑(每段 as_object/as_array 返回的借用生命周期互相嵌套),
|
||||
// 借用检查极难通过;改用拥有的 Value 顺序覆盖,代码直观。代价是 context 顶层
|
||||
// 的一次深拷贝——条件求值在 DAG 边上触发,频率低且 context 通常不深,开销可忽略。
|
||||
let mut cur: Value = context.clone();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'.' => {
|
||||
i += 1;
|
||||
let (key, next) = consume_key(p, i)?;
|
||||
i = next;
|
||||
cur = cur.as_object().and_then(|m| m.get(&key).cloned())?;
|
||||
}
|
||||
b'[' => {
|
||||
i += 1;
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
if bytes[i] == b'\'' {
|
||||
let (key, next) = consume_quoted(p, i)?;
|
||||
i = next;
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
if i >= bytes.len() || bytes[i] != b']' {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
cur = cur.as_object().and_then(|m| m.get(&key).cloned())?;
|
||||
} else {
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b']' {
|
||||
i += 1;
|
||||
}
|
||||
let idx_str = p[start..i].trim();
|
||||
let idx: isize = idx_str.parse().ok()?;
|
||||
i += 1; // 跳过 ]
|
||||
let arr = cur.as_array()?;
|
||||
let len = arr.len() as isize;
|
||||
let real = if idx < 0 { len + idx } else { idx };
|
||||
if real < 0 || real >= len {
|
||||
return None;
|
||||
}
|
||||
cur = arr.get(real as usize).cloned()?;
|
||||
}
|
||||
}
|
||||
c if c.is_ascii_whitespace() => {
|
||||
i += 1;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// 取一段点路径的键(到下一个 `.`/`[`/行尾),返回 (key, 下一个待处理位置)。
|
||||
fn consume_key(s: &str, start: usize) -> Option<(String, usize)> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = start;
|
||||
while i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'[' {
|
||||
i += 1;
|
||||
}
|
||||
let key = s[start..i].trim();
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((key.to_string(), i))
|
||||
}
|
||||
|
||||
/// 取单引号字符串(起始位置指向开引号 `'`),内部 `''` 转义为单引号。
|
||||
/// 返回 (内容, 闭引号后位置)。
|
||||
///
|
||||
/// 实现要点:先扫描定位闭引号的字节位置,再对原始切片整体取出内容,而非逐字节
|
||||
/// `bytes[i] as char` 累加。原因:单引号 `'` 是 ASCII 单字节字符,从开引号到闭引号
|
||||
/// 之间的字节范围天然落在 UTF-8 字符边界上,整体切片能完整保留多字节字符(中文/emoji);
|
||||
/// 逐字节 `as char` 会把每个字节当独立码点,破坏多字节 UTF-8 序列致乱码。
|
||||
fn consume_quoted(s: &str, start: usize) -> Option<(String, usize)> {
|
||||
let bytes = s.as_bytes();
|
||||
if start >= bytes.len() || bytes[start] != b'\'' {
|
||||
return None;
|
||||
}
|
||||
// 扫描定位真正的闭引号:遇到 `''` 视为转义(跳过),单个 `'` 才是闭引号
|
||||
let mut i = start + 1;
|
||||
let content_start = i;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'\'' {
|
||||
// 检测转义 ''
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
|
||||
i += 2; // 跳过转义的两引号,继续找闭引号
|
||||
continue;
|
||||
}
|
||||
// 闭引号找到:content_start..i 是引号内的原始字节段(落在 UTF-8 边界)
|
||||
let raw = &s[content_start..i];
|
||||
// 内部 `''` 转义为单引号:整段替换(无转义时直接 clone 原文,零额外分配开销仅一次)
|
||||
let content = raw.replace("''", "'");
|
||||
return Some((content, i + 1));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None // 未闭合引号
|
||||
}
|
||||
|
||||
// 历史:曾设计「借用或拥有的 Value 视图」类型以避免逐段 clone,后弃用——
|
||||
// eval_jsonpath 改为直接 clone(见上方实现内注释)。放弃借用优化的理由:JSON Path
|
||||
// 通常只有少数几段,每段 clone 一个 serde_json::Value 的开销可忽略,换取代码可读性
|
||||
// 与借用检查通过。此段不再对应任何类型定义,仅作设计取舍的历史说明保留。
|
||||
|
||||
// ============================================================================
|
||||
// Tokenizer —— 把表达式切成 Token 流
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Token {
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
Str(String),
|
||||
Path(String),
|
||||
/// `==` `!=` `>` `>=` `<` `<=` `contains`
|
||||
Cmp(CmpOp),
|
||||
And,
|
||||
Or,
|
||||
Not,
|
||||
LParen,
|
||||
RParen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum CmpOp {
|
||||
Eq,
|
||||
Ne,
|
||||
Gt,
|
||||
Ge,
|
||||
Lt,
|
||||
Le,
|
||||
Contains,
|
||||
}
|
||||
|
||||
fn tokenize(s: &str) -> Result<Vec<Token>, ()> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut toks = Vec::new();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < bytes.len() {
|
||||
// 略空白
|
||||
if bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match bytes[i] {
|
||||
b'(' => {
|
||||
toks.push(Token::LParen);
|
||||
i += 1;
|
||||
}
|
||||
b')' => {
|
||||
toks.push(Token::RParen);
|
||||
i += 1;
|
||||
}
|
||||
b'\'' => {
|
||||
let (val, next) = consume_quoted(s, i).ok_or(())?;
|
||||
toks.push(Token::Str(val));
|
||||
i = next;
|
||||
}
|
||||
b'$' => {
|
||||
// JSON Path:到下一个空白或比较符或括号外
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < bytes.len() {
|
||||
let c = bytes[i];
|
||||
// 遇空白 / 逻辑词边界 / 比较符 / 括号 停止
|
||||
if c.is_ascii_whitespace() || c == b'(' || c == b')' {
|
||||
break;
|
||||
}
|
||||
// 比较运算符起始
|
||||
if c == b'=' || c == b'!' || c == b'>' || c == b'<' {
|
||||
break;
|
||||
}
|
||||
// [ 段内允许,但 ] 后若跟字母数字属下一个 token(罕见,简化:整体吃掉)
|
||||
i += 1;
|
||||
}
|
||||
toks.push(Token::Path(s[start..i].to_string()));
|
||||
}
|
||||
b'=' => {
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
|
||||
toks.push(Token::Cmp(CmpOp::Eq));
|
||||
i += 2;
|
||||
} else {
|
||||
return Err(()); // 单 = 非法
|
||||
}
|
||||
}
|
||||
b'!' => {
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
|
||||
toks.push(Token::Cmp(CmpOp::Ne));
|
||||
i += 2;
|
||||
} else {
|
||||
return Err(()); // 单 ! 非法(不支持 C 风格!)
|
||||
}
|
||||
}
|
||||
b'>' => {
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
|
||||
toks.push(Token::Cmp(CmpOp::Ge));
|
||||
i += 2;
|
||||
} else {
|
||||
toks.push(Token::Cmp(CmpOp::Gt));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'<' => {
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
|
||||
toks.push(Token::Cmp(CmpOp::Le));
|
||||
i += 2;
|
||||
} else {
|
||||
toks.push(Token::Cmp(CmpOp::Lt));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// 数字字面量(含负号起始;正号省略)。注意:`and/or/not/true/false` 不会被
|
||||
// 数字分支吃掉,因为它们以字母开头走下面的字母分支。
|
||||
b'-' | b'0'..=b'9' => {
|
||||
let start = i;
|
||||
if bytes[i] == b'-' {
|
||||
i += 1;
|
||||
}
|
||||
let mut saw_digit = false;
|
||||
while i < bytes.len()
|
||||
&& (bytes[i].is_ascii_digit() || bytes[i] == b'.')
|
||||
{
|
||||
saw_digit = true;
|
||||
i += 1;
|
||||
}
|
||||
if !saw_digit {
|
||||
return Err(()); // 仅 `-` 非法
|
||||
}
|
||||
let num: f64 = s[start..i].parse().map_err(|_| ())?;
|
||||
toks.push(Token::Number(num));
|
||||
}
|
||||
// 字母起始:关键字 or/and/not/true/false 或 contains
|
||||
_ => {
|
||||
let start = i;
|
||||
while i < bytes.len()
|
||||
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_')
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
let word = &s[start..i];
|
||||
match word {
|
||||
"true" => toks.push(Token::Bool(true)),
|
||||
"false" => toks.push(Token::Bool(false)),
|
||||
"and" => toks.push(Token::And),
|
||||
"or" => toks.push(Token::Or),
|
||||
"not" => toks.push(Token::Not),
|
||||
"contains" => toks.push(Token::Cmp(CmpOp::Contains)),
|
||||
// 未识别关键字:保守起见视为非法(tokenize 失败 → evaluate false)
|
||||
_ => return Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(toks)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parser —— 递归下降,优先级 or < and < not < 比较 < 操作数
|
||||
// ============================================================================
|
||||
|
||||
struct Parser<'a> {
|
||||
toks: Vec<Token>,
|
||||
pos: usize,
|
||||
ctx: &'a Value,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn peek(&self) -> Option<&Token> {
|
||||
self.toks.get(self.pos)
|
||||
}
|
||||
|
||||
fn bump(&mut self) -> Option<Token> {
|
||||
let t = self.toks.get(self.pos).cloned();
|
||||
if t.is_some() {
|
||||
self.pos += 1;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// or := and ( 'or' and )*
|
||||
fn parse_or(&mut self) -> Result<bool, ()> {
|
||||
let mut left = self.parse_and()?;
|
||||
while let Some(Token::Or) = self.peek() {
|
||||
self.bump();
|
||||
// 注意:此处并非真正短路求值。右侧 parse_and() 必须无条件执行——
|
||||
// 递归下降解析器需消费右侧 token 才能正确推进 pos(否则残留 token 会让
|
||||
// 上层误判为非法表达式)。`||` 仅做最终布尔合并;本引擎求值无副作用,
|
||||
// 右侧即使结果被丢弃也无害。
|
||||
let right = self.parse_and()?;
|
||||
left = left || right;
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
/// and := not ( 'and' not )*
|
||||
fn parse_and(&mut self) -> Result<bool, ()> {
|
||||
let mut left = self.parse_not()?;
|
||||
while let Some(Token::And) = self.peek() {
|
||||
self.bump();
|
||||
// 同 parse_or:右侧 parse_not() 必须无条件执行以推进 pos,`&&` 仅做
|
||||
// 最终布尔合并,非短路求值(引擎无副作用)。
|
||||
let right = self.parse_not()?;
|
||||
left = left && right;
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
/// not := 'not' not | comparison
|
||||
fn parse_not(&mut self) -> Result<bool, ()> {
|
||||
if let Some(Token::Not) = self.peek() {
|
||||
self.bump();
|
||||
let v = self.parse_not()?;
|
||||
return Ok(!v);
|
||||
}
|
||||
self.parse_comparison()
|
||||
}
|
||||
|
||||
/// comparison := operand ( cmpop operand )?
|
||||
///
|
||||
/// 无比较运算符时:operand 自身需为布尔语义(JSON Path 取到 bool、或布尔字面量);
|
||||
/// 否则视为非布尔(保守 false)。
|
||||
fn parse_comparison(&mut self) -> Result<bool, ()> {
|
||||
let left = self.parse_operand()?;
|
||||
match self.peek() {
|
||||
Some(Token::Cmp(op)) => {
|
||||
let op = *op;
|
||||
self.bump();
|
||||
let right = self.parse_operand()?;
|
||||
Ok(eval_cmp(op, &left, &right))
|
||||
}
|
||||
_ => {
|
||||
// 单操作数:仅布尔字面量或 JSON Path 指向 bool 为 true;其余保守 false
|
||||
Ok(left.as_bool().unwrap_or(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// operand := '(' or ')' | bool | number | str | path
|
||||
fn parse_operand(&mut self) -> Result<Value, ()> {
|
||||
match self.bump() {
|
||||
Some(Token::LParen) => {
|
||||
let v = self.parse_or()?;
|
||||
// 期待闭括号
|
||||
match self.bump() {
|
||||
Some(Token::RParen) => Ok(Value::Bool(v)),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
Some(Token::Bool(b)) => Ok(Value::Bool(b)),
|
||||
Some(Token::Number(n)) => Ok(serde_json::json!(n)),
|
||||
Some(Token::Str(s)) => Ok(Value::String(s)),
|
||||
Some(Token::Path(p)) => {
|
||||
// JSON Path 求值失败 → Null(后续比较保守 false)
|
||||
Ok(eval_jsonpath(&p, self.ctx).unwrap_or(Value::Null))
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 比较求值。任一侧类型不兼容 → false(保守)。
|
||||
fn eval_cmp(op: CmpOp, left: &Value, right: &Value) -> bool {
|
||||
match op {
|
||||
CmpOp::Eq => json_eq(left, right),
|
||||
CmpOp::Ne => !json_eq(left, right),
|
||||
CmpOp::Gt | CmpOp::Ge | CmpOp::Lt | CmpOp::Le => {
|
||||
let cmp = num_cmp(left, right);
|
||||
match (op, cmp) {
|
||||
(CmpOp::Gt, Some(o)) => o.is_gt(),
|
||||
(CmpOp::Ge, Some(o)) => o.is_ge(),
|
||||
(CmpOp::Lt, Some(o)) => o.is_lt(),
|
||||
(CmpOp::Le, Some(o)) => o.is_le(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
CmpOp::Contains => json_contains(left, right),
|
||||
}
|
||||
}
|
||||
|
||||
/// 相等比较:数值按数值比(string "1" 不等于 number 1,避免隐式转换陷阱),
|
||||
/// 其余按 serde_json Value 相等。布尔与数字需类型对齐(serde_json bool != number)。
|
||||
fn json_eq(left: &Value, right: &Value) -> bool {
|
||||
// 两端均数值(含 i64/u64/f64):数值比较(1.0 == 1)
|
||||
if let (Some(a), Some(b)) = (as_f64(left), as_f64(right)) {
|
||||
return (a - b).abs() < f64::EPSILON;
|
||||
}
|
||||
left == right
|
||||
}
|
||||
|
||||
/// 数值比较。两端需均可转 f64(数字字面量、JSON number),否则 None(保守 false)。
|
||||
fn num_cmp(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
|
||||
let a = as_f64(left)?;
|
||||
let b = as_f64(right)?;
|
||||
a.partial_cmp(&b)
|
||||
}
|
||||
|
||||
fn as_f64(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// contains:
|
||||
/// - 左值为字符串、右值为字符串 → 子串包含
|
||||
/// - 左值为数组 → 任一元素 json_eq 右值
|
||||
/// - 其余 → false
|
||||
fn json_contains(left: &Value, right: &Value) -> bool {
|
||||
match left {
|
||||
Value::String(s) => right
|
||||
.as_str()
|
||||
.map(|r| s.contains(r))
|
||||
.unwrap_or(false),
|
||||
Value::Array(arr) => arr.iter().any(|e| json_eq(e, right)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,60 +503,399 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn ctx() -> Value {
|
||||
// 当前实现未使用 context,但保持传入以对齐签名
|
||||
json!({})
|
||||
}
|
||||
// --------------------------------------------------------------------
|
||||
// 字面量与向后兼容(原有 7 个用例语义保持)
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_true_literal_returns_true() {
|
||||
assert_eq!(ConditionEngine::evaluate("true", &ctx()).unwrap(), true);
|
||||
assert_eq!(ConditionEngine::evaluate("true", &json!({})).unwrap(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_false_literal_returns_false() {
|
||||
assert_eq!(ConditionEngine::evaluate("false", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("false", &json!({})).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_expression_defaults_to_false() {
|
||||
// 尚未实现的语法:表达式非 true/false 字面量时默认 false(保守拒绝,不静默放行)
|
||||
// 历史用例:非法表达式(单 = 等非法 token)保守 false
|
||||
// 注:增强后 "$.status == 'completed'" 已合法,改用真正非法的表达式
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status == 'completed'", &ctx()).unwrap(),
|
||||
ConditionEngine::evaluate("= =", &json!({})).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitespace_is_trimmed() {
|
||||
// trimmed() 去除首尾空白后再与字面量比较
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate(" true ", &ctx()).unwrap(),
|
||||
ConditionEngine::evaluate(" true ", &json!({})).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("\tfalse\n", &ctx()).unwrap(),
|
||||
ConditionEngine::evaluate("\tfalse\n", &json!({})).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_string_defaults_to_false() {
|
||||
assert_eq!(ConditionEngine::evaluate("", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate(" ", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("", &json!({})).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate(" ", &json!({})).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_sensitive_not_matched() {
|
||||
// 字面量比较区分大小写:True/False 既非 "true" 也非 "false",默认 false
|
||||
assert_eq!(ConditionEngine::evaluate("True", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("FALSE", &ctx()).unwrap(), false);
|
||||
// True/FALSE 仍非合法布尔字面量(只认小写 true/false)
|
||||
assert_eq!(ConditionEngine::evaluate("True", &json!({})).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("FALSE", &json!({})).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arbitrary_string_defaults_to_false() {
|
||||
assert_eq!(ConditionEngine::evaluate("yes", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("1", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("completed", &ctx()).unwrap(), false);
|
||||
// 未识别关键字(yes / completed)→ tokenize 失败 → false
|
||||
assert_eq!(ConditionEngine::evaluate("yes", &json!({})).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("completed", &json!({})).unwrap(), false);
|
||||
// 裸数字(非布尔语义)→ 单操作数非 bool → false
|
||||
assert_eq!(ConditionEngine::evaluate("1", &json!({})).unwrap(), false);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 一、JSON Path 求值
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn jsonpath_eq_string_literal() {
|
||||
let ctx = json!({ "status": "completed" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status == 'completed'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status == 'failed'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_nested_dot() {
|
||||
let ctx = json!({ "result": { "code": 200 } });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.result.code == 200", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_array_index() {
|
||||
let ctx = json!({ "tags": ["ai", "rust"] });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.tags[0] == 'ai'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
// 负数下标从尾部
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.tags[-1] == 'rust'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_missing_path_defaults_false() {
|
||||
// 路径不存在 → Null == 'completed' → false(保守)
|
||||
let ctx = json!({ "status": "ok" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.missing == 'x'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_bool_value_as_operand() {
|
||||
// JSON Path 指向 bool,可作单操作数布尔求值
|
||||
let ctx = json!({ "approved": true });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.approved", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
// 非 bool 字段作单操作数 → false(字符串非布尔语义)
|
||||
let ctx2 = json!({ "status": "ok" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status", &ctx2).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 二、比较运算符(==/!=/>/</>=/<=)
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn numeric_gt_lt() {
|
||||
let ctx = json!({ "count": 15 });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.count > 10", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.count < 10", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.count >= 15", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.count <= 14", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_negative_and_decimal() {
|
||||
let ctx = json!({ "temp": -3.5 });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.temp < 0", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.temp == -3.5", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ne_operator() {
|
||||
let ctx = json!({ "status": "ok" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status != 'failed'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status != 'ok'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_numeric_gt_defaults_false() {
|
||||
// 字符串 > 数字:类型不兼容 → 保守 false
|
||||
let ctx = json!({ "name": "abc" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.name > 10", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 三、contains
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn contains_substring() {
|
||||
let ctx = json!({ "msg": "hello world" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.msg contains 'world'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.msg contains 'xyz'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_array_element() {
|
||||
let ctx = json!({ "tags": ["ai", "rust", "devflow"] });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.tags contains 'rust'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.tags contains 'go'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_numeric_in_array() {
|
||||
// 数组 contains 数字(数值相等判定,1 == 1.0)
|
||||
let ctx = json!({ "nums": [1, 2, 3] });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.nums contains 2", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_non_string_non_array_defaults_false() {
|
||||
let ctx = json!({ "n": 100 });
|
||||
// 数字 contains ... 无意义 → false
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.n contains '1'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 四、逻辑组合 and / or / not
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn logical_and() {
|
||||
let ctx = json!({ "a": true, "b": false });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.a and $.b", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
let ctx2 = json!({ "a": true, "b": true });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.a and $.b", &ctx2).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_or() {
|
||||
let ctx = json!({ "a": false, "b": true });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.a or $.b", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_not() {
|
||||
let ctx = json!({ "approved": false });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("not $.approved", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
let ctx2 = json!({ "approved": true });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("not $.approved", &ctx2).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn and_or_precedence() {
|
||||
// and 优先级高于 or:true or (false and false) → true
|
||||
let ctx = json!({});
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("true or false and false", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_precedence_over_and() {
|
||||
// not 高于 and:not false and not false → true and true → true
|
||||
let ctx = json!({});
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("not false and not false", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 五、嵌套括号 + 综合
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn nested_parens() {
|
||||
let ctx = json!({});
|
||||
// (true or false) and (false or true) → true and true → true
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("(true or false) and (false or true)", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
// not (true and true) → false
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("not (true and true)", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complex_realworld_expression() {
|
||||
// 综合场景:状态完成 且 (分数 >= 80 或 含 vip 标签)且 未取消
|
||||
let ctx = json!({
|
||||
"status": "completed",
|
||||
"score": 85,
|
||||
"tags": ["vip", "ai"],
|
||||
"cancelled": false
|
||||
});
|
||||
let expr = "$.status == 'completed' and ($.score >= 80 or $.tags contains 'vip') and not $.cancelled";
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate(expr, &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_parens_defaults_false() {
|
||||
// 括号不闭合 → parse 失败 → 保守 false
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("(true and false", &json!({})).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_quote_defaults_false() {
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.s == 'abc", &json!({"s":"x"})).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// 六、多字节 UTF-8(中文 key/值、emoji)与转义——回归 consume_quoted 逐字节 bug
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn jsonpath_chinese_key_and_value() {
|
||||
// 中文 key + 中文值:验证 consume_quoted 整体切片保留 UTF-8(逐字节 as char 会乱码)
|
||||
let ctx = json!({ "状态": "已完成" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.状态 == '已完成'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.状态 == '失败'", &ctx).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_bracket_chinese_key() {
|
||||
// $['中文'] 单引号括号键含中文值,直接考验 consume_quoted 多字节处理
|
||||
let ctx = json!({ "中文": "成功" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$['中文'] == '成功'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonpath_emoji_value() {
|
||||
// emoji(4 字节 UTF-8 序列)作字面量值
|
||||
let ctx = json!({ "flag": "🚀" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.flag == '🚀'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_quote_escaped_inside_literal() {
|
||||
// 内部 '' 转义为单引号:it''s → it's
|
||||
let ctx = json!({ "msg": "it's done" });
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.msg == 'it''s done'", &ctx).unwrap(),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use df_types::events::WorkflowEvent;
|
||||
use df_types::types::NodeId;
|
||||
|
||||
use crate::conditions::ConditionEngine;
|
||||
use crate::dag::Dag;
|
||||
use crate::eventbus::EventBus;
|
||||
use crate::node::{NodeContext, NodeOutput};
|
||||
@@ -57,14 +58,16 @@ impl DagExecutor {
|
||||
|
||||
tracing::info!("DAG 执行开始,共 {} 层", layers.len());
|
||||
|
||||
// 预建入边索引:target → 直接前驱列表(O(E) 一次构建)
|
||||
// 预建入边索引:target → 直接前驱列表 + 边条件表达式(O(E) 一次构建)
|
||||
// 避免在内层按节点循环中调用 dag.predecessors()(每次 O(E) 全表扫描,整体 O(V·E))。
|
||||
let mut adjacency_in: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
|
||||
// 含 condition 是为 conditions-eval feature 接入:flag 开时按入边 condition 过滤前驱 input。
|
||||
// flag 关时 condition 字段被忽略,行为与增强前完全一致(零破坏)。
|
||||
let mut adjacency_in: HashMap<NodeId, Vec<(NodeId, Option<String>)>> = HashMap::new();
|
||||
for edge in &dag.edges {
|
||||
adjacency_in
|
||||
.entry(edge.target.clone())
|
||||
.or_default()
|
||||
.push(edge.source.clone());
|
||||
.push((edge.source.clone(), edge.condition.clone()));
|
||||
}
|
||||
|
||||
for (layer_idx, layer) in layers.iter().enumerate() {
|
||||
@@ -77,6 +80,61 @@ impl DagExecutor {
|
||||
anyhow::anyhow!("节点 {} 不存在于 DAG 中", node_id)
|
||||
})?;
|
||||
|
||||
// 构建节点上下文:通过入边索引 O(入度) 取前驱,而非 O(E) 全表扫描。
|
||||
//
|
||||
// conditions-eval feature(默认关):
|
||||
// 关:所有入边 inputs 全收集,condition 字段完全忽略 = 旧行为(零破坏)。
|
||||
// 开:对带 condition 的入边以 source output.data 为 context 求值;
|
||||
// false 则不收集该前驱 input。若节点【存在带 condition 的入边,且无任何
|
||||
// 入边(条件或无条件)放行】,则跳过该节点执行(不入 outputs、保持 Pending、
|
||||
// 不 emit),实现条件路由。无条件边 source 有 output 即视为放行。
|
||||
// 求值失败保守 false(对齐引擎语义)。
|
||||
//
|
||||
// 注:跳过判定在 set_running 之前 —— 跳过的节点不应被标记 Running/触发事件,
|
||||
// 保持 Pending 终态(与"条件未命中"语义一致)。
|
||||
let eval_conditions = cfg!(feature = "conditions-eval");
|
||||
let mut inputs: HashMap<NodeId, NodeOutput> = HashMap::new();
|
||||
let mut has_any_cond_edge = false;
|
||||
let mut any_edge_passed = false; // 任一入边放行(含无条件边)
|
||||
if let Some(preds) = adjacency_in.get(node_id) {
|
||||
for (pred_id, cond) in preds {
|
||||
if let Some(out) = outputs.get(pred_id) {
|
||||
if eval_conditions {
|
||||
if let Some(cond) = cond {
|
||||
has_any_cond_edge = true;
|
||||
// 以 source output.data 为 context 求值;失败保守 false
|
||||
let passed = ConditionEngine::evaluate(cond, &out.data)
|
||||
.unwrap_or(false);
|
||||
if passed {
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
}
|
||||
} else {
|
||||
// 无 condition 的入边:无条件放行(必收集)
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
}
|
||||
} else {
|
||||
// flag 关:condition 完全忽略,旧行为全收集
|
||||
inputs.insert(pred_id.clone(), out.clone());
|
||||
any_edge_passed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 根节点(无入边):不参与条件跳过判定,正常执行
|
||||
any_edge_passed = true;
|
||||
}
|
||||
|
||||
// 条件路由:flag 开 + 存在条件边 + 没有任何入边放行 → 跳过执行
|
||||
if eval_conditions && has_any_cond_edge && !any_edge_passed {
|
||||
tracing::info!(
|
||||
"节点 {} 所有入边条件均不满足,跳过执行(条件路由)",
|
||||
node_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 发送 NodeStarted 事件
|
||||
self.event_bus
|
||||
.send(WorkflowEvent::NodeStarted {
|
||||
@@ -86,16 +144,6 @@ impl DagExecutor {
|
||||
|
||||
self.state_machine.set_running(node_id.clone())?;
|
||||
|
||||
// 构建节点上下文:通过入边索引 O(入度) 取前驱,而非 O(E) 全表扫描
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ctx = NodeContext {
|
||||
node_id: node_id.clone(),
|
||||
inputs,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! DagExecutor 测试套件 — 从 executor.rs 抽离(纯搬迁,零行为变更)
|
||||
//!
|
||||
//! 抽离说明:executor.rs 原 513 行中 tests mod 占 305 行(60%),主体 run 仅 157 行。
|
||||
//! 独立成文件降低阅读 run 主体的滚动噪音,测试逻辑、断言、节点 mock 全部原样搬迁。
|
||||
//! 抽离说明:executor.rs 主体 run 逻辑之外曾带大量内联测试,占比偏高(抽离前的历史
|
||||
//! 行数分布不再维护),独立成文件降低阅读 run 主体的滚动噪音。测试逻辑、断言、节点 mock
|
||||
//! 全部原样搬迁。
|
||||
//! 通过 `#[path = "executor_helpers.rs"]` 内联回 executor.rs 的 #[cfg(test)] mod,
|
||||
//! 不进 lib.rs、不改 pub 路径、不影响外部调用方。
|
||||
//!
|
||||
//! SW-01 TOCTOU 相关测试(test_cancelled_node_skips_set_failed /
|
||||
//! test_cancelled_node_skips_set_completed / test_cancelled_node_emits_node_cancelled_event)
|
||||
//! 原样保留,锁定 executor.rs:136/138-153 的 Ok/Err 分支 is_cancelled 短路行为。
|
||||
//! 原样保留,锁定 executor.rs 节点执行完毕后 Ok/Err 两分支的 is_cancelled 短路行为
|
||||
//! (已取消节点跳过 set_completed/set_failed,保持 Cancelled 终态)。
|
||||
|
||||
use super::*;
|
||||
use crate::node::{Node, NodeResult, NodeSchema};
|
||||
@@ -312,3 +314,206 @@ async fn test_cancelled_node_emits_node_cancelled_event() {
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// conditions-eval feature:边条件路由测试(feature flag 开启时才编译)
|
||||
// ============================================================================
|
||||
|
||||
/// 测试节点:把 config.ok 作为 bool 输出到 { "ok": <bool> }。
|
||||
/// 边 condition "$.ok == true" 据此判定路由。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
struct BoolFlagNode {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[async_trait]
|
||||
impl Node for BoolFlagNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
Ok(NodeOutput::from_value(serde_json::json!({ "ok": self.ok })))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"bool_flag"
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试节点:执行时记录到共享 Mutex,用于断言节点是否被执行。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
struct RecordingNode {
|
||||
flag: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[async_trait]
|
||||
impl Node for RecordingNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
self.flag.lock().unwrap().push(self.name.clone());
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"recording"
|
||||
}
|
||||
}
|
||||
|
||||
/// conditions-eval 开启时:边 condition 求值 true → 下游执行。
|
||||
///
|
||||
/// 结构:branch(true) --condition "$.ok == true"--> target_run
|
||||
/// branch(true) --condition "$.ok == false"--> target_skip
|
||||
/// branch.ok==true:第一条边满足 → target_run 执行;第二条边 false → target_skip 跳过。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[tokio::test]
|
||||
async fn conditions_eval_routes_by_edge_condition() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("branch".to_string(), Box::new(BoolFlagNode { ok: true }));
|
||||
|
||||
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let ran_run = ran.clone();
|
||||
let ran_skip = ran.clone();
|
||||
|
||||
dag.add_node(
|
||||
"target_run".to_string(),
|
||||
Box::new(RecordingNode { flag: ran_run, name: "target_run".to_string() }),
|
||||
);
|
||||
dag.add_node(
|
||||
"target_skip".to_string(),
|
||||
Box::new(RecordingNode { flag: ran_skip, name: "target_skip".to_string() }),
|
||||
);
|
||||
|
||||
// 两条带条件的出边:branch.ok==true 命中第一条
|
||||
dag.add_edge_with_condition(
|
||||
"branch".to_string(),
|
||||
"target_run".to_string(),
|
||||
"$.ok == true".to_string(),
|
||||
);
|
||||
dag.add_edge_with_condition(
|
||||
"branch".to_string(),
|
||||
"target_skip".to_string(),
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".to_string());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
.expect("run");
|
||||
|
||||
// target_run 被执行(条件命中);target_skip 被跳过(条件不满足,不入 outputs)
|
||||
assert!(
|
||||
outputs.contains_key("target_run"),
|
||||
"条件命中的下游应执行,outputs: {:?}",
|
||||
outputs.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!outputs.contains_key("target_skip"),
|
||||
"条件不满足的下游应跳过(不入 outputs),outputs: {:?}",
|
||||
outputs.keys().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let ran = ran.lock().unwrap();
|
||||
assert!(
|
||||
ran.contains(&"target_run".to_string()),
|
||||
"target_run 应被执行,ran: {:?}",
|
||||
ran
|
||||
);
|
||||
assert!(
|
||||
!ran.contains(&"target_skip".to_string()),
|
||||
"target_skip 不应被执行,ran: {:?}",
|
||||
ran
|
||||
);
|
||||
}
|
||||
|
||||
/// conditions-eval 开启时:节点所有入边均带 condition 且全部 false → 跳过执行。
|
||||
/// 验证状态机保持 Pending(跳过节点不 set_running/Completed/Failed)。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[tokio::test]
|
||||
async fn conditions_eval_skip_keeps_pending() {
|
||||
use df_types::types::NodeStatus;
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("src".to_string(), Box::new(BoolFlagNode { ok: true }));
|
||||
dag.add_node(
|
||||
"skip_target".to_string(),
|
||||
Box::new(RecordingNode {
|
||||
flag: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
name: "skip_target".to_string(),
|
||||
}),
|
||||
);
|
||||
// src.ok==true,但边要求 == false → 不满足 → 跳过
|
||||
dag.add_edge_with_condition(
|
||||
"src".to_string(),
|
||||
"skip_target".to_string(),
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".to_string());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
.expect("run");
|
||||
|
||||
assert!(!outputs.contains_key("skip_target"), "skip_target 应被跳过不入 outputs");
|
||||
// 跳过的节点保持 Pending(未被状态机转换)
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"skip_target".to_string()),
|
||||
NodeStatus::Pending,
|
||||
"跳过的节点应保持 Pending"
|
||||
);
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"src".to_string()),
|
||||
NodeStatus::Completed,
|
||||
"源节点应正常完成"
|
||||
);
|
||||
}
|
||||
|
||||
/// conditions-eval 开启时:无条件入边 + 条件入边混合,无条件的总放行(不被跳过)。
|
||||
#[cfg(feature = "conditions-eval")]
|
||||
#[tokio::test]
|
||||
async fn conditions_eval_unconditional_edge_always_passes() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(BoolFlagNode { ok: true }));
|
||||
dag.add_node("b".to_string(), Box::new(BoolFlagNode { ok: false }));
|
||||
dag.add_node(
|
||||
"mix".to_string(),
|
||||
Box::new(RecordingNode {
|
||||
flag: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
name: "mix".to_string(),
|
||||
}),
|
||||
);
|
||||
// 一条无条件边(b → mix)+ 一条条件边(a → mix, 要求 a.ok==false 不满足)
|
||||
dag.add_edge("b".to_string(), "mix".to_string());
|
||||
dag.add_edge_with_condition(
|
||||
"a".to_string(),
|
||||
"mix".to_string(),
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".to_string());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
.expect("run");
|
||||
|
||||
// 存在无条件入边 → mix 必执行(无条件边不被跳过逻辑计入 has_any_cond_edge 的「全部 false」)
|
||||
assert!(
|
||||
outputs.contains_key("mix"),
|
||||
"存在无条件入边时节点应执行,outputs: {:?}",
|
||||
outputs.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user