重构: 后端 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:
2026-06-15 05:14:42 +08:00
parent 04032a2a8d
commit 2de0c6ecb7
37 changed files with 2457 additions and 484 deletions

View File

@@ -61,24 +61,6 @@ impl Dag {
});
}
/// 获取指定节点的所有上游节点 ID
pub fn predecessors(&self, node_id: &NodeId) -> Vec<NodeId> {
self.edges
.iter()
.filter(|e| &e.target == node_id)
.map(|e| e.source.clone())
.collect()
}
/// 获取指定节点的所有下游节点 ID
pub fn successors(&self, node_id: &NodeId) -> Vec<NodeId> {
self.edges
.iter()
.filter(|e| &e.source == node_id)
.map(|e| e.target.clone())
.collect()
}
/// 拓扑排序 — 返回按执行顺序排列的节点 ID 层级
///
/// 返回 Vec<Vec<NodeId>>,每层内的节点可以并行执行。

View File

@@ -3,8 +3,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::dag::Dag;
/// DAG 定义 — 可序列化/反序列化,用于持久化和模板
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DagDef {
@@ -73,28 +71,6 @@ impl DagDef {
condition: Some(condition.into()),
});
}
/// 从已有的运行时 Dag 提取定义(忽略节点实例,仅保留元数据)
///
/// 注意:此方法只能提取边的定义,节点配置无法从 trait object 反推
pub fn from_dag_edges(dag: &Dag) -> Self {
let edges: Vec<EdgeDef> = dag.edges.iter().map(|e| EdgeDef {
source: e.source.clone(),
target: e.target.clone(),
condition: e.condition.clone(),
}).collect();
let nodes: HashMap<String, NodeDef> = dag.nodes.iter().map(|(id, node)| {
(id.clone(), NodeDef {
id: id.clone(),
node_type: node.node_type().to_string(),
config: serde_json::Value::Null,
label: None,
})
}).collect();
Self { nodes, edges }
}
}
impl Default for DagDef {

View File

@@ -123,7 +123,13 @@ impl DagExecutor {
for (node_id, result, duration) in results {
match result {
Ok(output) => {
self.state_machine.set_completed(node_id.clone())?;
// 已取消的节点跳过 set_completed:
// Ok 后 join_all 让出执行权,窗口内 cancel_workflow_node IPC → set_cancelled 改 Cancelled,
// 随后此处 set_completed 走 transition 命中(Cancelled→Completed)非法 → bail 致已批准审批报失败(R-P1-3 TOCTOU)
// 与 Err 分支 is_cancelled 短路对称:已取消节点保持 Cancelled 终态
if !self.state_machine.is_cancelled(&node_id) {
self.state_machine.set_completed(node_id.clone())?;
}
self.event_bus
.send(WorkflowEvent::NodeCompleted {
node_id: node_id.clone(),
@@ -314,4 +320,53 @@ mod tests {
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_core::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
);
}
}

View File

@@ -42,14 +42,23 @@ impl NodeRegistry {
pub fn build_dag(&self, def: &DagDef) -> anyhow::Result<Dag> {
let mut dag = Dag::new();
// 创建所有节点实例
// 创建所有节点实例,同时收集节点 ID 集合用于边校验
let mut node_ids: std::collections::HashSet<&String> = std::collections::HashSet::new();
for (id, node_def) in &def.nodes {
let node = self.create(&node_def.node_type, &node_def.config)?;
dag.add_node(id.clone(), node);
node_ids.insert(id);
}
// 添加所有边
// 添加所有边 — 校验两端节点均存在,野边早返回
for edge_def in &def.edges {
if !node_ids.contains(&edge_def.source) || !node_ids.contains(&edge_def.target) {
anyhow::bail!(
"边 source/target 节点不存在: {} -> {}",
edge_def.source,
edge_def.target
);
}
match &edge_def.condition {
Some(cond) => dag.add_edge_with_condition(
edge_def.source.clone(),
@@ -62,16 +71,6 @@ impl NodeRegistry {
Ok(dag)
}
/// 检查是否已注册指定类型
pub fn is_registered(&self, type_name: &str) -> bool {
self.factories.contains_key(type_name)
}
/// 列出所有已注册的节点类型
pub fn registered_types(&self) -> Vec<&str> {
self.factories.keys().map(|s| s.as_str()).collect()
}
}
// 不实现 Default:原 Default 注册了一个会 panic 的 script 工厂(unimplemented!),

View File

@@ -83,26 +83,15 @@ impl StateMachine {
self.transition(node_id, NodeStatus::Failed)
}
/// 设置节点为等待中(暂未纳入转换校验
pub fn set_waiting(&self, node_id: NodeId) {
self.states
.lock()
.expect("状态机锁中毒")
.insert(node_id, NodeStatus::Waiting);
}
/// 设置节点为已跳过(暂未纳入转换校验)
pub fn set_skipped(&self, node_id: NodeId) {
self.states
.lock()
.expect("状态机锁中毒")
.insert(node_id, NodeStatus::Skipped);
}
/// 设置节点为已取消(暂未纳入转换校验,同 set_waiting/set_skipped 模式)
/// 设置节点为已取消 — 唯一受控旁路:不经 transition 合法性校验
///
/// 人工审批取消场景由前端 cancel_workflow_node IPC 触发
/// 阻塞节点(如 HumanNode)在 select! 轮询分支通过 is_cancelled 检测到后返回 Err。
/// 设计理由:人工审批取消场景由前端 cancel_workflow_node IPC 异步触发
/// 此时节点可能处于 Running 之外的任意态(如 Pending 排队中、阻塞等待审批中),
/// 若走 transition 校验会被 is_legal(Pending→Cancelled) 拒绝。
/// 故 set_cancelled 作为唯一受控旁路直接置位 Cancelled
/// 阻塞节点(如 HumanNode)在 select! 轮询分支通过 is_cancelled 检测后返回 Err。
///
/// 注:原 set_waiting/set_skipped 同为旁路置位但全仓零调用,已删除。
pub fn set_cancelled(&self, node_id: NodeId) {
self.states
.lock()
@@ -177,7 +166,7 @@ mod tests {
#[test]
fn test_set_cancelled_marks_node_cancelled() {
// set_cancelled 直接置位,不经转换校验(同 set_waiting/set_skipped 模式)
// set_cancelled 为唯一受控旁路,不经转换校验直接置位 Cancelled
let sm = StateMachine::new();
let id = "human-1".to_string();