重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复
- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
This commit is contained in:
@@ -47,6 +47,10 @@ fn parse_params(
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
|
||||
.to_string();
|
||||
// 空 key 早失败:避免空 key 发请求吃 401,错误伪装成"API Key 无效"(与 src-tauri secret::ensure_resolved_key 行为对齐)
|
||||
if api_key.trim().is_empty() {
|
||||
anyhow::bail!("AiNode api_key 为空(请检查节点配置或密钥注入链路),已阻止请求避免 401 误导");
|
||||
}
|
||||
|
||||
// ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ──
|
||||
let prompt = inputs
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use df_core::events::WorkflowEvent;
|
||||
use df_core::events::{SelectType, WorkflowEvent};
|
||||
|
||||
/// 人工审批节点(阻塞节点)
|
||||
pub struct HumanNode;
|
||||
@@ -33,6 +33,12 @@ impl Node for HumanNode {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(3600);
|
||||
|
||||
// F-260615-01: 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
|
||||
let select_type = match config.get("select_type").and_then(|v| v.as_str()) {
|
||||
Some("multiple") => SelectType::Multiple,
|
||||
_ => SelectType::Single,
|
||||
};
|
||||
|
||||
// 1. 先订阅,再发请求 —— broadcast 不回放历史消息,
|
||||
// 若 subscribe 晚于 send,Response 会在 receiver 建位前发出而丢失,节点死等到超时。
|
||||
let mut rx = ctx.event_bus.subscribe();
|
||||
@@ -46,6 +52,7 @@ impl Node for HumanNode {
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
options: options.clone(),
|
||||
select_type,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -58,19 +65,43 @@ impl Node for HumanNode {
|
||||
tokio::select! {
|
||||
recv = rx.recv() => match recv {
|
||||
Ok(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id, node_id, decision, comment,
|
||||
execution_id, node_id, decision, decisions, 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)) {
|
||||
// F-260615-01: 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
|
||||
// select_type=Single → 决策数必须 =1
|
||||
// select_type=Multiple → 决策数必须 ≥1
|
||||
// options 空 → 允许自由文本(仅受数量约束);
|
||||
// options 非空 → 每项必须 ∈ options
|
||||
// 兼容旧调用方:decisions 空但 decision 非空时,按 [decision] 单值处理。
|
||||
let mut picked: Vec<String> = decisions;
|
||||
if picked.is_empty() && !decision.is_empty() {
|
||||
picked.push(decision.clone());
|
||||
}
|
||||
let count_ok = match select_type {
|
||||
SelectType::Single => picked.len() == 1,
|
||||
SelectType::Multiple => !picked.is_empty(),
|
||||
};
|
||||
let each_valid = picked.iter().all(|d| !d.is_empty())
|
||||
&& (options.is_empty() || picked.iter().all(|d| options.contains(d)));
|
||||
if count_ok && each_valid {
|
||||
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
|
||||
let primary = picked.first().cloned().unwrap_or_default();
|
||||
return Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"decision": decision,
|
||||
"decision": primary,
|
||||
"decisions": picked,
|
||||
"comment": comment.unwrap_or_default(),
|
||||
})));
|
||||
}
|
||||
return Err(anyhow::anyhow!("审批决策非法: {}", decision));
|
||||
tracing::warn!(
|
||||
node_id = %ctx.node_id,
|
||||
execution_id = %ctx.execution_id,
|
||||
select_type = ?select_type,
|
||||
decision = %decision,
|
||||
decisions = ?picked,
|
||||
options = ?options,
|
||||
"审批决策非法,忽略并继续等待下一条合法 Response(由超时兜底)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(_) => continue, // 其他节点/类型的事件,忽略
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
@@ -108,6 +139,11 @@ impl Node for HumanNode {
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"select_type": {
|
||||
"type": "string",
|
||||
"enum": ["single", "multiple"],
|
||||
"default": "single"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
@@ -116,6 +152,7 @@ impl Node for HumanNode {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": { "type": "string" },
|
||||
"decisions": { "type": "array", "items": { "type": "string" } },
|
||||
"comment": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
@@ -134,7 +171,10 @@ impl Node for HumanNode {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_core::types::NodeStatus;
|
||||
use df_workflow::dag::Dag;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
use df_workflow::executor::DagExecutor;
|
||||
use df_workflow::state::StateMachine;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@@ -158,6 +198,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
|
||||
/// F-260615-01: 单选调用方仅填 decision;多选调用方填 decisions。
|
||||
async fn send_response(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
@@ -170,6 +211,26 @@ mod tests {
|
||||
execution_id: execution_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: decision.to_string(),
|
||||
decisions: vec![],
|
||||
comment: comment.map(String::from),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// F-260615-01: 多选响应发送助手(填 decisions 数组,decision 留空)
|
||||
async fn send_response_multi(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
decisions: &[&str],
|
||||
comment: Option<&str>,
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: String::new(),
|
||||
decisions: decisions.iter().map(|s| s.to_string()).collect(),
|
||||
comment: comment.map(String::from),
|
||||
})
|
||||
.await;
|
||||
@@ -279,14 +340,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_decision_rejected() {
|
||||
// options 非空,decision ∉ options → Err 决策非法
|
||||
async fn invalid_decision_ignored_then_timeout() {
|
||||
// R-P1-6: options 非空且 decision ∉ options → 不立即 Err(一次手误不应杀死节点),
|
||||
// warn 记录 + continue 续等下一条合法 Response;此处仅发一条非法的,短超时验证 → Err 超时。
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-1",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"] }),
|
||||
json!({ "options": ["同意", "拒绝"], "timeout_secs": 1 }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
@@ -298,8 +360,7 @@ mod tests {
|
||||
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);
|
||||
assert!(err.contains("超时"), "非法 decision 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -325,10 +386,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_decision_rejected_even_with_empty_options() {
|
||||
// 空 decision 始终非法(即使 options 空也不允许空 decision)
|
||||
async fn empty_decision_ignored_then_timeout_even_with_empty_options() {
|
||||
// R-P1-6: 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [] }));
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
@@ -339,10 +400,240 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
|
||||
// Closed 分支覆盖:execute 持有的 ctx 含 EventBus clone(即一个 sender),
|
||||
// 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。
|
||||
// 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。
|
||||
|
||||
// ===== B-03b-R8: executor 级端到端集成测试 =====
|
||||
// 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。
|
||||
// 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、
|
||||
// outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。
|
||||
|
||||
/// 简单测试节点:sleep 指定毫秒后返回空输出(仿 executor.rs SleepNode,作 human 的前驱)
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
/// B-03b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。
|
||||
/// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。
|
||||
/// 验证:Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。
|
||||
#[tokio::test]
|
||||
async fn end_to_end_human_approval_completes_workflow() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 executor send(broadcast 不回放)
|
||||
let exec_id = "exec-e2e-1";
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_node("b".to_string(), Box::new(HumanNode));
|
||||
dag.add_edge("a".to_string(), "b".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor
|
||||
.run(
|
||||
&dag,
|
||||
json!({ "title": "确认发布", "options": ["同意", "拒绝"], "timeout_secs": 5 }),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// 等 a 层完成、b 层 human subscribe + send Request。
|
||||
// executor 先发 NodeStarted(a)/NodeStarted(b),再发 human 的 Request —— 过滤跳过非 Request 事件
|
||||
let request = tokio::time::timeout(Duration::from_millis(1000), async {
|
||||
loop {
|
||||
if let Ok(ev @ WorkflowEvent::HumanApprovalRequest { .. }) = rx.recv().await {
|
||||
return ev;
|
||||
}
|
||||
// NodeStarted/NodeCompleted 等其他事件,继续等
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
request.is_ok(),
|
||||
"应收到 b 的 HumanApprovalRequest(R6 端到端),实际超时"
|
||||
);
|
||||
match request.unwrap() {
|
||||
WorkflowEvent::HumanApprovalRequest { node_id, title, options, .. } => {
|
||||
assert_eq!(node_id, "b");
|
||||
assert_eq!(title, "确认发布");
|
||||
assert_eq!(options, vec!["同意".to_string(), "拒绝".to_string()]);
|
||||
}
|
||||
other => panic!("期望 HumanApprovalRequest,收到 {:?}", other),
|
||||
}
|
||||
|
||||
// 外部 IPC 模拟:审批通过
|
||||
send_response(&bus, exec_id, "b", "同意", Some("可以发布")).await;
|
||||
|
||||
let outputs = run_handle.await.unwrap().unwrap();
|
||||
assert!(outputs.contains_key("a"), "outputs 应含前驱节点 a");
|
||||
assert_eq!(outputs["b"].data["decision"], json!("同意"));
|
||||
assert_eq!(outputs["b"].data["decisions"], json!(["同意"]));
|
||||
assert_eq!(outputs["b"].data["comment"], json!("可以发布"));
|
||||
assert_eq!(sm.get(&"a".to_string()), NodeStatus::Completed);
|
||||
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
|
||||
}
|
||||
|
||||
/// B-03b-R8: executor 驱动 human DAG,外部 set_cancelled(模拟 cancel_workflow_node IPC)
|
||||
/// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态,R1)
|
||||
/// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。
|
||||
#[tokio::test]
|
||||
async fn end_to_end_human_approval_cancelled() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 executor send
|
||||
let exec_id = "exec-e2e-cancel";
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("h".to_string(), Box::new(HumanNode));
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine();
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor.run(&dag, json!({ "timeout_secs": 5 })).await
|
||||
});
|
||||
|
||||
// 等 human 发出 Request(确认已 subscribe + send + 进入 select! 轮询)
|
||||
let saw_request = tokio::time::timeout(Duration::from_millis(1000), async {
|
||||
while !matches!(rx.recv().await, Ok(WorkflowEvent::HumanApprovalRequest { .. })) {}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
assert!(saw_request, "human 应先发出 HumanApprovalRequest");
|
||||
|
||||
// 外部 IPC 模拟:取消节点(写共享 state_machine,human cancel_tick 会读到)
|
||||
sm.set_cancelled("h".to_string());
|
||||
|
||||
let result = run_handle.await.unwrap();
|
||||
assert!(result.is_err(), "取消应致 run 返回 Err");
|
||||
let err = result.unwrap_err().to_string();
|
||||
// executor 把节点 Err 包成 "节点 h 执行失败"(context),原 "人工审批被取消" 在 source chain
|
||||
assert!(
|
||||
err.contains("取消") || err.contains("执行失败"),
|
||||
"应返回取消相关错误,实际: {}",
|
||||
err
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_failed 覆盖为 Failed)—— R1 修法端到端验证
|
||||
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
|
||||
}
|
||||
|
||||
// ===== F-260615-01: 多选审批覆盖 =====
|
||||
|
||||
/// F-260615-01: select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
|
||||
#[tokio::test]
|
||||
async fn multiple_select_returns_decisions_array() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["A", "B", "C"],
|
||||
"select_type": "multiple"
|
||||
}),
|
||||
);
|
||||
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_multi(&bus_clone, "exec-multi", "node-h", &["A", "C"], Some("多选")).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("A"), "decision 取首项");
|
||||
assert_eq!(out.data["decisions"], json!(["A", "C"]));
|
||||
assert_eq!(out.data["comment"], json!("多选"));
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
|
||||
#[tokio::test]
|
||||
async fn single_select_rejects_multiple_decisions_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-single-multi",
|
||||
"node-h",
|
||||
json!({ "options": ["A", "B"], "timeout_secs": 1 }), // 缺省 single
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// single 模式却发多个 → 非法
|
||||
send_response_multi(&bus_clone, "exec-single-multi", "node-h", &["A", "B"], None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "single 下多 decisions 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
|
||||
#[tokio::test]
|
||||
async fn multiple_select_invalid_option_ignored_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi-bad",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["A", "B"],
|
||||
"select_type": "multiple",
|
||||
"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;
|
||||
// 一项合法一项不合法 → 整批非法
|
||||
send_response_multi(&bus_clone, "exec-multi-bad", "node-h", &["A", "X"], None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "含非法 option 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
|
||||
/// (即所有未改造的现有 Request 均按 single 解析,零改动)
|
||||
#[tokio::test]
|
||||
async fn default_single_with_legacy_decision_single_value() {
|
||||
let bus = EventBus::new();
|
||||
// 注意:不传 select_type,验证缺省=Single
|
||||
let ctx = make_ctx(&bus, "exec-leg", "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;
|
||||
// 旧链路只填 decision,decisions 为空 → HumanNode 兼容回退
|
||||
send_response(&bus_clone, "exec-leg", "node-h", "同意", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user