新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地

This commit is contained in:
2026-06-16 12:41:13 +08:00
parent 212a927eee
commit 7d5cd4c89a
62 changed files with 4576 additions and 248 deletions

View File

@@ -6,6 +6,32 @@ use tokio::sync::broadcast;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
use df_core::events::{SelectType, WorkflowEvent};
/// F-260616-06 阶段2: 拒绝语义化关键字。
/// decision 归一化(去空白 + 小写)后命中此集合 → 审批拒绝 → 节点返 Err(触发工作流 failed)。
///
/// 识别范围(避免误伤):
/// - 仅当节点 options 非空且该 decision ∈ options 时拒绝 —— 前端从受控选项中选择,语义可信;
/// - options 空(自由文本)时同样按关键字判定 —— 仅当文本明确为拒绝词才拒绝,
/// 其余自由文本一律视为通过(向后兼容,不阻断自由反馈)。
///
/// 关键字集合与前端/IPC 的 options 命名约定对齐:「拒绝/驳回/退回」中文 +
/// 「reject/decline/deny/no/block」英文覆盖默认 options `["同意","拒绝"]`。
const REJECT_KEYWORDS: &[&str] = &[
"拒绝", "驳回", "退回", "否决",
"reject", "decline", "declined", "deny", "denied", "no", "block",
];
/// 判定单个 decision 是否为拒绝选项(归一化后命中 REJECT_KEYWORDS
fn is_reject_decision(d: &str) -> bool {
let normalized = d.trim().to_lowercase();
REJECT_KEYWORDS.iter().any(|k| normalized == *k)
}
/// 判定 picked 集合是否含拒绝选项:任一项命中即视为整单拒绝(多选场景「选了驳回」即驳回)。
fn contains_reject(picked: &[String]) -> bool {
picked.iter().any(|d| is_reject_decision(d))
}
/// 人工审批节点(阻塞节点)
pub struct HumanNode;
@@ -84,6 +110,26 @@ impl Node for HumanNode {
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 {
// F-260616-06 阶段2: 拒绝语义化。
// 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"),
// 下游无法据 failed 触发退回/重做。
// 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err
// "人工审批被拒绝(用户选择: <decision>)",executor Err 分支 set_failed
// → 工作流 failed 状态 → 阶段2 推进链可据 failed 触发退回。
// 行为变更:审批拒绝从 Ok → Err,标注(同步通知主代理)。
if contains_reject(&picked) {
let primary = picked.first().cloned().unwrap_or_default();
let comment_str = comment.unwrap_or_default();
return Err(anyhow::anyhow!(
"人工审批被拒绝(用户选择: {}){}",
primary,
if comment_str.is_empty() {
String::new()
} else {
format!(";意见: {}", comment_str)
}
));
}
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
let primary = picked.first().cloned().unwrap_or_default();
return Ok(NodeOutput::from_value(serde_json::json!({
@@ -636,4 +682,169 @@ mod tests {
assert_eq!(out.data["decision"], json!("同意"));
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
}
// ===== F-260616-06 阶段2: 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
/// F-260616-06: 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err不再 Ok
/// 阶段2 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
#[tokio::test]
async fn reject_decision_returns_error() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-reject",
"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-reject", "node-h", "拒绝", Some("方案不行")).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(
err.contains("拒绝") && err.contains("人工审批"),
"拒绝 decision 应返 Err「人工审批被拒绝...」, 实际: {}",
err
);
assert!(
err.contains("方案不行"),
"拒绝 Err 应携带 comment, 实际: {}",
err
);
}
/// F-260616-06: 同一 options 下选「同意」→ Ok通过路径不回归
#[tokio::test]
async fn approve_decision_still_ok() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-approve",
"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-approve", "node-h", "同意", None).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(out.data["decision"], json!("同意"));
}
/// F-260616-06: 英文 reject 关键字同样识别为拒绝 → Err归一化大小写/空白)。
/// 多关键字覆盖走 reject_keyword_detection_normalized 纯单元测试,此处仅验证端到端一条。
#[tokio::test]
async fn english_reject_keyword_returns_error() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-en",
"node-h",
json!({ "options": ["approve", "reject"], "timeout_secs": 5 }),
);
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-en", "node-h", "reject", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(
err.contains("拒绝"),
"英文 reject 关键字应返 Err, 实际: {}",
err
);
}
/// F-260616-06: 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
/// (选了「驳回」即驳回,即便同时选了「同意」)。
#[tokio::test]
async fn multiple_select_with_one_reject_returns_error() {
let bus = EventBus::new();
let ctx = make_ctx(
&bus,
"exec-multi-reject",
"node-h",
json!({
"options": ["同意", "驳回", "备注"],
"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-reject",
"node-h",
&["同意", "驳回"],
Some("驳回其中一项"),
)
.await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("拒绝"), "多选含拒绝项应返 Err, 实际: {}", err);
}
/// F-260616-06: options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
/// 其余自由文本(非拒绝词)仍按通过处理(向后兼容,不阻断自由反馈)。
#[tokio::test]
async fn empty_options_free_text_reject_keyword_still_errors() {
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-free-reject", "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-free-reject", "node-h", "拒绝", None).await;
let err = handle.await.unwrap().unwrap_err().to_string();
assert!(err.contains("拒绝"), "自由文本明确为拒绝词仍应 Err, 实际: {}", err);
}
/// F-260616-06: options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok不误伤自由反馈
/// empty_options_allows_free_text 已覆盖 "改成先做B方案" → Ok,此处补一条非拒绝中文短句。)
#[tokio::test]
async fn empty_options_non_reject_free_text_still_ok() {
let bus = EventBus::new();
let ctx = make_ctx(&bus, "exec-free-ok", "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-free-ok", "node-h", "再讨论一下", None).await;
let out = handle.await.unwrap().unwrap();
assert_eq!(out.data["decision"], json!("再讨论一下"));
}
/// F-260616-06 单元: 关键字判定函数归一化(去空白+小写)与边界。
#[test]
fn reject_keyword_detection_normalized() {
assert!(is_reject_decision("拒绝"));
assert!(is_reject_decision(" 拒绝 "));
assert!(is_reject_decision("Reject"));
assert!(is_reject_decision(" decline "));
assert!(is_reject_decision("驳回"));
assert!(is_reject_decision("退回"));
assert!(!is_reject_decision("同意"));
assert!(!is_reject_decision("approve"));
assert!(!is_reject_decision("再讨论"));
assert!(!is_reject_decision(""));
// 不是关键字开头,是整词匹配 —— "拒绝啦" 不应误判
assert!(!is_reject_decision("拒绝啦"));
assert!(contains_reject(&["同意".into()]) == false);
assert!(contains_reject(&["同意".into(), "拒绝".into()]) == true);
}
}

