Files
DevFlow/crates/df-nodes/src/task_workflow_templates.rs

229 lines
10 KiB
Rust

//! 任务推进链 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<DagDef> {
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 串行。阶段3(本批)起 ai_self_review 启用 gate:true:
/// - verdict=fail → AiSelfReviewNode 返回 Err → 工作流 failed(不经 human_review)
/// → ②-4 失败回调退回 in_review(review_rounds+=1)。
/// - verdict=unknown/pass → 放行 human_review,人定最终是否推进。
/// HumanNode reject 走 Err(②-5 已做)→ 工作流 failed → ②-4 失败回调退回 in_review。
/// 通过则完成回调(②-3)推进 status 到 testing。
fn testing_template() -> DagDef {
let mut dag = DagDef::new();
// 决策 a 步骤③:ai_self_review 节点类型对齐 state.rs 注册的独立自审节点
// (四维度 prompt + JSON 解析兜底 + 写回 output_json 加 review 子字段)。
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游,工作流 failed)。
dag.add_node(
"ai_self_review",
"ai_self_review",
serde_json::json!({ "gate": true }),
);
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: 单串行边");
// 节点类型(决策 a 步骤③:ai_self_review 独立节点类型)
let ai = dag.nodes.get("ai_self_review").expect("ai_self_review 存在");
assert_eq!(ai.node_type, "ai_self_review");
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游)
let gate = ai
.config
.get("gate")
.and_then(|v| v.as_bool())
.expect("ai_self_review config 应含 gate");
assert!(gate, "testing 模板 ai_self_review 应启用 gate:true(阶段3 闸门)");
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 失败回调路径不可达)"
);
}
}
}