重构: 拆executor工作流执行器(strategy核心库·抽测试)
- 新建 df-workflow/executor_helpers.rs(314行): 5测试节点mock + 5测试用例(含3 SW-01 TOCTOU) - executor.rs 513→210: 删内联tests + #[path] mod tests; run主体49-205保留(SW-01 TOCTOU :130-186零改动) - agent偏离合理: run无可抽纯helper(全内联+self耦合), 改抽测试降阅读噪音(305行tests→helper) 主代兜底: cargo check --workspace 0 + test df-workflow 23(含TOCTOU) + grep #[path] mod tests印证 strategy: 核心库, 测试代码抽离(cfg(test)零库影响), SW-01 TOCTOU保留 git add指定(df-workflow/*)
This commit is contained in:
@@ -206,308 +206,5 @@ impl DagExecutor {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::node::{Node, NodeResult, NodeSchema};
|
||||
use async_trait::async_trait;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 测试节点:sleep 指定毫秒后返回空输出
|
||||
struct SleepNode {
|
||||
sleep_ms: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for SleepNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await;
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"sleep"
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试节点:直接返回错误
|
||||
struct FailNode;
|
||||
|
||||
/// 测试节点:把 ctx.config 的指定 key 字符串原样回显到 output(验证节点级 config 下沉)
|
||||
struct EchoConfigNode {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for EchoConfigNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
let v = ctx
|
||||
.config
|
||||
.get(&self.key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Ok(NodeOutput::from_value(serde_json::json!({ "echo": v })))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"echo_config"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for FailNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
Err(anyhow::anyhow!("故意失败"))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"fail"
|
||||
}
|
||||
}
|
||||
|
||||
/// ④-1:节点级 config 下沉验证。
|
||||
/// DagDef 节点 config={foo:"bar"} + 全局 config={foo:"GLOBAL"} → NodeContext.config.foo=="bar"
|
||||
/// (节点级覆盖全局级)。同时验证缺节点 config 的节点回退全局 config(零行为破坏现有调用方)。
|
||||
#[tokio::test]
|
||||
async fn node_config_overrides_global_in_node_context() {
|
||||
use crate::registry::NodeRegistry;
|
||||
|
||||
// 构造 DagDef:n1 节点写 config={foo:"bar"};n2 节点 config 空(验证回退全局)
|
||||
let mut def = crate::dag_def::DagDef::new();
|
||||
def.add_node("n1".to_string(), "echo".to_string(), serde_json::json!({ "foo": "bar" }));
|
||||
def.add_node("n2".to_string(), "echo".to_string(), serde_json::json!({}));
|
||||
|
||||
// 注册 echo 工厂 → EchoConfigNode(读 config.foo)
|
||||
let mut registry = NodeRegistry::new();
|
||||
registry.register("echo", |_cfg| {
|
||||
Box::new(EchoConfigNode { key: "foo".to_string() })
|
||||
});
|
||||
|
||||
let dag = registry.build_dag(&def).expect("build_dag");
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
|
||||
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
||||
.await
|
||||
.expect("run");
|
||||
|
||||
// n1:节点级 foo="bar" 覆盖全局 "GLOBAL"
|
||||
assert_eq!(
|
||||
outputs["n1"].data["echo"],
|
||||
serde_json::json!("bar"),
|
||||
"节点级 config 应覆盖全局"
|
||||
);
|
||||
// n2:节点 config 空 → 回退全局 foo="GLOBAL"(零行为破坏现有调用方)
|
||||
assert_eq!(
|
||||
outputs["n2"].data["echo"],
|
||||
serde_json::json!("GLOBAL"),
|
||||
"空节点 config 应回退全局"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_layer_runs_in_parallel() {
|
||||
// 两个无依赖节点位于同一层,各 sleep 100ms
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let start = std::time::Instant::now();
|
||||
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert_eq!(outputs.len(), 2);
|
||||
// 串行需要约 200ms,并行应明显小于 180ms
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(180),
|
||||
"同层节点应并行执行,实际耗时 {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_layer_failure_aborts_following_layers() {
|
||||
// a(失败) 与 b(成功) 同层,c 依赖 a,失败后 c 不应执行
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(FailNode));
|
||||
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_edge("a".to_string(), "c".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let err = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("节点 a 执行失败"));
|
||||
|
||||
// 同层成功节点状态正常更新,下游节点保持 Pending
|
||||
use df_types::types::NodeStatus;
|
||||
assert_eq!(executor.state_machine.get(&"a".to_string()), NodeStatus::Failed);
|
||||
assert_eq!(executor.state_machine.get(&"b".to_string()), NodeStatus::Completed);
|
||||
assert_eq!(executor.state_machine.get(&"c".to_string()), NodeStatus::Pending);
|
||||
}
|
||||
|
||||
/// 测试节点:执行中通过共享 node_status 自取消(模拟外部 IPC set_cancelled),返回 Err
|
||||
struct CancelSelfNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for CancelSelfNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
// 通过共享 node_status 置 Cancelled(写共享 HashMap,executor.state_machine 可见)
|
||||
ctx.node_status.set_cancelled(ctx.node_id.clone());
|
||||
Err(anyhow::anyhow!("人工审批被取消"))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"cancel_self"
|
||||
}
|
||||
}
|
||||
|
||||
/// B-03b-R1:取消的节点返回 Err 时,executor 跳过 set_failed(Cancelled→Failed transition 非法会 bail),
|
||||
/// 状态保持 Cancelled,run 返回取消相关 Err 而非状态转换错误。
|
||||
#[tokio::test]
|
||||
async fn test_cancelled_node_skips_set_failed() {
|
||||
use df_types::types::NodeStatus;
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".to_string());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("取消") || err.contains("执行失败"),
|
||||
"应返回取消相关错误,实际: {}",
|
||||
err
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_failed 覆盖为 Failed)—— R1 修法生效的核心验证
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"x".to_string()),
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试节点:执行中通过共享 node_status 自取消(模拟外部 IPC set_cancelled),返回 Ok
|
||||
/// 模拟 HumanNode select! 收合法 HumanApprovalResponse 后 return Ok,但 join_all 让出窗口内
|
||||
/// cancel_workflow_node IPC 把节点改 Cancelled 的 TOCTOU 场景。
|
||||
struct CancelSelfThenOkNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for CancelSelfThenOkNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
ctx.node_status.set_cancelled(ctx.node_id.clone());
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"cancel_self_then_ok"
|
||||
}
|
||||
}
|
||||
|
||||
/// R-P1-3:Ok 后取消的 TOCTOU 防护。节点返回 Ok 但执行期间已被 set_cancelled,
|
||||
/// executor Ok 分支必须跳过 set_completed(transition Cancelled→Completed 非法会 bail),
|
||||
/// 状态保持 Cancelled,run 返回 Ok(已批准审批不应误报失败)。
|
||||
#[tokio::test]
|
||||
async fn test_cancelled_node_skips_set_completed() {
|
||||
use df_types::types::NodeStatus;
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Ok 后取消应短路 set_completed 而非 bail,实际 err: {:?}",
|
||||
result.err()
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_completed 覆盖为 Completed,也不 bail)—— R-P1-3 修法核心验证
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"y".to_string()),
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// 复核-新③:取消节点(Err 路径)emit 的应是 NodeCancelled(非 NodeFailed),
|
||||
/// 前端按 type 区分「取消」与「失败」。订阅事件总线收集所有事件断言。
|
||||
#[tokio::test]
|
||||
async fn test_cancelled_node_emits_node_cancelled_event() {
|
||||
use df_types::events::WorkflowEvent;
|
||||
use df_types::types::NodeStatus;
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("c".to_string(), Box::new(CancelSelfNode));
|
||||
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string());
|
||||
let _ = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// 收集所有事件(run 已结束,事件总线无新事件)
|
||||
let mut events: Vec<WorkflowEvent> = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
events.push(ev);
|
||||
}
|
||||
|
||||
// 必含 NodeCancelled { node_id: "c" }
|
||||
let cancelled = events.iter().any(|e| matches!(
|
||||
e,
|
||||
WorkflowEvent::NodeCancelled { node_id } if node_id == "c"
|
||||
));
|
||||
assert!(cancelled, "取消节点应 emit NodeCancelled, 实际事件: {:?}", events);
|
||||
|
||||
// 不应含 node "c" 的 NodeFailed(失败事件)—— 语义双标修复的核心验证
|
||||
let failed = events.iter().any(|e| matches!(
|
||||
e,
|
||||
WorkflowEvent::NodeFailed { node_id, .. } if node_id == "c"
|
||||
));
|
||||
assert!(!failed, "取消节点不应 emit NodeFailed, 实际事件: {:?}", events);
|
||||
|
||||
// 状态保持 Cancelled
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"c".to_string()),
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "executor_helpers.rs"]
|
||||
mod tests;
|
||||
|
||||
314
crates/df-workflow/src/executor_helpers.rs
Normal file
314
crates/df-workflow/src/executor_helpers.rs
Normal file
@@ -0,0 +1,314 @@
|
||||
//! DagExecutor 测试套件 — 从 executor.rs 抽离(纯搬迁,零行为变更)
|
||||
//!
|
||||
//! 抽离说明:executor.rs 原 513 行中 tests mod 占 305 行(60%),主体 run 仅 157 行。
|
||||
//! 独立成文件降低阅读 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 短路行为。
|
||||
|
||||
use super::*;
|
||||
use crate::node::{Node, NodeResult, NodeSchema};
|
||||
use async_trait::async_trait;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 测试节点:sleep 指定毫秒后返回空输出
|
||||
struct SleepNode {
|
||||
sleep_ms: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for SleepNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
tokio::time::sleep(Duration::from_millis(self.sleep_ms)).await;
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"sleep"
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试节点:直接返回错误
|
||||
struct FailNode;
|
||||
|
||||
/// 测试节点:把 ctx.config 的指定 key 字符串原样回显到 output(验证节点级 config 下沉)
|
||||
struct EchoConfigNode {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for EchoConfigNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
let v = ctx
|
||||
.config
|
||||
.get(&self.key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Ok(NodeOutput::from_value(serde_json::json!({ "echo": v })))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"echo_config"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for FailNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
Err(anyhow::anyhow!("故意失败"))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"fail"
|
||||
}
|
||||
}
|
||||
|
||||
/// ④-1:节点级 config 下沉验证。
|
||||
/// DagDef 节点 config={foo:"bar"} + 全局 config={foo:"GLOBAL"} → NodeContext.config.foo=="bar"
|
||||
/// (节点级覆盖全局级)。同时验证缺节点 config 的节点回退全局 config(零行为破坏现有调用方)。
|
||||
#[tokio::test]
|
||||
async fn node_config_overrides_global_in_node_context() {
|
||||
use crate::registry::NodeRegistry;
|
||||
|
||||
// 构造 DagDef:n1 节点写 config={foo:"bar"};n2 节点 config 空(验证回退全局)
|
||||
let mut def = crate::dag_def::DagDef::new();
|
||||
def.add_node("n1".to_string(), "echo".to_string(), serde_json::json!({ "foo": "bar" }));
|
||||
def.add_node("n2".to_string(), "echo".to_string(), serde_json::json!({}));
|
||||
|
||||
// 注册 echo 工厂 → EchoConfigNode(读 config.foo)
|
||||
let mut registry = NodeRegistry::new();
|
||||
registry.register("echo", |_cfg| {
|
||||
Box::new(EchoConfigNode { key: "foo".to_string() })
|
||||
});
|
||||
|
||||
let dag = registry.build_dag(&def).expect("build_dag");
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
|
||||
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
||||
.await
|
||||
.expect("run");
|
||||
|
||||
// n1:节点级 foo="bar" 覆盖全局 "GLOBAL"
|
||||
assert_eq!(
|
||||
outputs["n1"].data["echo"],
|
||||
serde_json::json!("bar"),
|
||||
"节点级 config 应覆盖全局"
|
||||
);
|
||||
// n2:节点 config 空 → 回退全局 foo="GLOBAL"(零行为破坏现有调用方)
|
||||
assert_eq!(
|
||||
outputs["n2"].data["echo"],
|
||||
serde_json::json!("GLOBAL"),
|
||||
"空节点 config 应回退全局"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_layer_runs_in_parallel() {
|
||||
// 两个无依赖节点位于同一层,各 sleep 100ms
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let start = std::time::Instant::now();
|
||||
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert_eq!(outputs.len(), 2);
|
||||
// 串行需要约 200ms,并行应明显小于 180ms
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(180),
|
||||
"同层节点应并行执行,实际耗时 {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_layer_failure_aborts_following_layers() {
|
||||
// a(失败) 与 b(成功) 同层,c 依赖 a,失败后 c 不应执行
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(FailNode));
|
||||
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_edge("a".to_string(), "c".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let err = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("节点 a 执行失败"));
|
||||
|
||||
// 同层成功节点状态正常更新,下游节点保持 Pending
|
||||
use df_types::types::NodeStatus;
|
||||
assert_eq!(executor.state_machine.get(&"a".to_string()), NodeStatus::Failed);
|
||||
assert_eq!(executor.state_machine.get(&"b".to_string()), NodeStatus::Completed);
|
||||
assert_eq!(executor.state_machine.get(&"c".to_string()), NodeStatus::Pending);
|
||||
}
|
||||
|
||||
/// 测试节点:执行中通过共享 node_status 自取消(模拟外部 IPC set_cancelled),返回 Err
|
||||
struct CancelSelfNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for CancelSelfNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
// 通过共享 node_status 置 Cancelled(写共享 HashMap,executor.state_machine 可见)
|
||||
ctx.node_status.set_cancelled(ctx.node_id.clone());
|
||||
Err(anyhow::anyhow!("人工审批被取消"))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"cancel_self"
|
||||
}
|
||||
}
|
||||
|
||||
/// B-03b-R1:取消的节点返回 Err 时,executor 跳过 set_failed(Cancelled→Failed transition 非法会 bail),
|
||||
/// 状态保持 Cancelled,run 返回取消相关 Err 而非状态转换错误。
|
||||
#[tokio::test]
|
||||
async fn test_cancelled_node_skips_set_failed() {
|
||||
use df_types::types::NodeStatus;
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".to_string());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("取消") || err.contains("执行失败"),
|
||||
"应返回取消相关错误,实际: {}",
|
||||
err
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_failed 覆盖为 Failed)—— R1 修法生效的核心验证
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"x".to_string()),
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试节点:执行中通过共享 node_status 自取消(模拟外部 IPC set_cancelled),返回 Ok
|
||||
/// 模拟 HumanNode select! 收合法 HumanApprovalResponse 后 return Ok,但 join_all 让出窗口内
|
||||
/// cancel_workflow_node IPC 把节点改 Cancelled 的 TOCTOU 场景。
|
||||
struct CancelSelfThenOkNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for CancelSelfThenOkNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
ctx.node_status.set_cancelled(ctx.node_id.clone());
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"cancel_self_then_ok"
|
||||
}
|
||||
}
|
||||
|
||||
/// R-P1-3:Ok 后取消的 TOCTOU 防护。节点返回 Ok 但执行期间已被 set_cancelled,
|
||||
/// executor Ok 分支必须跳过 set_completed(transition Cancelled→Completed 非法会 bail),
|
||||
/// 状态保持 Cancelled,run 返回 Ok(已批准审批不应误报失败)。
|
||||
#[tokio::test]
|
||||
async fn test_cancelled_node_skips_set_completed() {
|
||||
use df_types::types::NodeStatus;
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Ok 后取消应短路 set_completed 而非 bail,实际 err: {:?}",
|
||||
result.err()
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_completed 覆盖为 Completed,也不 bail)—— R-P1-3 修法核心验证
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"y".to_string()),
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// 复核-新③:取消节点(Err 路径)emit 的应是 NodeCancelled(非 NodeFailed),
|
||||
/// 前端按 type 区分「取消」与「失败」。订阅事件总线收集所有事件断言。
|
||||
#[tokio::test]
|
||||
async fn test_cancelled_node_emits_node_cancelled_event() {
|
||||
use df_types::events::WorkflowEvent;
|
||||
use df_types::types::NodeStatus;
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("c".to_string(), Box::new(CancelSelfNode));
|
||||
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string());
|
||||
let _ = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// 收集所有事件(run 已结束,事件总线无新事件)
|
||||
let mut events: Vec<WorkflowEvent> = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
events.push(ev);
|
||||
}
|
||||
|
||||
// 必含 NodeCancelled { node_id: "c" }
|
||||
let cancelled = events.iter().any(|e| matches!(
|
||||
e,
|
||||
WorkflowEvent::NodeCancelled { node_id } if node_id == "c"
|
||||
));
|
||||
assert!(cancelled, "取消节点应 emit NodeCancelled, 实际事件: {:?}", events);
|
||||
|
||||
// 不应含 node "c" 的 NodeFailed(失败事件)—— 语义双标修复的核心验证
|
||||
let failed = events.iter().any(|e| matches!(
|
||||
e,
|
||||
WorkflowEvent::NodeFailed { node_id, .. } if node_id == "c"
|
||||
));
|
||||
assert!(!failed, "取消节点不应 emit NodeFailed, 实际事件: {:?}", events);
|
||||
|
||||
// 状态保持 Cancelled
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"c".to_string()),
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user