View File

@@ -5,3 +5,4 @@ pub mod human_node;
pub mod script_node;
pub mod task_advance_node;
pub mod task_state_machine;
pub mod task_workflow_templates;

View File

@@ -30,9 +30,12 @@ use crate::task_state_machine::{can_transition, is_regression, is_valid_state, A
/// - `target_status`:目标状态(7 态之一,snake_case)
///
/// 流程:
/// 1. 任务存在性:找不到 → InvalidState 错误(复用 Error::InvalidState 语义,前端可据此提示)
/// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示)
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链)
/// 3. 状态机:can_transition(from, to) 不合法 → InvalidState 错误(含 from/to 上下文)
/// 3. 状态机(三类拒绝,错误区分供前端分辨):
/// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作)
/// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文)
/// - can_transition 闸门矩阵判否即此分支
/// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
///
@@ -57,17 +60,18 @@ pub async fn advance_task_atomic(
.await?
.ok_or_else(|| df_core::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
// 3. 状态机校验(同态拒绝:todo→todo 等,can_transition 返回 false)。
// 3. 状态机校验(三类拒绝,错误类型区分供前端分辨):
// - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例)
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
let from = current.status.as_str();
if from == target_status {
return Err(df_core::error::Error::InvalidState {
current: from.to_string(),
expected: target_status.to_string(),
});
return Err(df_core::error::Error::Validation(format!(
"相同状态 {from:?},无需推进"
)));
}
if !can_transition(from, target_status) {
return Err(df_core::error::Error::InvalidState {
current: from.to_string(),
current: format!("{from}→{target_status}(非法状态转换)"),
expected: target_status.to_string(),
});
}
@@ -258,26 +262,48 @@ mod tests {
#[tokio::test]
async fn illegal_skip_rejected() {
// CR-01-D: 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
let repo = setup().await;
repo.insert(rec("t1", "todo")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
match err {
df_core::error::Error::InvalidState { current, expected } => {
assert!(current.contains("todo→done"), "非法转换消息应含 from→to,实际: {current}");
assert!(current.contains("非法状态转换"), "消息应含「非法状态转换」,实际: {current}");
assert_eq!(expected, "done");
}
other => panic!("非法转换应是 InvalidState,实际: {other:?}"),
}
}
#[tokio::test]
async fn terminal_done_no_successor() {
// CR-01-D: 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
let repo = setup().await;
repo.insert(rec("t1", "done")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
match err {
df_core::error::Error::InvalidState { current, .. } => {
assert!(current.contains("done→todo"), "终态后继消息应含 from→to,实际: {current}");
}
other => panic!("终态后继应是 InvalidState,实际: {other:?}"),
}
}
#[tokio::test]
async fn same_status_rejected() {
// CR-01-D: 同态拒绝(from==to)与非法转换(跳态)错误类型区分。
// 同态属空操作,归 Validation「相同状态,无需推进」;
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
let repo = setup().await;
repo.insert(rec("t1", "in_progress")).await.unwrap();
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
assert!(matches!(err, df_core::error::Error::InvalidState { .. }));
match err {
df_core::error::Error::Validation(msg) => {
assert!(msg.contains("相同状态"), "同态消息应含「相同状态」,实际: {msg}");
}
other => panic!("同态拒绝应是 Validation,实际: {other:?}"),
}
}
#[tokio::test]
@@ -339,4 +365,191 @@ mod tests {
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
}
// ============================================================
// CR-06 观察项 ② 测试补强 — ②-3(completed)/②-4(failed) 回调推进覆盖
//
// workflow.rs 的 spawn 闭包回调依赖三段纯计算:
// 1. template_for(target) 选模板(已由 task_workflow_templates.rs 测试覆盖)
// 2. regression_target(target) 推失败退回态(workflow.rs 私有,此处镜像锁定契约)
// 3. advance_task_atomic(repo, tid, to) 落库(本模块核心,下方覆盖各回调 to)
//
// workflow.rs spawn 闭包集成测试需 mock AppState/AppHandle/EventBus,df-nodes crate
// 无此基础设施 → 降级为纯函数级 + 状态机集成级(零 mock,稳)。回调三段计算全覆盖,
// 唯一未覆盖的是「闭包组装 + tracing」胶水代码(非业务逻辑)。
// ============================================================
/// 镜像 workflow.rs::regression_target(workflow.rs L44-51 私有函数)的失败退回映射。
///
/// 此处是契约镜像(非导入):若 workflow.rs 改映射而忘同步,这里会红,提醒两处一致。
/// 映射:testing→in_review / in_review→in_progress / in_progress→todo / 其他→None。
///
/// 为什么不 pub regression_target 复用? 它是 workflow.rs(app crate)的私有函数,
/// df-nodes 不依赖 app crate(单向依赖:app → df-nodes)。把退回映射当 df-nodes 的
/// 业务契约之一镜像锁定,符合「推进链业务逻辑落 df-nodes」(D-260616-03)定位。
fn regression_target(target: &str) -> Option<&'static str> {
match target {
"testing" => Some("in_review"),
"in_review" => Some("in_progress"),
"in_progress" => Some("todo"),
_ => None,
}
}
/// 镜像 workflow.rs spawn 闭包的回调决策(completed→target / failed→regression_target)。
/// 抽出纯函数便于直接断言,与 workflow.rs L267-273 的 match 逻辑一一对应。
///
/// 返回 Option<String>(非 &str):completed 分支回传入的 target_status(非 'static),
/// failed 分支回 regression_target 的常量。统一 String 避免生命周期冲突。
fn callback_advance_target(status: &str, target_status: &str) -> Option<String> {
match status {
"completed" => Some(target_status.to_string()),
"failed" => regression_target(target_status).map(String::from),
_ => None,
}
}
#[test]
fn regression_target_mapping_matches_contract() {
// 锁定 workflow.rs ②-4 失败退回映射表(逐项)。
assert_eq!(regression_target("testing"), Some("in_review"));
assert_eq!(regression_target("in_review"), Some("in_progress"));
assert_eq!(regression_target("in_progress"), Some("todo"));
// done 是终态前向目标,失败无可退态(workflow.rs 注释:done→None)
assert_eq!(regression_target("done"), None);
// todo 是起点态无可退;blocked/cancelled 非推进链目标 → None
assert_eq!(regression_target("todo"), None);
assert_eq!(regression_target("blocked"), None);
assert_eq!(regression_target("cancelled"), None);
// 未知态防御性 → None
assert_eq!(regression_target("merged"), None);
assert_eq!(regression_target(""), None);
}
#[test]
fn callback_completed_advances_to_target() {
// ②-3:工作流 completed → 推进到 target_status(无论 target 是哪个)
assert_eq!(
callback_advance_target("completed", "in_progress").as_deref(),
Some("in_progress")
);
assert_eq!(
callback_advance_target("completed", "testing").as_deref(),
Some("testing")
);
assert_eq!(
callback_advance_target("completed", "done").as_deref(),
Some("done")
);
}
#[test]
fn callback_failed_advances_to_regression_target() {
// ②-4:工作流 failed → 推进到 regression_target(target)
assert_eq!(
callback_advance_target("failed", "testing").as_deref(),
Some("in_review")
);
assert_eq!(
callback_advance_target("failed", "in_review").as_deref(),
Some("in_progress")
);
assert_eq!(
callback_advance_target("failed", "in_progress").as_deref(),
Some("todo")
);
// failed + done:无退回映射 → None(回调跳过,workflow.rs 会 warn 跳过日志)
assert_eq!(callback_advance_target("failed", "done"), None);
}
#[test]
fn callback_non_terminal_status_skips() {
// 工作流非终态(running/cancelled 等)不应触发回调 — workflow.rs match 的 _ 分支。
// (cancelled 在 workflow.rs 实际归入 failed 路径因 status 字段取值,这里锁定
// 唯一终态语义:仅 "completed"/"failed" 两态触发推进。)
for non_terminal in ["running", "pending", ""] {
assert_eq!(
callback_advance_target(non_terminal, "in_progress"),
None,
"非终态 {non_terminal:?} 不应触发回调推进"
);
}
}
/// ②-3 回调落库验证:completed → advance_task_atomic(target) 成功推进 + 不累加 rounds。
/// 覆盖三个 target 的前向推进(in_progress/testing/done)从各自合法 from 态触发。
#[tokio::test]
async fn callback_completed_advance_lands_in_db() {
// in_progress: todo → in_progress(②-3 in_progress 模板完成)
let repo = setup().await;
repo.insert(rec("c1", "todo")).await.unwrap();
let to = callback_advance_target("completed", "in_progress").unwrap();
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
// testing: in_review → testing(②-3 testing 模板自审+核对通过)
repo.insert(rec("c2", "in_review")).await.unwrap();
let to = callback_advance_target("completed", "testing").unwrap();
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
assert_eq!(r.status, "testing");
assert_eq!(r.review_rounds, 0);
// done: testing → done(②-3 done 模板最终核对通过)
repo.insert(rec("c3", "testing")).await.unwrap();
let to = callback_advance_target("completed", "done").unwrap();
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
assert_eq!(r.status, "done");
assert_eq!(r.review_rounds, 0);
}
/// ②-4 回调落库验证:failed → advance_task_atomic(regression_target) 退回 + 累加 rounds。
/// 关键差异:②-4 退回转换是 regression,review_rounds += 1(与 ②-3 前向推进 0 增量对比)。
#[tokio::test]
async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
let repo = setup().await;
repo.insert(rec("f1", "testing")).await.unwrap();
let to = callback_advance_target("failed", "testing").unwrap();
assert_eq!(to, "in_review", "testing 失败应退回 in_review");
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
assert_eq!(r.status, "in_review");
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
repo.insert(rec("f2", "in_review")).await.unwrap();
let to = callback_advance_target("failed", "in_review").unwrap();
assert_eq!(to, "in_progress");
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 1);
// in_progress 模板失败:任务当前 in_progress → 退回 todo(rounds 不累加,
// 因 todo→in_progress 是前向,in_progress→todo 是非法转换 —— 见状态机)
// 关键:regression_target("in_progress")=todo,但状态机不允许 in_progress→todo,
// 故回调 advance_task_atomic 会报 InvalidState。验证这一防御性行为:
repo.insert(rec("f3", "in_progress")).await.unwrap();
let to = callback_advance_target("failed", "in_progress").unwrap();
assert_eq!(to, "todo");
let err = advance_task_atomic(&repo, "f3", &to).await.unwrap_err();
assert!(
matches!(err, df_core::error::Error::InvalidState { .. }),
"in_progress→todo 是非法转换,回调应被状态机拦截(InvalidState),实际: {err:?}"
);
// 任务状态未被改动(仍是 in_progress)
let still = repo.get_by_id("f3").await.unwrap().unwrap();
assert_eq!(still.status, "in_progress", "非法退回不应改动 status");
}
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。
/// workflow.rs 此分支仅 tracing::warn! 跳过,不调 advance_task_atomic。
/// 锁定:done 失败不会推进任务(终态前向目标失败,无可退态)。
#[tokio::test]
async fn callback_failed_done_skips_advance() {
let to = callback_advance_target("failed", "done");
assert_eq!(to, None, "done 失败无退回映射 → 回调跳过(None)");
// 不调 advance_task_atomic(workflow.rs L294-302 的 else 分支)
}
}

View File

@@ -0,0 +1,211 @@
//! 任务推进链 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 串行。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 失败回调路径不可达)"
);
}
}
}