新增: 工具工作流补全(diff_files工具 + 技能清单注入 + 工作流进度共享 + human端到端测试 + 知识库MCP工具)
This commit is contained in:
@@ -196,6 +196,7 @@ mod tests {
|
||||
fn should_use_namespace_always_large_tool() {
|
||||
assert!(should_use_namespace("short", "read_file"));
|
||||
assert!(should_use_namespace("short", "list_directory"));
|
||||
assert!(should_use_namespace("short", "diff_files"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+252
-2
@@ -15,9 +15,9 @@
|
||||
// name→id 解析:src-tauri 有机制层解析(audit/mod.rs auto_resolve),MCP 面暂不同步。
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use df_storage::crud::{IdeaQuery, IdeaRepo, ProjectQuery, ProjectRepo, TaskQuery, TaskRepo};
|
||||
use df_storage::crud::{IdeaQuery, IdeaRepo, KnowledgeRepo, ProjectQuery, ProjectRepo, TaskQuery, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
|
||||
use df_storage::models::{IdeaRecord, KnowledgeRecord, ProjectRecord, TaskRecord};
|
||||
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
|
||||
use futures::future::BoxFuture;
|
||||
use serde_json::{json, Value};
|
||||
@@ -123,6 +123,22 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
|
||||
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, score_idea),
|
||||
// ─── 工作流(High) ───
|
||||
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
|
||||
// ─── 知识库 ───
|
||||
spec("search_knowledge", "检索知识库:按关键词 LIKE 匹配 title/content(可选 kind/limit);传 query_embedding 数组则走向量检索(余弦相似度 top-N)", object_schema(json!({
|
||||
"query": str_field("检索关键词"),
|
||||
"kind": opt_str_field("知识类型过滤(可空):review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization"),
|
||||
"limit": int_field("返回上限(可空,默认 5,上限 10)"),
|
||||
"query_embedding": json!({ "type": "array", "items": { "type": "number" }, "description": "查询向量(可空,传则走向量检索)" })
|
||||
}), &["query"]), Low, search_knowledge),
|
||||
spec("insert_knowledge", "新增知识条目(Medium 风险,默认允许+审计日志;状态恒为 candidate,审核发布后才参与检索)", object_schema(json!({
|
||||
"kind": str_field("知识类型:review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization"),
|
||||
"title": str_field("标题"),
|
||||
"content": str_field("内容"),
|
||||
"tags": opt_str_field("标签 JSON 数组字符串(可空)"),
|
||||
"source_project": opt_str_field("来源项目(可空)"),
|
||||
"source_ref": opt_str_field("来源引用(可空,如 conv:{id})"),
|
||||
"confidence": opt_str_field("置信度(可空:high/medium/low)")
|
||||
}), &["kind", "title", "content"]), Medium, insert_knowledge),
|
||||
// ─── 回收站 ───
|
||||
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash),
|
||||
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
|
||||
@@ -980,6 +996,126 @@ fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 知识库(复用 df-storage KnowledgeRepo)
|
||||
// ============================================================
|
||||
//
|
||||
// 分层存储(Tier 2/3:热/温/冷知识分级)需单独设计,本批只接 Tier 1 扁平
|
||||
// KnowledgeRepo(单表 + 向量列),不引入存储分层。
|
||||
|
||||
/// 7 种合法知识类型(对齐 migrations.rs V7 建表注释)。
|
||||
const KNOWLEDGE_KINDS: &[&str] = &[
|
||||
"review_rule", "prompt_template", "pitfall", "architecture_pattern",
|
||||
"diagnosis", "deployment_note", "workflow_optimization",
|
||||
];
|
||||
|
||||
/// 检索知识(Low 只读):默认关键词 LIKE(title/content),可选 kind/limit;
|
||||
/// 传 query_embedding(数组)则走向量检索(余弦 top-N),返回带相似度。
|
||||
/// 向量由调用方生成(MCP 无 AI provider 上下文),与 GUI 的 hybrid_search 共用
|
||||
/// KnowledgeRepo::search_vector,结果一致只做列裁剪。
|
||||
fn search_knowledge(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let query = match arg_str(&args, "query") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let kind = args.get("kind").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|i| i.max(1) as usize)
|
||||
.unwrap_or(5)
|
||||
.min(10);
|
||||
let query_vec: Option<Vec<f32>> = args
|
||||
.get("query_embedding")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|n| n.as_f64()).map(|f| f as f32).collect());
|
||||
Box::pin(async move {
|
||||
let repo = KnowledgeRepo::new(&db);
|
||||
match query_vec {
|
||||
Some(vec) => {
|
||||
if vec.is_empty() {
|
||||
return CallToolResult::error("query_embedding 不能为空数组");
|
||||
}
|
||||
match repo.search_vector(&vec, limit).await {
|
||||
Ok(hits) => {
|
||||
let hits: Vec<Value> = hits
|
||||
.into_iter()
|
||||
.map(|(rec, score)| json!({
|
||||
"score": (score * 1000.0).round() / 1000.0,
|
||||
"knowledge": rec
|
||||
}))
|
||||
.collect();
|
||||
json_ok(json!({ "count": hits.len(), "hits": hits }))
|
||||
}
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
}
|
||||
None => match repo.search(&query, kind.as_deref(), limit).await {
|
||||
Ok(list) => json_ok(json!({ "count": list.len(), "knowledge": list })),
|
||||
Err(e) => err_str(e),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 新增知识条目(Medium 写库):状态恒为 candidate(审核发布后才参与检索,
|
||||
/// 对齐 GUI knowledge_create 语义),不自动生成嵌入(嵌入由发布链路处理)。
|
||||
fn insert_knowledge(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let kind = match arg_str(&args, "kind") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let title = match arg_str(&args, "title") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let content = match arg_str(&args, "content") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let tags = args.get("tags").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
let source_project = args.get("source_project").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
let source_ref = args.get("source_ref").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
let confidence = args.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
||||
medium_audit("insert_knowledge", &title);
|
||||
Box::pin(async move {
|
||||
if !KNOWLEDGE_KINDS.contains(&kind.as_str()) {
|
||||
return CallToolResult::error(format!(
|
||||
"非法知识类型: {kind}, 有效值: {}",
|
||||
KNOWLEDGE_KINDS.join("/")
|
||||
));
|
||||
}
|
||||
if title.trim().is_empty() || content.trim().is_empty() {
|
||||
return CallToolResult::error("title/content 不能为空");
|
||||
}
|
||||
let now = now_millis();
|
||||
let rec = KnowledgeRecord {
|
||||
id: new_id(),
|
||||
kind,
|
||||
title,
|
||||
content,
|
||||
tags,
|
||||
status: "candidate".to_string(),
|
||||
confidence,
|
||||
reuse_count: 0,
|
||||
verified: false,
|
||||
source_project,
|
||||
source_ref,
|
||||
reasoning: None,
|
||||
embedding_status: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
let repo = KnowledgeRepo::new(&db);
|
||||
match repo.insert(rec.clone()).await {
|
||||
Ok(id) => json_ok(json!({ "id": id, "knowledge": rec })),
|
||||
Err(e) => err_str(e),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测:evaluate_idea(只读,不写库)/ score_idea(写库)/ 风险契约
|
||||
// ============================================================
|
||||
@@ -1079,6 +1215,30 @@ mod tests {
|
||||
repo.insert(rec).await.unwrap()
|
||||
}
|
||||
|
||||
/// 插入一条知识,返回 id(search_knowledge 测试用;status=published 才进检索)
|
||||
async fn seed_knowledge(ctx: &Ctx, title: &str, content: &str, kind: &str) -> String {
|
||||
let repo = KnowledgeRepo::new(&ctx.db);
|
||||
let now = now_millis();
|
||||
let rec = KnowledgeRecord {
|
||||
id: new_id(),
|
||||
kind: kind.to_owned(),
|
||||
title: title.to_owned(),
|
||||
content: content.to_owned(),
|
||||
tags: None,
|
||||
status: "published".to_string(),
|
||||
confidence: Some("high".to_owned()),
|
||||
reuse_count: 0,
|
||||
verified: true,
|
||||
source_project: None,
|
||||
source_ref: None,
|
||||
reasoning: None,
|
||||
embedding_status: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
repo.insert(rec).await.unwrap()
|
||||
}
|
||||
|
||||
/// 读当前 DB 中的 idea.scores(原始字符串)
|
||||
async fn db_scores(ctx: &Ctx, id: &str) -> Option<String> {
|
||||
IdeaRepo::new(&ctx.db)
|
||||
@@ -1557,4 +1717,94 @@ mod tests {
|
||||
let stack = v["project"]["stack"].as_str().unwrap_or_default();
|
||||
assert!(stack.contains("rust"), "应探测到 rust 技术栈,实际 stack: {stack}");
|
||||
}
|
||||
|
||||
// ── 知识库工具(search_knowledge Low / insert_knowledge Medium)────────────
|
||||
|
||||
/// search_knowledge:关键词命中已发布知识,返回记录列表(不写库,只读契约)。
|
||||
#[tokio::test]
|
||||
async fn search_knowledge_keyword_returns_hits() {
|
||||
let ctx = test_ctx().await;
|
||||
seed_knowledge(&ctx, "Rust 异步模型", "tokio 运行时与并发", "pitfall").await;
|
||||
|
||||
let r = search_knowledge(&ctx, json!({ "query": "tokio" })).await;
|
||||
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["count"], 1);
|
||||
assert_eq!(v["knowledge"][0]["title"], "Rust 异步模型");
|
||||
}
|
||||
|
||||
/// search_knowledge:缺 query 必填参数 → 报错。
|
||||
#[tokio::test]
|
||||
async fn search_knowledge_missing_query_errors() {
|
||||
let ctx = test_ctx().await;
|
||||
let r = search_knowledge(&ctx, json!({})).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
assert!(text_of(&r).contains("缺少必填参数"));
|
||||
}
|
||||
|
||||
/// search_knowledge:传入 query_embedding → 向量检索,返回带 score 的命中。
|
||||
#[tokio::test]
|
||||
async fn search_knowledge_vector_with_embedding() {
|
||||
let ctx = test_ctx().await;
|
||||
let id = seed_knowledge(&ctx, "Rust 异步", "tokio 并发模型", "pitfall").await;
|
||||
// 写一条向量(f32 数组,维度 3),search_vector 才能命中
|
||||
KnowledgeRepo::new(&ctx.db).set_embedding(&id, &[0.1, 0.2, 0.3]).await.unwrap();
|
||||
|
||||
let r = search_knowledge(&ctx, json!({
|
||||
"query": "ignored", // 有 embedding 时 query 仅作占位,检索走向量
|
||||
"query_embedding": [0.1, 0.2, 0.3]
|
||||
})).await;
|
||||
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["count"], 1);
|
||||
assert!(v["hits"][0]["score"].is_number());
|
||||
assert_eq!(v["hits"][0]["knowledge"]["id"], id);
|
||||
}
|
||||
|
||||
/// insert_knowledge:创建成功,状态恒 candidate(待审核),DB 可读回。
|
||||
#[tokio::test]
|
||||
async fn insert_knowledge_creates_candidate() {
|
||||
let ctx = test_ctx().await;
|
||||
let r = insert_knowledge(&ctx, json!({
|
||||
"kind": "pitfall",
|
||||
"title": "MCP 超时",
|
||||
"content": "审批响应须带 execution_id 匹配",
|
||||
"tags": "[\"mcp\",\"workflow\"]"
|
||||
})).await;
|
||||
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
||||
let v = json_of(&r);
|
||||
let id = v["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(v["knowledge"]["status"], "candidate");
|
||||
assert_eq!(v["knowledge"]["reuse_count"], 0);
|
||||
let persisted = KnowledgeRepo::new(&ctx.db).get_by_id(&id).await.unwrap();
|
||||
assert!(persisted.is_some(), "DB 应能读回新建知识");
|
||||
assert_eq!(persisted.unwrap().status, "candidate");
|
||||
}
|
||||
|
||||
/// insert_knowledge:非法 kind / 空 title → 报错(不落库)。
|
||||
#[tokio::test]
|
||||
async fn insert_knowledge_invalid_input_errors() {
|
||||
let ctx = test_ctx().await;
|
||||
let bad_kind = insert_knowledge(&ctx, json!({ "kind": "nope", "title": "t", "content": "c" })).await;
|
||||
assert_eq!(bad_kind.is_error, Some(true));
|
||||
assert!(text_of(&bad_kind).contains("非法知识类型"));
|
||||
|
||||
let empty_title = insert_knowledge(&ctx, json!({ "kind": "pitfall", "title": " ", "content": "c" })).await;
|
||||
assert_eq!(empty_title.is_error, Some(true));
|
||||
assert!(text_of(&empty_title).contains("不能为空"));
|
||||
}
|
||||
|
||||
/// 风险契约:search_knowledge=Low(只读,read-only 放行),insert_knowledge=Medium(写库,read-only 拒)。
|
||||
#[test]
|
||||
fn knowledge_tools_risk_contract() {
|
||||
let search = find("search_knowledge").expect("search_knowledge 必须注册");
|
||||
let insert = find("insert_knowledge").expect("insert_knowledge 必须注册");
|
||||
assert_eq!(search.risk, RiskLevel::Low, "search_knowledge 必须 Low(只读契约)");
|
||||
assert_eq!(insert.risk, RiskLevel::Medium, "insert_knowledge 必须 Medium(写库 → read-only 拒)");
|
||||
|
||||
assert!(visible_for_test(true, "search_knowledge"));
|
||||
assert!(!visible_for_test(true, "insert_knowledge"));
|
||||
assert!(visible_for_test(false, "search_knowledge"));
|
||||
assert!(visible_for_test(false, "insert_knowledge"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! human 节点端到端集成测试 — DagDef → NodeRegistry.build_dag → DagExecutor.run
|
||||
//!
|
||||
//! df-workflow 不依赖 df-nodes(反向依赖),此处用等价阻塞节点模拟 HumanNode 的
|
||||
//! 「发审批请求 → 挂起等待 → 外部 approve → 返回结果」链路,覆盖
|
||||
//! DagDef 序列化 → 注册表 → 执行器全链路 + 事件序列断言(R6 send 缺 await /
|
||||
//! R7 契约失配的存活土壤)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use df_types::events::{SelectType, WorkflowEvent};
|
||||
use df_types::types::NodeStatus;
|
||||
use df_workflow::dag_def::DagDef;
|
||||
use df_workflow::eventbus::{EventBus, EventSubscriber};
|
||||
use df_workflow::executor::DagExecutor;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use df_workflow::registry::NodeRegistry;
|
||||
|
||||
/// 前驱节点:sleep 后返回空输出(仿 executor 单测 SleepNode)。
|
||||
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: json!(null),
|
||||
output: json!(null),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"sleep"
|
||||
}
|
||||
}
|
||||
|
||||
/// 模拟 HumanNode 的阻塞审批节点:先 subscribe 再发 HumanApprovalRequest(broadcast 不回放),
|
||||
/// select! 等待匹配 execution_id + node_id 的 HumanApprovalResponse,收到则返回 decision。
|
||||
struct ApprovalNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for ApprovalNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
let mut rx = ctx.event_bus.subscribe();
|
||||
let _ = ctx
|
||||
.event_bus
|
||||
.send(WorkflowEvent::HumanApprovalRequest {
|
||||
execution_id: ctx.execution_id.clone(),
|
||||
node_id: ctx.node_id.clone(),
|
||||
title: "确认发布".to_string(),
|
||||
description: String::new(),
|
||||
options: vec!["同意".into(), "拒绝".into()],
|
||||
select_type: SelectType::Single,
|
||||
})
|
||||
.await;
|
||||
let timeout_secs = ctx
|
||||
.config
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(2);
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
tokio::select! {
|
||||
recv = rx.recv() => match recv {
|
||||
Ok(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id, node_id, decision, decisions, ..
|
||||
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
|
||||
let primary = if decision.is_empty() {
|
||||
decisions.first().cloned().unwrap_or_default()
|
||||
} else {
|
||||
decision
|
||||
};
|
||||
return Ok(NodeOutput::from_value(json!({ "decision": primary })));
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(_) => return Err(anyhow::anyhow!("事件总线关闭,审批无法完成")),
|
||||
},
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
return Err(anyhow::anyhow!("人工审批等待超时({timeout_secs}s)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: json!(null),
|
||||
output: json!(null),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_blocking(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"human"
|
||||
}
|
||||
}
|
||||
|
||||
/// 从事件流中等待一条 HumanApprovalRequest(跳过 NodeStarted/NodeCompleted 等其他事件)。
|
||||
async fn wait_approval_request(mut rx: EventSubscriber) -> WorkflowEvent {
|
||||
tokio::time::timeout(Duration::from_millis(1000), async move {
|
||||
loop {
|
||||
if let Ok(ev @ WorkflowEvent::HumanApprovalRequest { .. }) = rx.recv().await {
|
||||
return ev;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("应收到 HumanApprovalRequest,实际超时")
|
||||
}
|
||||
|
||||
/// 端到端主链路:前驱 sleep(a) → human(b)。
|
||||
/// 执行到 human 后挂起等审批 → 外部 approve(模拟 approve_human_approval IPC) → 返回结果、工作流完成。
|
||||
#[tokio::test]
|
||||
async fn human_workflow_end_to_end_approval() {
|
||||
let bus = EventBus::new();
|
||||
let rx = bus.subscribe(); // 先 subscribe 再执行(broadcast 不回放)
|
||||
let exec_id = "exec-e2e-approve";
|
||||
|
||||
// DagDef(可序列化定义)→ NodeRegistry.build_dag → 运行时 Dag
|
||||
let mut def = DagDef::new();
|
||||
def.add_node("a", "sleep", json!({}));
|
||||
def.add_node("b", "human", json!({}));
|
||||
def.add_edge("a", "b");
|
||||
let mut registry = NodeRegistry::new();
|
||||
registry.register("sleep", |_| Box::new(SleepNode { sleep_ms: 10 }));
|
||||
registry.register("human", |_| Box::new(ApprovalNode));
|
||||
let dag = registry.build_dag(&def).expect("build_dag 应成功");
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
let run_handle = tokio::spawn(async move { executor.run(&dag, json!({})).await });
|
||||
|
||||
// a 层完成、b 层 human subscribe + send Request 后应收到审批请求
|
||||
let request = wait_approval_request(rx).await;
|
||||
match request {
|
||||
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),
|
||||
}
|
||||
|
||||
// 外部 approve(模拟 approve_human_approval IPC 发送 HumanApprovalResponse 到总线)
|
||||
bus.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: exec_id.into(),
|
||||
node_id: "b".to_string(),
|
||||
decision: "同意".into(),
|
||||
decisions: vec![],
|
||||
comment: Some("可以发布".into()),
|
||||
})
|
||||
.await;
|
||||
|
||||
let outputs = run_handle
|
||||
.await
|
||||
.expect("run 不应 panic")
|
||||
.expect("run 应成功");
|
||||
assert!(outputs.contains_key("a"), "outputs 应含前驱节点 a");
|
||||
assert_eq!(outputs["b"].data["decision"], json!("同意"));
|
||||
assert_eq!(sm.get(&"a".to_string()), NodeStatus::Completed);
|
||||
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
|
||||
}
|
||||
|
||||
/// 取消路径:外部 set_cancelled(模拟 cancel_workflow_node IPC 写共享状态机)
|
||||
/// → human select! 轮询 is_cancelled → Err → 状态保持 Cancelled。
|
||||
#[tokio::test]
|
||||
async fn human_workflow_cancel_via_shared_state_machine() {
|
||||
let bus = EventBus::new();
|
||||
let rx = bus.subscribe();
|
||||
let exec_id = "exec-e2e-cancel";
|
||||
|
||||
let mut def = DagDef::new();
|
||||
def.add_node("h", "human", json!({}));
|
||||
let mut registry = NodeRegistry::new();
|
||||
registry.register("human", |_| Box::new(ApprovalNode));
|
||||
let dag = registry.build_dag(&def).expect("build_dag 应成功");
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine();
|
||||
let run_handle = tokio::spawn(async move { executor.run(&dag, json!({})).await });
|
||||
|
||||
// 等 human 发出审批请求(确认已 subscribe + send + 进入 select! 轮询)
|
||||
let _ = wait_approval_request(rx).await;
|
||||
|
||||
// 外部 cancel:写共享状态机置 Cancelled,humar select! 的 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();
|
||||
assert!(
|
||||
err.contains("取消") || err.contains("执行失败"),
|
||||
"应返回取消相关错误,实际: {err}"
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_failed 覆盖为 Failed)—— 取消终态不被失败语义覆盖
|
||||
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
|
||||
}
|
||||
|
||||
/// 挂起等待路径:收到请求后不 approve → human 挂起直到超时 → run 返 Err。
|
||||
/// 证明节点真在等待外部响应(而非空转直接返回)。
|
||||
#[tokio::test]
|
||||
async fn human_workflow_waits_then_times_out_without_approve() {
|
||||
let bus = EventBus::new();
|
||||
let rx = bus.subscribe();
|
||||
let exec_id = "exec-e2e-timeout";
|
||||
|
||||
let mut def = DagDef::new();
|
||||
def.add_node("h", "human", json!({ "timeout_secs": 1 }));
|
||||
let mut registry = NodeRegistry::new();
|
||||
registry.register("human", |_| Box::new(ApprovalNode));
|
||||
let dag = registry.build_dag(&def).expect("build_dag 应成功");
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let run_handle = tokio::spawn(async move { executor.run(&dag, json!({})).await });
|
||||
|
||||
let _ = wait_approval_request(rx).await;
|
||||
let result = run_handle.await.unwrap();
|
||||
assert!(result.is_err(), "无审批响应应超时失败");
|
||||
// executor 包了「节点 h 执行失败」context,用 {:#} 展开整条错误链断言根因
|
||||
let chain = format!("{:#}", result.unwrap_err());
|
||||
assert!(chain.contains("超时"), "应返回超时错误,实际: {chain}");
|
||||
}
|
||||
Reference in New Issue
Block a user