新增: AI Chat多项增强(审批去重/编辑重发/导出/实体引用/会话置顶搜索)+任务推进链df-nodes落地
This commit is contained in:
@@ -529,8 +529,13 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
.map(move |event| match event {
|
||||
Ok(ev) => Ok(apply_anthropic_event(&ev.data, &mut usage_accum)),
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("Anthropic SSE 错误: {}", e);
|
||||
error!(error = %e, "Anthropic SSE 事件流错误");
|
||||
Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e))
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -287,6 +287,14 @@ impl ContextManager {
|
||||
fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// step 0(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。
|
||||
// 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除,
|
||||
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
|
||||
let messages: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| m.is_active())
|
||||
.collect();
|
||||
|
||||
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id)
|
||||
let resolved_ids: HashSet<&str> = messages
|
||||
.iter()
|
||||
@@ -432,6 +440,77 @@ impl ContextManager {
|
||||
true
|
||||
}
|
||||
|
||||
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result)
|
||||
///
|
||||
/// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
|
||||
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
|
||||
///
|
||||
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
|
||||
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
|
||||
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
|
||||
pub fn pop_last_assistant_round(&mut self) -> bool {
|
||||
if self.messages.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut popped_any = false;
|
||||
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
|
||||
while let Some(last) = self.messages.last() {
|
||||
if matches!(last.message.role, MessageRole::User) {
|
||||
break;
|
||||
}
|
||||
let removed = self.messages.pop().expect("just checked non-empty");
|
||||
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
|
||||
if matches!(removed.message.role, MessageRole::Assistant) {
|
||||
popped_any = true;
|
||||
}
|
||||
}
|
||||
popped_any
|
||||
}
|
||||
|
||||
/// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
|
||||
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
|
||||
///
|
||||
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
|
||||
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
|
||||
/// 找不到返回 Err(()),调用方据此报错。
|
||||
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||
// 反向找末条 active user 消息且 content 匹配
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User)
|
||||
&& t.message.content == target_content
|
||||
&& t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
|
||||
let mut count = 0usize;
|
||||
for t in self.messages[i + 1..].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
t.message.status = Some("truncated".to_string());
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 替换末条 active user 消息的 content(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
|
||||
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
|
||||
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User) && t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
|
||||
self.messages.iter().map(|t| &t.message)
|
||||
|
||||
@@ -505,8 +505,13 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
.map(move |event| match event {
|
||||
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
|
||||
Err(e) => {
|
||||
error!("SSE 流错误: {}", e);
|
||||
Err(anyhow::anyhow!("SSE 流错误: {}", e))
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("SSE 流错误: {}", e);
|
||||
error!("{}", ctx);
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -48,23 +48,33 @@ pub struct ChatMessage {
|
||||
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None, model: None }
|
||||
Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None }
|
||||
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None }
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None }
|
||||
}
|
||||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls), model: None }
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None }
|
||||
}
|
||||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None, model: None }
|
||||
Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None }
|
||||
}
|
||||
|
||||
/// 是否处于 active 态(status 为 None 或 "active")。truncated 返回 false。
|
||||
pub fn is_active(&self) -> bool {
|
||||
!matches!(self.status.as_deref(), Some("truncated"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ pub enum WorkflowEvent {
|
||||
node_id: NodeId,
|
||||
error: String,
|
||||
},
|
||||
/// 节点执行被取消(如 HumanNode 人工审批取消:set_cancelled 后节点返回 Err,
|
||||
/// 状态保 Cancelled 终态)。语义有别于 NodeFailed(失败):取消是用户主动行为,
|
||||
/// 前端按取消归「取消」展示,不应归类「失败」。
|
||||
NodeCancelled {
|
||||
node_id: NodeId,
|
||||
},
|
||||
/// 工作流暂停(等待外部输入)
|
||||
WorkflowPaused {
|
||||
reason: String,
|
||||
|
||||
@@ -78,6 +78,12 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
}
|
||||
};
|
||||
|
||||
// CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
|
||||
// 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程,
|
||||
// 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。
|
||||
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
|
||||
cmd.kill_on_drop(true);
|
||||
|
||||
if let Some(dir) = &request.working_dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 分支)
|
||||
}
|
||||
}
|
||||
|
||||
211
crates/df-nodes/src/task_workflow_templates.rs
Normal file
211
crates/df-nodes/src/task_workflow_templates.rs
Normal 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 失败回调路径不可达)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1047,6 +1047,7 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
||||
model: row.get("model")?,
|
||||
models: row.get("models")?,
|
||||
archived: row.get::<_, i32>("archived")? != 0,
|
||||
pinned: row.get::<_, i32>("pinned")? != 0,
|
||||
prompt_tokens: row.get("prompt_tokens")?,
|
||||
completion_tokens: row.get("completion_tokens")?,
|
||||
created_at: row.get("created_at")?,
|
||||
@@ -1137,10 +1138,11 @@ impl_repo!(
|
||||
from_row => |row| ai_conversation_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, prompt_tokens, completion_tokens, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.created_at, rec.updated_at
|
||||
],
|
||||
@@ -1148,9 +1150,10 @@ impl_repo!(
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, prompt_tokens = ?7, completion_tokens = ?8, updated_at = ?9 WHERE id = ?10",
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11",
|
||||
params![
|
||||
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.updated_at, rec.id
|
||||
],
|
||||
@@ -1237,6 +1240,42 @@ impl AiToolExecutionRepo {
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 审批历史面板分页查询:按 requested_at 倒序(最新在前),limit 默认 50。
|
||||
///
|
||||
/// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码
|
||||
/// ORDER BY created_at,本表无该列)。limit/offset 上限钳制(limit ≤ 200),
|
||||
/// 防前端恶意/失误传超大值。
|
||||
pub async fn list_recent(
|
||||
&self,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<AiToolExecutionRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
// 钳制 limit 防滥用(默认 50,最大 200)
|
||||
let safe_limit = limit.min(200) as i64;
|
||||
let safe_offset = offset as i64;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT * FROM ai_tool_executions ORDER BY requested_at DESC LIMIT ?1 OFFSET ?2",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![safe_limit, safe_offset], |row| {
|
||||
ai_tool_execution_from_row(row)
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
impl_repo!(
|
||||
@@ -1611,6 +1650,26 @@ impl AiConversationRepo {
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 设置置顶标记(仅改 pinned,不动 updated_at) — UX-17
|
||||
///
|
||||
/// 同 set_archived:置顶是纯元数据标记,不应改变相对时间。前端排序读 pinned DESC, updated_at DESC。
|
||||
pub async fn set_pinned(&self, id: &str, pinned: bool) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_conversations SET pinned = ?1 WHERE id = ?2",
|
||||
params![if pinned { 1 } else { 0 }, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
|
||||
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
|
||||
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 15] = [
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 16] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -50,6 +50,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(13, migrate_v13),
|
||||
(14, migrate_v14),
|
||||
(15, migrate_v15),
|
||||
(16, migrate_v16),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -276,6 +277,24 @@ fn migrate_v15(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V16: 幂等补 ai_conversations.pinned 列(对话置顶,UX-17)
|
||||
///
|
||||
/// 侧栏置顶分组排序信号:前端按 pinned DESC, updated_at DESC 排,置顶在前。
|
||||
/// 纯元数据标记(同 archived),NOT NULL DEFAULT 0 保证老库行非 NULL,AiConversationRecord.pinned 为 bool。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15 模式),对新库/老库均安全。
|
||||
fn migrate_v16(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "ai_conversations", "pinned") {
|
||||
conn.execute(
|
||||
"ALTER TABLE ai_conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v16: 补建 ai_conversations.pinned 列(对话置顶)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [16])?;
|
||||
tracing::info!("迁移 v16 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V1 建表 SQL
|
||||
const V1_SQL: &str = "
|
||||
-- 想法表
|
||||
|
||||
@@ -160,6 +160,7 @@ pub struct AiConversationRecord {
|
||||
pub model: Option<String>,
|
||||
pub models: Option<String>, // 用过的所有 model(JSON 数组字符串,去重)
|
||||
pub archived: bool, // 是否归档(侧栏折叠展示)
|
||||
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||
pub created_at: String,
|
||||
|
||||
@@ -22,6 +22,12 @@ pub struct Dag {
|
||||
pub nodes: HashMap<NodeId, Box<dyn Node>>,
|
||||
/// 边集合
|
||||
pub edges: Vec<Edge>,
|
||||
/// 节点级配置(DagDef.nodes[id].config 提取自 build_dag,run 时与全局 config deep_merge)
|
||||
///
|
||||
/// 语义:节点级覆盖全局级。DagExecutor 构造 NodeContext 时执行
|
||||
/// `deep_merge(initial_config, node_configs[id])`,节点定义里写的同名 key 优先于
|
||||
/// run_workflow 传入的全局 config。
|
||||
pub node_configs: HashMap<NodeId, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Dag {
|
||||
@@ -30,6 +36,7 @@ impl Dag {
|
||||
Self {
|
||||
nodes: HashMap::new(),
|
||||
edges: Vec::new(),
|
||||
node_configs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +45,19 @@ impl Dag {
|
||||
self.nodes.insert(id, node);
|
||||
}
|
||||
|
||||
/// 添加节点(附带节点级配置)
|
||||
///
|
||||
/// 节点级配置在 DagExecutor 构造 NodeContext 时覆盖全局 config(deep_merge)。
|
||||
pub fn add_node_with_config(
|
||||
&mut self,
|
||||
id: NodeId,
|
||||
node: Box<dyn Node>,
|
||||
config: serde_json::Value,
|
||||
) {
|
||||
self.nodes.insert(id.clone(), node);
|
||||
self.node_configs.insert(id, config);
|
||||
}
|
||||
|
||||
/// 添加边
|
||||
pub fn add_edge(&mut self, source: NodeId, target: NodeId) {
|
||||
self.edges.push(Edge {
|
||||
@@ -135,3 +155,85 @@ impl Default for Dag {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 深度合并两个 JSON 值:节点级 `node` 覆盖全局级 `global`。
|
||||
///
|
||||
/// 规则:
|
||||
/// - 两端均为 Object:递归合并(同 key 时节点级覆盖全局级,新增 key 各自保留)。
|
||||
/// - 非两端 Object:节点级直接覆盖全局级(包括 `Null`,符合 JSON 显式空语义)。
|
||||
/// - 节点级为 `Null` 的语义是「显式置空」,按非 Object 规则覆盖(直接返回 Null);
|
||||
/// 若调用方希望「跳过节点配置」,应在构造 Dag 时不写入该 key(node_configs 缺失即用全局)。
|
||||
///
|
||||
/// 用途:DagExecutor 构造 NodeContext 时 `deep_merge(initial_config, node_config)`,
|
||||
/// 让节点定义里写的 NodeDef.config 优先于 run_workflow 传入的全局 config。
|
||||
pub fn deep_merge(global: &serde_json::Value, node: &serde_json::Value) -> serde_json::Value {
|
||||
use serde_json::Value;
|
||||
match (global, node) {
|
||||
(Value::Object(g), Value::Object(n)) => {
|
||||
let mut merged = g.clone();
|
||||
for (k, nv) in n {
|
||||
// 同 key 递归(支持嵌套对象局部覆盖);不同类型时递归退化为「节点级直接覆盖」
|
||||
let gv = merged.get(k).cloned();
|
||||
let merged_v = match gv {
|
||||
Some(gv) => deep_merge(&gv, nv),
|
||||
None => nv.clone(),
|
||||
};
|
||||
merged.insert(k.clone(), merged_v);
|
||||
}
|
||||
Value::Object(merged)
|
||||
}
|
||||
// 非两端 Object:节点级覆盖全局级(含 Null、标量、数组)
|
||||
_ => node.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deep_merge_tests {
|
||||
use super::deep_merge;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn node_overrides_global_scalar() {
|
||||
assert_eq!(deep_merge(&json!({"a": 1}), &json!({"a": 2})), json!({"a": 2}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_adds_new_key() {
|
||||
assert_eq!(
|
||||
deep_merge(&json!({"a": 1}), &json!({"b": 2})),
|
||||
json!({"a": 1, "b": 2})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_object_recursive_merge() {
|
||||
assert_eq!(
|
||||
deep_merge(&json!({"o": {"x": 1, "y": 2}}), &json!({"o": {"y": 9}})),
|
||||
json!({"o": {"x": 1, "y": 9}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_node_config_is_noop() {
|
||||
// 核心保现有调用方行为:节点 config 为空对象 → 结果等同全局(零行为破坏)
|
||||
let global = json!({"title": "确认", "options": ["同意"]});
|
||||
assert_eq!(deep_merge(&global, &json!({})), global);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_node_config_treated_as_noop() {
|
||||
// DagExecutor 对缺失 key 的节点不调 deep_merge(直接用 global),此处覆盖语义对照:
|
||||
// 若强行把缺失视作 Null,应覆盖(显式空语义),与「缺失」不同(缺失走 if let Some 分支)。
|
||||
let global = json!({"a": 1});
|
||||
assert_eq!(deep_merge(&global, &json!(null)), json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_replaces_not_concat() {
|
||||
// 非两端 Object(数组)→ 节点级直接覆盖,不拼接
|
||||
assert_eq!(
|
||||
deep_merge(&json!({"arr": [1, 2]}), &json!({"arr": [3]})),
|
||||
json!({"arr": [3]})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,13 @@ impl DagExecutor {
|
||||
let ctx = NodeContext {
|
||||
node_id: node_id.clone(),
|
||||
inputs,
|
||||
config: initial_config.clone(),
|
||||
// 节点级覆盖全局级:deep_merge(initial_config, node_configs[id])。
|
||||
// 节点 NodeDef.config 未定义(node_configs 缺该 key)→ 直接用全局 initial_config,
|
||||
// 与旧行为一致(零行为破坏现有 HumanNode/AiNode 等读全局 config 的调用方)。
|
||||
config: match dag.node_configs.get(node_id) {
|
||||
Some(node_cfg) => crate::dag::deep_merge(&initial_config, node_cfg),
|
||||
None => initial_config.clone(),
|
||||
},
|
||||
execution_id: self.execution_id.clone(),
|
||||
event_bus: self.event_bus.clone(),
|
||||
// 共享执行器状态机,使节点(如 HumanNode)能读取真实状态而非空状态机
|
||||
@@ -142,16 +148,24 @@ impl DagExecutor {
|
||||
let error_msg = e.to_string();
|
||||
// 已取消的节点(如 HumanNode 审批取消)跳过 set_failed:
|
||||
// Cancelled 已是终态,transition(Cancelled→Failed) 非法会 bail 致工作流崩溃(B-03b-R1)
|
||||
if !self.state_machine.is_cancelled(&node_id) {
|
||||
if self.state_machine.is_cancelled(&node_id) {
|
||||
// emit NodeCancelled(非 NodeFailed):取消语义有别于失败,前端按 type 归「取消」非「失败」
|
||||
self.event_bus
|
||||
.send(WorkflowEvent::NodeCancelled {
|
||||
node_id: node_id.clone(),
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
self.state_machine.set_failed(node_id.clone())?;
|
||||
self.event_bus
|
||||
.send(WorkflowEvent::NodeFailed {
|
||||
node_id: node_id.clone(),
|
||||
error: error_msg,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
self.event_bus
|
||||
.send(WorkflowEvent::NodeFailed {
|
||||
node_id: node_id.clone(),
|
||||
error: error_msg,
|
||||
})
|
||||
.await;
|
||||
// 同层多个失败时只报告第一个
|
||||
// 取消与失败一致:节点返回 Err 即中止后续层(run 返回 Err);
|
||||
// 仅事件类型区分,工作流结果语义保持不变(零行为破坏)。
|
||||
if first_err.is_none() {
|
||||
first_err = Some(e.context(format!("节点 {} 执行失败", node_id)));
|
||||
}
|
||||
@@ -210,6 +224,35 @@ mod tests {
|
||||
/// 测试节点:直接返回错误
|
||||
struct FailNode;
|
||||
|
||||
/// 测试节点:把 ctx.config 的指定 key 字符串原样回显到 output(验证节点级 config 下沉)
|
||||
struct EchoConfigNode {
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for EchoConfigNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
let v = ctx
|
||||
.config
|
||||
.get(&self.key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Ok(NodeOutput::from_value(serde_json::json!({ "echo": v })))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"echo_config"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for FailNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
@@ -228,6 +271,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// ④-1:节点级 config 下沉验证。
|
||||
/// DagDef 节点 config={foo:"bar"} + 全局 config={foo:"GLOBAL"} → NodeContext.config.foo=="bar"
|
||||
/// (节点级覆盖全局级)。同时验证缺节点 config 的节点回退全局 config(零行为破坏现有调用方)。
|
||||
#[tokio::test]
|
||||
async fn node_config_overrides_global_in_node_context() {
|
||||
use crate::registry::NodeRegistry;
|
||||
|
||||
// 构造 DagDef:n1 节点写 config={foo:"bar"};n2 节点 config 空(验证回退全局)
|
||||
let mut def = crate::dag_def::DagDef::new();
|
||||
def.add_node("n1".to_string(), "echo".to_string(), serde_json::json!({ "foo": "bar" }));
|
||||
def.add_node("n2".to_string(), "echo".to_string(), serde_json::json!({}));
|
||||
|
||||
// 注册 echo 工厂 → EchoConfigNode(读 config.foo)
|
||||
let mut registry = NodeRegistry::new();
|
||||
registry.register("echo", |_cfg| {
|
||||
Box::new(EchoConfigNode { key: "foo".to_string() })
|
||||
});
|
||||
|
||||
let dag = registry.build_dag(&def).expect("build_dag");
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
|
||||
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
||||
.await
|
||||
.expect("run");
|
||||
|
||||
// n1:节点级 foo="bar" 覆盖全局 "GLOBAL"
|
||||
assert_eq!(
|
||||
outputs["n1"].data["echo"],
|
||||
serde_json::json!("bar"),
|
||||
"节点级 config 应覆盖全局"
|
||||
);
|
||||
// n2:节点 config 空 → 回退全局 foo="GLOBAL"(零行为破坏现有调用方)
|
||||
assert_eq!(
|
||||
outputs["n2"].data["echo"],
|
||||
serde_json::json!("GLOBAL"),
|
||||
"空节点 config 应回退全局"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_layer_runs_in_parallel() {
|
||||
// 两个无依赖节点位于同一层,各 sleep 100ms
|
||||
@@ -369,4 +453,46 @@ mod tests {
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// 复核-新③:取消节点(Err 路径)emit 的应是 NodeCancelled(非 NodeFailed),
|
||||
/// 前端按 type 区分「取消」与「失败」。订阅事件总线收集所有事件断言。
|
||||
#[tokio::test]
|
||||
async fn test_cancelled_node_emits_node_cancelled_event() {
|
||||
use df_core::events::WorkflowEvent;
|
||||
use df_core::types::NodeStatus;
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("c".to_string(), Box::new(CancelSelfNode));
|
||||
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string());
|
||||
let _ = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// 收集所有事件(run 已结束,事件总线无新事件)
|
||||
let mut events: Vec<WorkflowEvent> = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
events.push(ev);
|
||||
}
|
||||
|
||||
// 必含 NodeCancelled { node_id: "c" }
|
||||
let cancelled = events.iter().any(|e| matches!(
|
||||
e,
|
||||
WorkflowEvent::NodeCancelled { node_id } if node_id == "c"
|
||||
));
|
||||
assert!(cancelled, "取消节点应 emit NodeCancelled, 实际事件: {:?}", events);
|
||||
|
||||
// 不应含 node "c" 的 NodeFailed(失败事件)—— 语义双标修复的核心验证
|
||||
let failed = events.iter().any(|e| matches!(
|
||||
e,
|
||||
WorkflowEvent::NodeFailed { node_id, .. } if node_id == "c"
|
||||
));
|
||||
assert!(!failed, "取消节点不应 emit NodeFailed, 实际事件: {:?}", events);
|
||||
|
||||
// 状态保持 Cancelled
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"c".to_string()),
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,10 @@ impl NodeRegistry {
|
||||
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);
|
||||
// 节点级 config 下沉到 Dag.node_configs:DagExecutor.run 构造 NodeContext 时
|
||||
// deep_merge(initial_config, node_configs[id])(节点级覆盖全局级),解决此前
|
||||
// initial_config.clone() 直接当 ctx.config、忽略 NodeDef.config 的问题。
|
||||
dag.add_node_with_config(id.clone(), node, node_def.config.clone());
|
||||
node_ids.insert(id);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user