//! 任务推进链 DAG 模板(F-260616-06 阶段2 / D-260616-03) //! //! 推进链前向转换的工作流拓扑描述(声明式,纯数据)。DagDef 只描述节点与边, //! 执行逻辑靠 DagExecutor 驱动各 Node trait 的 execute —— 模板本身不跑逻辑。 //! //! 三模板对应推进链三个前向转换点: //! - "in_progress": todo→in_progress,单 AiNode 执行任务 //! - "testing": in_review→testing,AiNode 自审 → HumanNode 核对(human reject 走 Err //! 触发工作流 failed → ②-4 回调退回 in_review) //! - "done": testing→done,单 HumanNode 最终核对 //! //! 不建 workflow_defs 表(模板少且稳定,硬编码;tasks.workflow_def_id 留 None)。 //! //! config 设计:节点级 config 不硬编码 task_id / provider 等运行时参数 —— 全部留空。 //! 运行时 run_workflow(task_id, target_status) 调用方注入全局 config(task_id / //! target_status / provider 配置),由 ④-1 deep_merge 与节点级 config 合并 //! (节点级覆盖全局)。模板只描述拓扑,不绑定具体任务。 use df_workflow::dag_def::DagDef; /// 按目标状态返回推进链工作流模板。 /// /// `target_status` 取推进链前向目标状态(in_progress / testing / done)。 /// 未匹配返回 None —— 调用方降级(如直接 IPC 推进,不启工作流),不 panic。 /// /// 返回的 DagDef 节点级 config 为空对象或仅含拓扑级元信息(label 等), /// 运行时参数由调用方全局 config 注入 + deep_merge。 pub fn template_for(target_status: &str) -> Option { match target_status { "in_progress" => Some(in_progress_template()), "testing" => Some(testing_template()), "done" => Some(done_template()), _ => None, } } /// todo → in_progress:单 AiNode 执行任务。 /// /// 拓扑:1 个 ai 节点。完成回调(②-3)推进 status 到 in_progress。 fn in_progress_template() -> DagDef { let mut dag = DagDef::new(); // config 留空:prompt / provider / task_id 均由 run_workflow 全局 config 注入 // (④-1 deep_merge 合并),模板只描述拓扑。 dag.add_node( "ai_execute", "ai", serde_json::json!({}), ); dag } /// in_review → testing:AiNode 自审 → HumanNode 核对。 /// /// 拓扑:ai → human 串行。HumanNode reject 走 Err(②-5 已做)→ 工作流 failed → /// ②-4 失败回调退回 in_review(review_rounds+=1)。 /// 通过则完成回调(②-3)推进 status 到 testing。 fn testing_template() -> DagDef { let mut dag = DagDef::new(); dag.add_node("ai_self_review", "ai", serde_json::json!({})); dag.add_node( "human_review", "human", serde_json::json!({ // 拓扑级元信息(非运行时参数):审批卡片默认文案。options 含「拒绝」 // 触发 ②-5 reject → Err → 工作流 failed。 "title": "核对 AI 自审结果", "options": ["同意", "拒绝"], }), ); dag.add_edge("ai_self_review", "human_review"); dag } /// testing → done:单 HumanNode 最终核对。 /// /// 拓扑:1 个 human 节点。通过则完成回调(②-3)推进 status 到 done。 /// reject 同理走 Err → failed → ②-4 退回。 fn done_template() -> DagDef { let mut dag = DagDef::new(); dag.add_node( "human_final_review", "human", serde_json::json!({ "title": "最终核对", "options": ["同意", "拒绝"], }), ); dag } // ============================================================ // 单元测试 — 模板拓扑断言 // ============================================================ #[cfg(test)] mod tests { use super::*; #[test] fn unknown_status_returns_none() { assert!(template_for("unknown").is_none()); assert!(template_for("").is_none()); assert!(template_for("merged").is_none()); } #[test] fn in_progress_template_single_ai_node() { let dag = template_for("in_progress").expect("in_progress 模板存在"); assert_eq!(dag.nodes.len(), 1, "in_progress: 单节点"); assert!(dag.edges.is_empty(), "in_progress: 无边"); let node = dag.nodes.get("ai_execute").expect("ai_execute 节点存在"); assert_eq!(node.node_type, "ai"); // config 留空(运行时注入) assert!( node.config.as_object().map(|o| o.is_empty()).unwrap_or(true), "ai_execute config 应为空对象" ); } #[test] fn testing_template_ai_to_human_serial() { let dag = template_for("testing").expect("testing 模板存在"); assert_eq!(dag.nodes.len(), 2, "testing: ai + human 两节点"); assert_eq!(dag.edges.len(), 1, "testing: 单串行边"); // 节点类型 let ai = dag.nodes.get("ai_self_review").expect("ai_self_review 存在"); assert_eq!(ai.node_type, "ai"); let human = dag.nodes.get("human_review").expect("human_review 存在"); assert_eq!(human.node_type, "human"); // 边方向:ai → human let edge = &dag.edges[0]; assert_eq!(edge.source, "ai_self_review"); assert_eq!(edge.target, "human_review"); // human options 含「拒绝」(触发 ②-5 reject) let options = human .config .get("options") .and_then(|v| v.as_array()) .expect("human_review options 存在"); let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect(); assert!(opts.contains(&"拒绝"), "options 应含「拒绝」触发 reject"); } #[test] fn done_template_single_human_node() { let dag = template_for("done").expect("done 模板存在"); assert_eq!(dag.nodes.len(), 1, "done: 单节点"); assert!(dag.edges.is_empty(), "done: 无边"); let node = dag.nodes.get("human_final_review").expect("human_final_review 存在"); assert_eq!(node.node_type, "human"); } // ---------- ②-3 / ②-4 回调语义对齐(CR-06 观察项 ② 测试补强) ---------- // // 工作流完成(②-3)/失败(②-4)回调在 src-tauri/src/commands/workflow.rs spawn 闭包里, // 回调的核心计算是:template_for 选模板 + regression_target(target) 推退回态 + // advance_task_atomic 落库。这里锁定 template_for 三模板对回调语义的约束, // 保证回调侧(workflow.rs)的 target_status 取值与模板存在性一致 —— // 即回调只会对 template_for 返回 Some 的三个 target 触发,其余 target 模板不存在、 // 工作流压根不会启动,回调自然不触发。 /// 三模板对应的 target_status 集合(workflow.rs ②-3/②-4 回调仅对这些 target 触发) const CALLBACK_TARGETS: &[&str] = &["in_progress", "testing", "done"]; #[test] fn callback_targets_all_have_templates() { // ②-3/②-4 回调依赖 template_for(target) 非空 —— 凡是能进回调的 target 必有模板。 // 若新增一个回调 target 但忘了加模板,这里会先红。 for target in CALLBACK_TARGETS { assert!( template_for(target).is_some(), "回调 target {target:?} 必须有对应模板(template_for 非 None)" ); } } #[test] fn non_callback_targets_have_no_template() { // 反向:非推进链 target(todo/in_review/blocked/cancelled/未知) 无模板, // 工作流不启动,②-3/②-4 回调不触发 —— 锁定回调边界。 for non_target in ["todo", "in_review", "blocked", "cancelled", "merged", ""] { assert!( template_for(non_target).is_none(), "非推进链 target {non_target:?} 不应有模板(否则回调边界被扩大)" ); } } #[test] fn testing_and_done_templates_have_reject_path() { // ②-4 失败回调依赖模板内 human reject 走 Err → 工作流 failed → regression_target。 // testing(done 同理)模板的 human 节点 options 必须含「拒绝」,否则失败回调不可达。 // (in_progress 模板只有 ai 节点,失败是 ai 异常,无 reject 路径 —— 单独验证。) for target in ["testing", "done"] { let dag = template_for(target).unwrap_or_else(|| panic!("{target} 模板应存在")); let human = dag.nodes.values().find(|n| n.node_type == "human").unwrap_or_else(|| { panic!("{target} 模板应含 human 节点(承载 reject → failed → ②-4 回调)") }); let options = human .config .get("options") .and_then(|v| v.as_array()) .unwrap_or_else(|| panic!("{target} human 节点 config 应有 options 数组")); let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect(); assert!( opts.contains(&"拒绝"), "{target} human options 应含「拒绝」(否则 ②-4 失败回调路径不可达)" ); } } }