修复: 工作流审批闭环(execution_id 下沉 + 共享状态机 + HumanNode 真审批)
- B-06: DagExecutor 接收 execution_id 下沉到 NodeContext,消除 dummy-execution-id 硬编码 - B-07: NodeContext.node_status 共享 self.state_machine.clone()(is_cancelled 可工作) - B-03a: HumanNode execute 重写为 subscribe→send→select! 循环(响应/超时/取消 + execution_id+node_id 双键 + Lagged 容忍),删假返回"同意" - eventbus.rs 删 try_recv_human_approval 死代码 - 新增 human_node 7 单测(正常/双键过滤/超时/非法决策/自由文本) 照 B-03-人工审批响应机制.md 设计。cargo check 0 error,df-nodes 14 + df-workflow 11 test 全过
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::time::Duration;
|
||||
use tokio::time;
|
||||
use tokio::sync::broadcast;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use df_core::events::WorkflowEvent;
|
||||
|
||||
@@ -21,44 +21,77 @@ impl Node for HumanNode {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let options = config.get("options")
|
||||
let options: Vec<String> = config.get("options")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect::<Vec<_>>())
|
||||
.unwrap_or_else(|| vec!["同意", "拒绝"]);
|
||||
.map(String::from)
|
||||
.collect())
|
||||
.unwrap_or_else(|| vec!["同意".into(), "拒绝".into()]);
|
||||
|
||||
// 发送人工审批请求到事件总线
|
||||
let timeout_secs = config.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(3600);
|
||||
|
||||
// 1. 先订阅,再发请求 —— broadcast 不回放历史消息,
|
||||
// 若 subscribe 晚于 send,Response 会在 receiver 建位前发出而丢失,节点死等到超时。
|
||||
let mut rx = ctx.event_bus.subscribe();
|
||||
|
||||
// 2. 发送人工审批请求到事件总线
|
||||
let _ = ctx.event_bus.send(WorkflowEvent::HumanApprovalRequest {
|
||||
execution_id: ctx.execution_id.clone(),
|
||||
node_id: ctx.node_id.clone(),
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
options: options.iter().map(|s| s.to_string()).collect(),
|
||||
options: options.clone(),
|
||||
});
|
||||
|
||||
// 等待用户响应(最多等待 1 小时)
|
||||
let timeout = Duration::from_secs(3600);
|
||||
let start_time = std::time::Instant::now();
|
||||
// 3. select! 循环:Response / 超时 / 取消
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let mut cancel_tick = tokio::time::interval(Duration::from_millis(500));
|
||||
cancel_tick.tick().await; // 丢弃首个立即触发的 tick
|
||||
|
||||
loop {
|
||||
// 检查是否超时
|
||||
if start_time.elapsed() > timeout {
|
||||
return Err(anyhow::anyhow!("人工审批超时"));
|
||||
tokio::select! {
|
||||
recv = rx.recv() => match recv {
|
||||
Ok(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id, node_id, decision, comment,
|
||||
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
|
||||
// decision 合法性校验:
|
||||
// options 空 → 允许自由文本决策;
|
||||
// options 非空 → 强制 decision ∈ options,否则报错而非静默放行。
|
||||
// 空 decision 始终非法。
|
||||
if !decision.is_empty() && (options.is_empty() || options.contains(&decision)) {
|
||||
return Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"decision": decision,
|
||||
"comment": comment.unwrap_or_default(),
|
||||
})));
|
||||
}
|
||||
return Err(anyhow::anyhow!("审批决策非法: {}", decision));
|
||||
}
|
||||
Ok(_) => continue, // 其他节点/类型的事件,忽略
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(
|
||||
"HumanNode {} 漏收 {} 条事件(可能错过自身响应,继续等待)",
|
||||
ctx.node_id, n
|
||||
);
|
||||
// 容量 256 + 审批低频,漏自身 Response 概率极低;
|
||||
// 丢弃则可能误判超时,故 continue。
|
||||
continue;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
return Err(anyhow::anyhow!("事件总线关闭,审批无法完成"));
|
||||
}
|
||||
},
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
return Err(anyhow::anyhow!("人工审批超时({}s)", timeout_secs));
|
||||
}
|
||||
_ = cancel_tick.tick() => {
|
||||
if ctx.node_status.is_cancelled(&ctx.node_id) {
|
||||
return Err(anyhow::anyhow!("人工审批被取消"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查节点是否被取消
|
||||
if ctx.node_status.is_cancelled(&ctx.node_id) {
|
||||
return Err(anyhow::anyhow!("人工审批被取消"));
|
||||
}
|
||||
|
||||
// TODO: 检查审批响应(需要前端实现)
|
||||
// 目前直接返回同意
|
||||
let output = serde_json::json!({
|
||||
"decision": "同意",
|
||||
"comment": "",
|
||||
});
|
||||
return Ok(NodeOutput::from_value(output));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,3 +127,185 @@ impl Node for HumanNode {
|
||||
"human"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
use df_workflow::state::StateMachine;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 构造 NodeContext:自定义 execution_id / node_id / config,共享同一 EventBus。
|
||||
fn make_ctx(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
config: serde_json::Value,
|
||||
) -> NodeContext {
|
||||
NodeContext {
|
||||
node_id: node_id.to_string(),
|
||||
inputs: HashMap::new(),
|
||||
config,
|
||||
execution_id: execution_id.to_string(),
|
||||
event_bus: event_bus.clone(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
|
||||
async fn send_response(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
decision: &str,
|
||||
comment: Option<&str>,
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: decision.to_string(),
|
||||
comment: comment.map(String::from),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normal_approval_returns_decision() {
|
||||
// 正常审批:发匹配(exec_id+node_id) Response → 返回该 decision
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({}));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
// 给 execute 一点时间 subscribe + send Request
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "同意", Some("好的")).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
assert_eq!(out.data["comment"], json!("好的"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mismatched_execution_id_filtered_then_timeout() {
|
||||
// execution_id 不匹配:Response 被过滤,短超时验证 → Err 超时
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 错配的 execution_id
|
||||
send_response(&bus_clone, "exec-OTHER", "node-h", "同意", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "不匹配 exec_id 应被过滤后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mismatched_node_id_filtered_then_timeout() {
|
||||
// node_id 不匹配:Response 被过滤,短超时验证 → Err 超时
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// 错配的 node_id
|
||||
send_response(&bus_clone, "exec-1", "node-OTHER", "同意", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "不匹配 node_id 应被过滤后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_when_no_response() {
|
||||
// 无 Response → sleep_until(deadline) 触发超时
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "timeout_secs": 1 }));
|
||||
|
||||
let err = HumanNode.execute(ctx).await.unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "应报超时, 实际: {}", err);
|
||||
assert!(err.contains("1s"), "超时消息应带秒数, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_decision_rejected() {
|
||||
// options 非空,decision ∉ options → Err 决策非法
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-1",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"] }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "随便", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("非法"), "非法 decision 应报错, 实际: {}", err);
|
||||
assert!(err.contains("随便"), "错误信息应含 decision 值, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_options_allows_free_text() {
|
||||
// options 空 → 允许任意自由文本 decision(不校验)
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "改成先做B方案", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
out.data["decision"],
|
||||
json!("改成先做B方案"),
|
||||
"options 空应允许自由文本 decision"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_decision_rejected_even_with_empty_options() {
|
||||
// 空 decision 始终非法(即使 options 空也不允许空 decision)
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [] }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
HumanNode.execute(ctx).await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
send_response(&bus_clone, "exec-1", "node-h", "", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("非法"), "空 decision 应报错, 实际: {}", err);
|
||||
}
|
||||
|
||||
// Closed 分支覆盖:execute 持有的 ctx 含 EventBus clone(即一个 sender),
|
||||
// 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。
|
||||
// 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::SendError;
|
||||
use df_core::events::{WorkflowEvent, HumanApprovalResponse};
|
||||
use df_core::events::WorkflowEvent;
|
||||
|
||||
/// 事件总线
|
||||
#[derive(Debug)]
|
||||
@@ -44,13 +44,6 @@ impl EventBus {
|
||||
pub fn emit_human_approval_request(&self, event: WorkflowEvent) -> Result<usize, SendError<WorkflowEvent>> {
|
||||
self.sender.send(event)
|
||||
}
|
||||
|
||||
/// 尝试获取人工审批响应(简化版本,实际需要实现状态存储)
|
||||
pub fn try_recv_human_approval(&self, _execution_id: &str, _node_id: &str) -> Option<HumanApprovalResponse> {
|
||||
// TODO: 实现审批响应的存储和检索
|
||||
// 目前返回 None,需要配合前端实现
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBus {
|
||||
|
||||
@@ -16,14 +16,20 @@ pub struct DagExecutor {
|
||||
event_bus: EventBus,
|
||||
/// 节点状态机
|
||||
state_machine: StateMachine,
|
||||
/// 工作流执行 ID(由调用方传入,下沉到每个 NodeContext)
|
||||
execution_id: String,
|
||||
}
|
||||
|
||||
impl DagExecutor {
|
||||
/// 创建执行器
|
||||
pub fn new(event_bus: EventBus) -> Self {
|
||||
///
|
||||
/// `execution_id` 为本次工作流执行的唯一标识,会下沉到每个节点的 NodeContext,
|
||||
/// 用于节点内的事件关联、审计追踪等。
|
||||
pub fn new(event_bus: EventBus, execution_id: String) -> Self {
|
||||
Self {
|
||||
event_bus,
|
||||
state_machine: StateMachine::new(),
|
||||
execution_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,9 +79,10 @@ impl DagExecutor {
|
||||
node_id: node_id.clone(),
|
||||
inputs,
|
||||
config: initial_config.clone(),
|
||||
execution_id: "dummy-execution-id".to_string(), // TODO: 应该从 workflow_execution 获取
|
||||
execution_id: self.execution_id.clone(),
|
||||
event_bus: self.event_bus.clone(),
|
||||
node_status: StateMachine::new(),
|
||||
// 共享执行器状态机,使节点(如 HumanNode)能读取真实状态而非空状态机
|
||||
node_status: self.state_machine.clone(),
|
||||
};
|
||||
|
||||
// 仅捕获节点共享引用与所有权上下文,避免与 self 借用冲突
|
||||
@@ -196,7 +203,7 @@ mod tests {
|
||||
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());
|
||||
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();
|
||||
@@ -219,7 +226,7 @@ mod tests {
|
||||
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());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let err = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
|
||||
@@ -101,7 +101,7 @@ pub async fn run_workflow(
|
||||
let db = state.db.clone();
|
||||
let exec_id = execution_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut executor = DagExecutor::new(event_bus.clone());
|
||||
let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone());
|
||||
let result = executor.run(&runtime_dag, config).await;
|
||||
|
||||
let (status, error) = match &result {
|
||||
|
||||
Reference in New Issue
Block a user