重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复
- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
This commit is contained in:
@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, MessageRole, ProviderFeatures,
|
||||
CompletionRequest, CompletionResponse, LlmProvider, MessageRole,
|
||||
StreamChunk, StreamResult, TokenUsage, ToolCall, ToolCallDelta,
|
||||
};
|
||||
|
||||
@@ -102,7 +102,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
let v: serde_json::Value = match serde_json::from_str(data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
}
|
||||
};
|
||||
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
@@ -121,7 +121,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
total_tokens: inp as u32,
|
||||
});
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
}
|
||||
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
|
||||
"message_delta" => {
|
||||
@@ -131,14 +131,14 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
acc.completion_tokens = out as u32;
|
||||
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
}
|
||||
// 文本增量
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = v.get("delta") {
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
|
||||
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None };
|
||||
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None, error: None };
|
||||
}
|
||||
// 工具入参增量
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
|
||||
@@ -154,10 +154,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
function_arguments: Some(partial),
|
||||
}]),
|
||||
usage: None,
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
}
|
||||
// 工具块开始:带 id + name
|
||||
"content_block_start" => {
|
||||
@@ -185,10 +186,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
function_arguments: None,
|
||||
}]),
|
||||
usage: None,
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None }
|
||||
}
|
||||
// 消息结束:带出累积 usage
|
||||
"message_stop" => StreamChunk {
|
||||
@@ -196,15 +198,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
},
|
||||
// 错误事件
|
||||
// 错误事件:流中途出错。不走 finished 完成路径(避免残缺响应被当正常完成入库),
|
||||
// 改由 stream_llm 识别 error 非空 → 发 AiError + 丢弃残缺(与 OpenAI 路径 Err 一致)。
|
||||
"error" => {
|
||||
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
|
||||
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error").to_string();
|
||||
error!(%msg, "Anthropic 流式错误事件");
|
||||
StreamChunk { delta: String::new(), finished: true, tool_calls: None, usage: None }
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: Some(msg) }
|
||||
}
|
||||
// content_block_stop / ping 等不产出 chunk
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None },
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +240,7 @@ impl AnthropicCompatProvider {
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("reqwest builder 失败,降级为默认 client: {}", e);
|
||||
warn!("reqwest builder 失败,回退默认 client: {}", e);
|
||||
Client::new()
|
||||
});
|
||||
Self {
|
||||
@@ -508,12 +512,8 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
"anthropic-compat"
|
||||
}
|
||||
|
||||
fn supported_features(&self) -> ProviderFeatures {
|
||||
ProviderFeatures {
|
||||
streaming: true,
|
||||
function_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
fn endpoint(&self) -> String {
|
||||
self.messages_url()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,17 +663,29 @@ mod tests {
|
||||
assert!(!c.finished);
|
||||
}
|
||||
|
||||
/// error 事件 → finished=true 终态空 chunk
|
||||
/// error 事件 → error=Some + finished=false(R-P1-1:避免残缺响应被当正常完成入库,
|
||||
/// 由 stream_llm 识别 error 非空发 AiError + 丢弃残缺,与 OpenAI 路径 Err 一致)
|
||||
#[test]
|
||||
fn anthropic_error_event_finishes_stream() {
|
||||
fn anthropic_error_event_yields_error_not_finished() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
apply_anthropic_event(&message_start(10), &mut acc);
|
||||
let c = apply_anthropic_event(r#"{"type":"error","error":{"message":"overloaded"}}"#, &mut acc);
|
||||
assert!(c.finished, "error 应终止流");
|
||||
assert!(!c.finished, "error 不走 finished 完成路径,否则残缺响应会被当正常完成");
|
||||
assert_eq!(c.error.as_deref(), Some("overloaded"), "error 事件应携带错误消息");
|
||||
assert!(c.usage.is_none(), "error 不带出 usage");
|
||||
assert!(c.delta.is_empty(), "error 不带文本增量");
|
||||
assert!(acc.is_some(), "error 不应清空已累积的 usage(与原实现一致)");
|
||||
}
|
||||
|
||||
/// error 事件无 error.message 字段时兜底为 "stream error"
|
||||
#[test]
|
||||
fn anthropic_error_event_missing_message_falls_back() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_anthropic_event(r#"{"type":"error"}"#, &mut acc);
|
||||
assert_eq!(c.error.as_deref(), Some("stream error"));
|
||||
assert!(!c.finished);
|
||||
}
|
||||
|
||||
/// ping / content_block_stop 等事件 → 空且不 finished
|
||||
#[test]
|
||||
fn anthropic_ping_and_block_stop_yield_empty_chunk() {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::provider::{ChatMessage, MessageRole};
|
||||
use crate::provider::{ChatMessage, MessageRole, ToolCall};
|
||||
|
||||
// ============================================================
|
||||
// Token 估算器(零依赖粗估)
|
||||
@@ -155,6 +155,10 @@ pub struct ContextManager {
|
||||
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
||||
const PROTECT_COUNT: usize = 6;
|
||||
|
||||
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176)。
|
||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||
const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
|
||||
impl ContextManager {
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
Self {
|
||||
@@ -224,9 +228,9 @@ impl ContextManager {
|
||||
);
|
||||
}
|
||||
|
||||
// 未超预算 → 直接返回全量
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
return (self.all_messages_clone(), false);
|
||||
return (Self::sanitize_messages(self.all_messages_clone()), false);
|
||||
}
|
||||
|
||||
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
|
||||
@@ -260,7 +264,137 @@ impl ContextManager {
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
(msgs, true)
|
||||
(Self::sanitize_messages(msgs), true)
|
||||
}
|
||||
|
||||
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
|
||||
///
|
||||
/// 根因:Anthropic 流式 `tool_use` 块缺 id 时(anthropic_compat.rs 170-176)
|
||||
/// 生成 `tool_missing_{idx}` 占位 id;对应的 `tool_result` 一旦推入历史,
|
||||
/// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
|
||||
/// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对,
|
||||
/// 不改持久化(self.messages 全量保留),让卡死的会话能自愈继续。
|
||||
///
|
||||
/// provider 协议铁律:assistant 头里每个 tool_call.id 都必须在后续有对应的
|
||||
/// tool_result,否则服务端 400/500。本函数按此自愈,分三档处理每个头:
|
||||
///
|
||||
/// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。
|
||||
/// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。
|
||||
/// - 部分闭合:保留头但只留已闭合的 tool_call,丢弃未闭合的 tool_call 及其
|
||||
/// 无主 tool_result(「不能整条删」——保住已发生的合法工具交互历史)。
|
||||
///
|
||||
/// 单次遍历、保序过滤、不重排(user/assistant/tool 角色交替不被打乱)。
|
||||
fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id)
|
||||
let resolved_ids: HashSet<&str> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.filter_map(|m| m.tool_call_id.as_deref())
|
||||
.collect();
|
||||
|
||||
// step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
|
||||
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
|
||||
// partial_heads:部分闭合的头——保留但需重写 tool_calls(只留已闭合的)
|
||||
// full_drop_heads:全未闭合的头——整条丢弃
|
||||
let mut orphaned_ids: HashSet<String> = HashSet::new();
|
||||
let mut full_drop_heads = 0u32;
|
||||
let mut partial_heads = 0u32;
|
||||
// 记录部分闭合头中「应保留的 id」,用于 step3 精确重写
|
||||
let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for (i, m) in messages.iter().enumerate() {
|
||||
if !matches!(m.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(calls) = m.tool_calls.as_ref() else { continue };
|
||||
if calls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
|
||||
.iter()
|
||||
.partition(|c| {
|
||||
!c.id.starts_with(TOOL_MISSING_PREFIX)
|
||||
&& resolved_ids.contains(c.id.as_str())
|
||||
});
|
||||
|
||||
match (resolved.is_empty(), unresolved.is_empty()) {
|
||||
// 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids
|
||||
(true, false) => {
|
||||
full_drop_heads += 1;
|
||||
for c in &unresolved {
|
||||
orphaned_ids.insert(c.id.clone());
|
||||
}
|
||||
}
|
||||
// 全闭合 → 原样保留
|
||||
(false, true) => {}
|
||||
// 部分闭合 → 保留头,只留 resolved 的 tool_call,unresolved 入 orphaned_ids
|
||||
(false, false) => {
|
||||
partial_heads += 1;
|
||||
for c in &unresolved {
|
||||
orphaned_ids.insert(c.id.clone());
|
||||
}
|
||||
partial_keep.insert(i, resolved.into_iter().cloned().collect());
|
||||
}
|
||||
// resolved/unresolved 都空不可能(calls 非空已保证)
|
||||
(true, true) => {}
|
||||
}
|
||||
}
|
||||
|
||||
if orphaned_ids.is_empty() && partial_keep.is_empty() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
// step 3:保序过滤 + 部分闭合头重写 tool_calls
|
||||
let sanitized: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, mut m)| match m.role {
|
||||
MessageRole::Assistant => {
|
||||
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
|
||||
if calls.is_empty() {
|
||||
return Some(m);
|
||||
}
|
||||
if let Some(keep) = partial_keep.get(&i) {
|
||||
// 部分闭合:重写 tool_calls 为仅已闭合子集
|
||||
m.tool_calls = Some(keep.clone());
|
||||
Some(m)
|
||||
} else {
|
||||
// 全闭合:保留;全未闭合(id 全在 orphaned_ids):丢弃
|
||||
let all_orphaned =
|
||||
calls.iter().all(|c| orphaned_ids.contains(&c.id));
|
||||
if all_orphaned {
|
||||
None
|
||||
} else {
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageRole::Tool => {
|
||||
// 无主 tool_result(其 id 命中 orphaned_ids)丢弃,其余保留
|
||||
if m
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| orphaned_ids.contains(id))
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
_ => Some(m),
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::warn!(
|
||||
full_drop_heads,
|
||||
partial_rewrite_heads = partial_heads,
|
||||
orphaned_tool_results = orphaned_ids.len(),
|
||||
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
|
||||
);
|
||||
sanitized
|
||||
}
|
||||
|
||||
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
|
||||
@@ -355,7 +489,6 @@ struct EvictionUnit {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::ToolCall;
|
||||
|
||||
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
ContextConfig {
|
||||
@@ -365,6 +498,168 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_placeholder_id_triplet() {
|
||||
// 中毒场景:assistant 头带 tool_missing_ 占位 id + 无主 tool_result
|
||||
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("调用工具"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
|
||||
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
|
||||
assert_eq!(msgs[0].content, "调用工具");
|
||||
// 持久化全量保留(自愈只改发送视图)
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_unresolved_tool_call_head() {
|
||||
// 真实 id 但全无 tool_result(流式中断/异常)→ 全未闭合档,整头丢弃
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||
));
|
||||
// 两个 call 都无 result → 整头丢弃
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|
||||
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
|
||||
"全未闭合的 assistant 头应被剔除");
|
||||
assert!(msgs.is_empty(), "全未闭合应整条删");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_partial_resolve_rewrites_head() {
|
||||
// 多 tool_call,部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
|
||||
// provider 协议铁律:head 的每个 tool_call.id 必须都有 tool_result。
|
||||
// 「不能整条删」——保住已发生的合法工具交互(call_b)历史。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("触发工具"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||
));
|
||||
// 只有 call_b 有 result → call_a 未闭合
|
||||
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
|
||||
let assistant_heads: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
.collect();
|
||||
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
|
||||
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
|
||||
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
|
||||
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
|
||||
assert!(
|
||||
head_calls.iter().all(|c| c.id != "call_a"),
|
||||
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
|
||||
);
|
||||
|
||||
// tool_result 只剩 call_b 的,user 前置保留
|
||||
let tool_msgs: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
|
||||
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
|
||||
assert!(
|
||||
msgs.iter()
|
||||
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
|
||||
"无关 user 消息不应被误删"
|
||||
);
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_well_formed_triplet() {
|
||||
// 正常三元组:head 的每个 tool_call 都有匹配 tool_result → 不剔除
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_a", "结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
assert_eq!(msgs.len(), 2, "正常三元组不应被 sanitize 剔除");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_mixed_multi_message_sequence() {
|
||||
// 场景 4:连续多条消息混合——
|
||||
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
|
||||
// 预期:全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("开场"));
|
||||
// 全闭合三元组
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调1",
|
||||
vec![ToolCall::new("ok_1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
|
||||
// 中毒占位 id 三元组(全未闭合)→ 整删
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调2",
|
||||
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
|
||||
// 部分闭合头:keep_1 闭合,drop_1 未闭合 → 重写为只剩 keep_1
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调3",
|
||||
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
|
||||
mgr.push(ChatMessage::user("收尾"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
|
||||
let head_ids: Vec<Vec<String>> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
.map(|m| {
|
||||
m.tool_calls
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.id.clone())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
head_ids.iter().all(|ids| {
|
||||
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
|
||||
}),
|
||||
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
|
||||
);
|
||||
let tool_ids: Vec<String> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.filter_map(|m| m.tool_call_id.clone())
|
||||
.collect();
|
||||
// ok_1(全闭合保留)、keep_1(部分闭合重写后保留)应在;占位/drop_1 不在
|
||||
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
|
||||
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
|
||||
assert!(
|
||||
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
|
||||
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
|
||||
);
|
||||
// 两条 user 消息原样保留
|
||||
let user_contents: Vec<&str> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::User))
|
||||
.map(|m| m.content.as_str())
|
||||
.collect();
|
||||
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_history_no_trim() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! df-ai: AI 编排 — LLM Provider、模型路由、Agent 协调、上下文管理、流式处理、工具注册
|
||||
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
|
||||
|
||||
pub mod ai_tools;
|
||||
pub mod anthropic_compat;
|
||||
@@ -6,8 +6,6 @@ pub mod context;
|
||||
pub mod coordinator;
|
||||
pub mod openai_compat;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
pub mod stream;
|
||||
|
||||
use provider::LlmProvider;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult,
|
||||
CompletionRequest, CompletionResponse, LlmProvider, StreamChunk, StreamResult,
|
||||
TokenUsage, ToolCall, ToolCallDelta,
|
||||
};
|
||||
|
||||
@@ -148,6 +148,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +185,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
finished,
|
||||
tool_calls,
|
||||
usage: None,
|
||||
error: None,
|
||||
}
|
||||
} else {
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)
|
||||
@@ -192,6 +194,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +205,7 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +237,7 @@ impl OpenAICompatProvider {
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("reqwest builder 失败,降级为默认 client(无 connect_timeout): {}", e);
|
||||
warn!("reqwest builder 失败,回退默认 client: {}", e);
|
||||
Client::new()
|
||||
});
|
||||
Self {
|
||||
@@ -495,12 +499,8 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
&self.default_model
|
||||
}
|
||||
|
||||
fn supported_features(&self) -> ProviderFeatures {
|
||||
ProviderFeatures {
|
||||
streaming: true,
|
||||
function_calling: true,
|
||||
vision: false,
|
||||
}
|
||||
fn endpoint(&self) -> String {
|
||||
self.chat_url()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,14 +151,6 @@ pub struct TokenUsage {
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// Provider 支持的特性标志
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProviderFeatures {
|
||||
pub streaming: bool,
|
||||
pub function_calling: bool,
|
||||
pub vision: bool,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
@@ -172,6 +164,10 @@ pub struct StreamChunk {
|
||||
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
@@ -216,6 +212,10 @@ pub trait LlmProvider: Send + Sync {
|
||||
/// Provider 名称
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 支持的特性
|
||||
fn supported_features(&self) -> ProviderFeatures;
|
||||
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。
|
||||
/// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印
|
||||
/// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。
|
||||
fn endpoint(&self) -> String {
|
||||
self.name().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
//! 模型路由 — 根据任务类型选择最优模型
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 任务类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskType {
|
||||
/// 代码生成
|
||||
CodeGeneration,
|
||||
/// 代码审查
|
||||
CodeReview,
|
||||
/// 文档生成
|
||||
Documentation,
|
||||
/// 分析推理
|
||||
Analysis,
|
||||
/// 摘要总结
|
||||
Summarization,
|
||||
/// 通用对话
|
||||
Chat,
|
||||
}
|
||||
|
||||
/// 模型路由器
|
||||
///
|
||||
/// 根据任务类型、成本、延迟等选择最优模型
|
||||
pub struct ModelRouter {
|
||||
/// 默认模型
|
||||
default_model: String,
|
||||
}
|
||||
|
||||
impl ModelRouter {
|
||||
/// 创建路由器
|
||||
pub fn new(default_model: &str) -> Self {
|
||||
Self {
|
||||
default_model: default_model.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据任务类型路由到合适的模型
|
||||
pub fn route(&self, task_type: &TaskType) -> String {
|
||||
// TODO: 实现基于规则的模型路由
|
||||
match task_type {
|
||||
TaskType::CodeGeneration => self.default_model.clone(),
|
||||
TaskType::CodeReview => self.default_model.clone(),
|
||||
TaskType::Documentation => self.default_model.clone(),
|
||||
TaskType::Analysis => self.default_model.clone(),
|
||||
TaskType::Summarization => self.default_model.clone(),
|
||||
TaskType::Chat => self.default_model.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! 流式处理 — LLM 响应的流式输出管理
|
||||
|
||||
use crate::provider::StreamChunk;
|
||||
|
||||
/// 流式响应收集器
|
||||
pub struct StreamCollector {
|
||||
/// 已收集的文本
|
||||
text: String,
|
||||
/// 是否完成
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl StreamCollector {
|
||||
/// 创建收集器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一个 chunk
|
||||
pub fn push(&mut self, chunk: &StreamChunk) {
|
||||
self.text.push_str(&chunk.delta);
|
||||
if chunk.finished {
|
||||
self.finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取已收集的文本
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// 是否已完成
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.finished
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,31 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::NodeId;
|
||||
|
||||
/// 人工审批选择类型(F-260615-01)
|
||||
/// - Single: 单选(decision 单值),缺省值,向后兼容现有调用方
|
||||
/// - Multiple: 多选(decisions 数组)
|
||||
///
|
||||
/// serde rename_all="snake_case" → "single"/"multiple"
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SelectType {
|
||||
#[default]
|
||||
Single,
|
||||
Multiple,
|
||||
}
|
||||
|
||||
/// 人工审批响应
|
||||
///
|
||||
/// 兼容字段(decision 单值):仅 select_type=Single 时使用;
|
||||
/// 新字段 decisions 数组:Single 时长度=1,Multiple 时长度≥1。
|
||||
/// 旧调用方仍可只填 decision,HumanNode 下游兼容两者。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HumanApprovalResponse {
|
||||
pub execution_id: String,
|
||||
pub node_id: NodeId,
|
||||
pub decision: String,
|
||||
#[serde(default)]
|
||||
pub decisions: Vec<String>,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
@@ -63,12 +82,18 @@ pub enum WorkflowEvent {
|
||||
title: String,
|
||||
description: String,
|
||||
options: Vec<String>,
|
||||
/// F-260615-01: 选择类型,缺省 Single(向后兼容)
|
||||
#[serde(default)]
|
||||
select_type: SelectType,
|
||||
},
|
||||
/// 人工审批响应
|
||||
HumanApprovalResponse {
|
||||
execution_id: String,
|
||||
node_id: NodeId,
|
||||
decision: String,
|
||||
/// F-260615-01: 多选结果(Single 模式长度=1,Multiple 模式长度≥1)
|
||||
#[serde(default)]
|
||||
decisions: Vec<String>,
|
||||
comment: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,3 +3,6 @@
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod types;
|
||||
|
||||
// 顶层 re-export:常用时间工具(跨 crate 调用方用 df_core::now_millis,避免 types:: 前缀)
|
||||
pub use types::now_millis;
|
||||
|
||||
@@ -27,6 +27,21 @@ pub type ExecutionId = String;
|
||||
/// 决策 ID
|
||||
pub type DecisionId = String;
|
||||
|
||||
// ============================================================
|
||||
// 时间工具
|
||||
// ============================================================
|
||||
|
||||
/// 当前 Unix 时间戳(毫秒)
|
||||
///
|
||||
/// 统一时间获取入口,避免 commands 层与 df-storage 层各写一份 SystemTime 调用(DRY)。
|
||||
/// 需要字符串形式的调用方自行 `.to_string()`。
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态枚举
|
||||
// ============================================================
|
||||
@@ -143,6 +158,29 @@ impl TaskStatus {
|
||||
TaskStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验字符串是否为合法的 TaskStatus 存储值(小写 snake_case)。
|
||||
/// 用于落库前拦截拼写错误(如 "in-progess"、"in progress"、大小写错等),
|
||||
/// 防止任意字符串静默落库导致按枚举查询全漏、任务"消失"。
|
||||
pub fn is_valid(s: &str) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
"todo" | "in_progress" | "in_review" | "testing" | "done" | "blocked" | "cancelled"
|
||||
)
|
||||
}
|
||||
|
||||
/// 合法 status 值列表,用于错误提示。
|
||||
pub fn valid_values() -> &'static [&'static str] {
|
||||
&[
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"testing",
|
||||
"done",
|
||||
"blocked",
|
||||
"cancelled",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskStatus {
|
||||
@@ -299,3 +337,96 @@ impl Default for Priority {
|
||||
pub fn new_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TaskStatus;
|
||||
|
||||
// 合法值:覆盖全部枚举变体(与 as_str 一致)
|
||||
#[test]
|
||||
fn is_valid_accepts_all_variants() {
|
||||
assert!(TaskStatus::is_valid("todo"));
|
||||
assert!(TaskStatus::is_valid("in_progress"));
|
||||
assert!(TaskStatus::is_valid("in_review"));
|
||||
assert!(TaskStatus::is_valid("testing"));
|
||||
assert!(TaskStatus::is_valid("done"));
|
||||
assert!(TaskStatus::is_valid("blocked"));
|
||||
assert!(TaskStatus::is_valid("cancelled"));
|
||||
}
|
||||
|
||||
// as_str 与 is_valid 必须自洽:每个变体的存储值都应被 is_valid 接受
|
||||
#[test]
|
||||
fn is_valid_consistent_with_as_str() {
|
||||
let all = [
|
||||
TaskStatus::Todo,
|
||||
TaskStatus::InProgress,
|
||||
TaskStatus::InReview,
|
||||
TaskStatus::Testing,
|
||||
TaskStatus::Done,
|
||||
TaskStatus::Blocked,
|
||||
TaskStatus::Cancelled,
|
||||
];
|
||||
for v in all {
|
||||
assert!(
|
||||
TaskStatus::is_valid(v.as_str()),
|
||||
"as_str({:?}) = {:?} 未被 is_valid 接受",
|
||||
v,
|
||||
v.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 拼写错误(典型回归场景)
|
||||
#[test]
|
||||
fn is_valid_rejects_typos() {
|
||||
assert!(!TaskStatus::is_valid("in-progess")); // 漏 r + 连字符
|
||||
assert!(!TaskStatus::is_valid("in-progress")); // 连字符非下划线
|
||||
assert!(!TaskStatus::is_valid("in progress")); // 空格
|
||||
assert!(!TaskStatus::is_valid("inprogres")); // 漏 s
|
||||
assert!(!TaskStatus::is_valid("tod")); // 截断
|
||||
assert!(!TaskStatus::is_valid("don")); // 截断
|
||||
assert!(!TaskStatus::is_valid("canceled")); // 美式拼写(单 l)
|
||||
assert!(!TaskStatus::is_valid("complete")); // 近义词
|
||||
assert!(!TaskStatus::is_valid("pending")); // 近义词
|
||||
}
|
||||
|
||||
// 大小写敏感
|
||||
#[test]
|
||||
fn is_valid_case_sensitive() {
|
||||
assert!(!TaskStatus::is_valid("Todo"));
|
||||
assert!(!TaskStatus::is_valid("TODO"));
|
||||
assert!(!TaskStatus::is_valid("InProgress"));
|
||||
assert!(!TaskStatus::is_valid("IN_PROGRESS"));
|
||||
assert!(!TaskStatus::is_valid("Done"));
|
||||
}
|
||||
|
||||
// 边界:空串、空白、前后空格、带前后缀
|
||||
#[test]
|
||||
fn is_valid_rejects_empty_and_whitespace() {
|
||||
assert!(!TaskStatus::is_valid(""));
|
||||
assert!(!TaskStatus::is_valid(" "));
|
||||
assert!(!TaskStatus::is_valid(" todo")); // 前导空格
|
||||
assert!(!TaskStatus::is_valid("todo ")); // 尾随空格
|
||||
assert!(!TaskStatus::is_valid(" todo ")); // 两端空格
|
||||
assert!(!TaskStatus::is_valid("todo\n")); // 换行
|
||||
}
|
||||
|
||||
// 边界:非 ASCII / 注入尝试
|
||||
#[test]
|
||||
fn is_valid_rejects_garbage() {
|
||||
assert!(!TaskStatus::is_valid("'; DROP TABLE tasks;--"));
|
||||
assert!(!TaskStatus::is_valid("待开始")); // 中文近义
|
||||
assert!(!TaskStatus::is_valid("todo;done"));
|
||||
assert!(!TaskStatus::is_valid("todo,done"));
|
||||
}
|
||||
|
||||
// valid_values 必须与 is_valid 自洽(防清单漏项)
|
||||
#[test]
|
||||
fn valid_values_all_pass_is_valid() {
|
||||
for &v in TaskStatus::valid_values() {
|
||||
assert!(TaskStatus::is_valid(v), "valid_values 含 {:?} 但 is_valid 拒绝", v);
|
||||
}
|
||||
// 数量应等于枚举变体数
|
||||
assert_eq!(TaskStatus::valid_values().len(), 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,14 +52,24 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
// kill_on_drop 确保 child 句柄被 drop 时(包括 timeout 取消)自动 kill 子进程,
|
||||
// 防止 stdout/stderr pipe fd 泄漏 + 孤儿/僵尸进程累积。
|
||||
cmd.kill_on_drop(true);
|
||||
let child = cmd.spawn()?;
|
||||
// wait_with_output 消费 child 并回收所有 pipe + 等待退出(避免僵尸)。
|
||||
let output = match request.timeout_secs {
|
||||
Some(secs) => tokio::time::timeout(
|
||||
Some(secs) => match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(secs),
|
||||
cmd.output(),
|
||||
child.wait_with_output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("命令执行超时: {}秒", secs))??,
|
||||
None => cmd.output().await?,
|
||||
{
|
||||
Ok(res) => res?,
|
||||
// timeout 触发:此处 child 已被 wait_with_output 消费,但因 kill_on_drop
|
||||
// 在构建时已设,spawn 出的底层进程在 child 句柄 drop 时自动 kill。
|
||||
Err(_) => return Err(anyhow::anyhow!("命令执行超时: {}秒", secs)),
|
||||
},
|
||||
None => child.wait_with_output().await?,
|
||||
};
|
||||
|
||||
let duration = start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
//! 想法晋升 — 将想法转为项目
|
||||
//!
|
||||
//! 历史的 IdeaPromoter/PromotionPolicy/try_promote/do_promote 三件套
|
||||
//! 是空壳 TODO(do_promote 从未接入 df-project,误接入会得虚假成功),
|
||||
//! 实际晋升路径由 src-tauri/src/commands/idea.rs::promote_idea 直接复用
|
||||
//! df-project::ProjectManager::create_from_idea 完成。这里仅保留作为
|
||||
//! IPC 返回类型的 PromotionResult。
|
||||
//!
|
||||
//! 死代码已清,详见 docs/todo.md R-PD-14。
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
|
||||
use df_core::types::{IdeaId, ProjectId};
|
||||
|
||||
use crate::adversarial::Recommendation;
|
||||
use crate::capture::Idea;
|
||||
|
||||
/// 晋升结果
|
||||
/// 晋升结果(promote_idea IPC 返回类型)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PromotionResult {
|
||||
pub idea_id: IdeaId,
|
||||
@@ -16,77 +20,3 @@ pub struct PromotionResult {
|
||||
pub promoted: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// 晋升策略
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PromotionPolicy {
|
||||
/// 自动晋升(评分达标时自动转为项目)
|
||||
Auto,
|
||||
/// 手动确认(需要人工审批)
|
||||
Manual,
|
||||
/// 半自动(AI 评估 + 人工确认)
|
||||
SemiAuto,
|
||||
}
|
||||
|
||||
/// 想法晋升器
|
||||
pub struct IdeaPromoter {
|
||||
policy: PromotionPolicy,
|
||||
}
|
||||
|
||||
impl IdeaPromoter {
|
||||
/// 创建晋升器
|
||||
pub fn new(policy: PromotionPolicy) -> Self {
|
||||
Self { policy }
|
||||
}
|
||||
|
||||
/// 评估并尝试晋升想法
|
||||
///
|
||||
/// TODO: 接入 df-project 创建项目
|
||||
pub fn try_promote(&self, idea: &Idea, recommendation: &Recommendation) -> Result<PromotionResult> {
|
||||
match self.policy {
|
||||
PromotionPolicy::Auto => {
|
||||
if matches!(recommendation, Recommendation::ImmediateAction | Recommendation::Soon) {
|
||||
self.do_promote(idea)
|
||||
} else {
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id: String::new(),
|
||||
promoted: false,
|
||||
reason: format!("评估建议 {:?},未达到自动晋升标准", recommendation),
|
||||
})
|
||||
}
|
||||
}
|
||||
PromotionPolicy::Manual => {
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id: String::new(),
|
||||
promoted: false,
|
||||
reason: "手动模式,等待人工审批".to_string(),
|
||||
})
|
||||
}
|
||||
PromotionPolicy::SemiAuto => {
|
||||
// TODO: AI 评估 + 人工确认流程
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id: String::new(),
|
||||
promoted: false,
|
||||
reason: "半自动模式,等待确认".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行晋升操作
|
||||
fn do_promote(&self, idea: &Idea) -> Result<PromotionResult> {
|
||||
let project_id = df_core::types::new_id();
|
||||
// TODO: 调用 df-project 创建项目
|
||||
tracing::info!("想法 {} 晋升为项目 {}", idea.id, project_id);
|
||||
|
||||
Ok(PromotionResult {
|
||||
idea_id: idea.id.clone(),
|
||||
project_id,
|
||||
promoted: true,
|
||||
reason: "评估达标,自动晋升".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ fn parse_params(
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
|
||||
.to_string();
|
||||
// 空 key 早失败:避免空 key 发请求吃 401,错误伪装成"API Key 无效"(与 src-tauri secret::ensure_resolved_key 行为对齐)
|
||||
if api_key.trim().is_empty() {
|
||||
anyhow::bail!("AiNode api_key 为空(请检查节点配置或密钥注入链路),已阻止请求避免 401 误导");
|
||||
}
|
||||
|
||||
// ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ──
|
||||
let prompt = inputs
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use df_core::events::WorkflowEvent;
|
||||
use df_core::events::{SelectType, WorkflowEvent};
|
||||
|
||||
/// 人工审批节点(阻塞节点)
|
||||
pub struct HumanNode;
|
||||
@@ -33,6 +33,12 @@ impl Node for HumanNode {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(3600);
|
||||
|
||||
// F-260615-01: 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
|
||||
let select_type = match config.get("select_type").and_then(|v| v.as_str()) {
|
||||
Some("multiple") => SelectType::Multiple,
|
||||
_ => SelectType::Single,
|
||||
};
|
||||
|
||||
// 1. 先订阅,再发请求 —— broadcast 不回放历史消息,
|
||||
// 若 subscribe 晚于 send,Response 会在 receiver 建位前发出而丢失,节点死等到超时。
|
||||
let mut rx = ctx.event_bus.subscribe();
|
||||
@@ -46,6 +52,7 @@ impl Node for HumanNode {
|
||||
title: title.to_string(),
|
||||
description: description.to_string(),
|
||||
options: options.clone(),
|
||||
select_type,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -58,19 +65,43 @@ impl Node for HumanNode {
|
||||
tokio::select! {
|
||||
recv = rx.recv() => match recv {
|
||||
Ok(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id, node_id, decision, comment,
|
||||
execution_id, node_id, decision, decisions, comment,
|
||||
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
|
||||
// decision 合法性校验:
|
||||
// options 空 → 允许自由文本决策;
|
||||
// options 非空 → 强制 decision ∈ options,否则报错而非静默放行。
|
||||
// 空 decision 始终非法。
|
||||
if !decision.is_empty() && (options.is_empty() || options.contains(&decision)) {
|
||||
// F-260615-01: 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
|
||||
// select_type=Single → 决策数必须 =1
|
||||
// select_type=Multiple → 决策数必须 ≥1
|
||||
// options 空 → 允许自由文本(仅受数量约束);
|
||||
// options 非空 → 每项必须 ∈ options
|
||||
// 兼容旧调用方:decisions 空但 decision 非空时,按 [decision] 单值处理。
|
||||
let mut picked: Vec<String> = decisions;
|
||||
if picked.is_empty() && !decision.is_empty() {
|
||||
picked.push(decision.clone());
|
||||
}
|
||||
let count_ok = match select_type {
|
||||
SelectType::Single => picked.len() == 1,
|
||||
SelectType::Multiple => !picked.is_empty(),
|
||||
};
|
||||
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 {
|
||||
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
|
||||
let primary = picked.first().cloned().unwrap_or_default();
|
||||
return Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"decision": decision,
|
||||
"decision": primary,
|
||||
"decisions": picked,
|
||||
"comment": comment.unwrap_or_default(),
|
||||
})));
|
||||
}
|
||||
return Err(anyhow::anyhow!("审批决策非法: {}", decision));
|
||||
tracing::warn!(
|
||||
node_id = %ctx.node_id,
|
||||
execution_id = %ctx.execution_id,
|
||||
select_type = ?select_type,
|
||||
decision = %decision,
|
||||
decisions = ?picked,
|
||||
options = ?options,
|
||||
"审批决策非法,忽略并继续等待下一条合法 Response(由超时兜底)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(_) => continue, // 其他节点/类型的事件,忽略
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
@@ -108,6 +139,11 @@ impl Node for HumanNode {
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"select_type": {
|
||||
"type": "string",
|
||||
"enum": ["single", "multiple"],
|
||||
"default": "single"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
@@ -116,6 +152,7 @@ impl Node for HumanNode {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decision": { "type": "string" },
|
||||
"decisions": { "type": "array", "items": { "type": "string" } },
|
||||
"comment": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
@@ -134,7 +171,10 @@ impl Node for HumanNode {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_core::types::NodeStatus;
|
||||
use df_workflow::dag::Dag;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
use df_workflow::executor::DagExecutor;
|
||||
use df_workflow::state::StateMachine;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
@@ -158,6 +198,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
|
||||
/// F-260615-01: 单选调用方仅填 decision;多选调用方填 decisions。
|
||||
async fn send_response(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
@@ -170,6 +211,26 @@ mod tests {
|
||||
execution_id: execution_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: decision.to_string(),
|
||||
decisions: vec![],
|
||||
comment: comment.map(String::from),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// F-260615-01: 多选响应发送助手(填 decisions 数组,decision 留空)
|
||||
async fn send_response_multi(
|
||||
event_bus: &EventBus,
|
||||
execution_id: &str,
|
||||
node_id: &str,
|
||||
decisions: &[&str],
|
||||
comment: Option<&str>,
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: String::new(),
|
||||
decisions: decisions.iter().map(|s| s.to_string()).collect(),
|
||||
comment: comment.map(String::from),
|
||||
})
|
||||
.await;
|
||||
@@ -279,14 +340,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_decision_rejected() {
|
||||
// options 非空,decision ∉ options → Err 决策非法
|
||||
async fn invalid_decision_ignored_then_timeout() {
|
||||
// R-P1-6: options 非空且 decision ∉ options → 不立即 Err(一次手误不应杀死节点),
|
||||
// warn 记录 + continue 续等下一条合法 Response;此处仅发一条非法的,短超时验证 → Err 超时。
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-1",
|
||||
"node-h",
|
||||
json!({ "options": ["同意", "拒绝"] }),
|
||||
json!({ "options": ["同意", "拒绝"], "timeout_secs": 1 }),
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
@@ -298,8 +360,7 @@ mod tests {
|
||||
send_response(&bus_clone, "exec-1", "node-h", "随便", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("非法"), "非法 decision 应报错, 实际: {}", err);
|
||||
assert!(err.contains("随便"), "错误信息应含 decision 值, 实际: {}", err);
|
||||
assert!(err.contains("超时"), "非法 decision 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -325,10 +386,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_decision_rejected_even_with_empty_options() {
|
||||
// 空 decision 始终非法(即使 options 空也不允许空 decision)
|
||||
async fn empty_decision_ignored_then_timeout_even_with_empty_options() {
|
||||
// R-P1-6: 空 decision 始终非法,但同样不立即 Err——warn + continue 续等,短超时验证 → Err 超时。
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [] }));
|
||||
let ctx = make_ctx(&bus, "exec-1", "node-h", json!({ "options": [], "timeout_secs": 1 }));
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
@@ -339,10 +400,240 @@ mod tests {
|
||||
send_response(&bus_clone, "exec-1", "node-h", "", None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("非法"), "空 decision 应报错, 实际: {}", err);
|
||||
assert!(err.contains("超时"), "空 decision 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
// Closed 分支覆盖:execute 持有的 ctx 含 EventBus clone(即一个 sender),
|
||||
// 在 execute 运行期间通道无法 Closed(至少该 sender 存活)。
|
||||
// 该分支仅在所有 sender drop 后可达,需独立集成测试,此处不强造。
|
||||
|
||||
// ===== B-03b-R8: executor 级端到端集成测试 =====
|
||||
// 上述单测均为 HumanNode.execute 直接调用级,不覆盖 executor 驱动含 human 的 DAG。
|
||||
// 端到端覆盖 NodeStarted/Completed 事件流转、共享 state_machine、Request 经 executor 驱动真发出、
|
||||
// outputs 收集,是 R6(send 缺 await)/R7(契约失配)的存活土壤。
|
||||
|
||||
/// 简单测试节点:sleep 指定毫秒后返回空输出(仿 executor.rs SleepNode,作 human 的前驱)
|
||||
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: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"sleep"
|
||||
}
|
||||
}
|
||||
|
||||
/// B-03b-R8: executor 驱动 a(SleepNode) → b(HumanNode) 两层 DAG 端到端。
|
||||
/// a 完成 → b 阻塞等审批 → 外部发 Response(模拟 approve_human_approval IPC) → b Ok → 工作流完成。
|
||||
/// 验证:Request 经 executor 驱动真发出(R6)、outputs 收集两层、共享 state_machine 两节点皆 Completed。
|
||||
#[tokio::test]
|
||||
async fn end_to_end_human_approval_completes_workflow() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 executor send(broadcast 不回放)
|
||||
let exec_id = "exec-e2e-1";
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_node("b".to_string(), Box::new(HumanNode));
|
||||
dag.add_edge("a".to_string(), "b".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor
|
||||
.run(
|
||||
&dag,
|
||||
json!({ "title": "确认发布", "options": ["同意", "拒绝"], "timeout_secs": 5 }),
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// 等 a 层完成、b 层 human subscribe + send Request。
|
||||
// executor 先发 NodeStarted(a)/NodeStarted(b),再发 human 的 Request —— 过滤跳过非 Request 事件
|
||||
let request = tokio::time::timeout(Duration::from_millis(1000), async {
|
||||
loop {
|
||||
if let Ok(ev @ WorkflowEvent::HumanApprovalRequest { .. }) = rx.recv().await {
|
||||
return ev;
|
||||
}
|
||||
// NodeStarted/NodeCompleted 等其他事件,继续等
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
request.is_ok(),
|
||||
"应收到 b 的 HumanApprovalRequest(R6 端到端),实际超时"
|
||||
);
|
||||
match request.unwrap() {
|
||||
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),
|
||||
}
|
||||
|
||||
// 外部 IPC 模拟:审批通过
|
||||
send_response(&bus, exec_id, "b", "同意", Some("可以发布")).await;
|
||||
|
||||
let outputs = run_handle.await.unwrap().unwrap();
|
||||
assert!(outputs.contains_key("a"), "outputs 应含前驱节点 a");
|
||||
assert_eq!(outputs["b"].data["decision"], json!("同意"));
|
||||
assert_eq!(outputs["b"].data["decisions"], json!(["同意"]));
|
||||
assert_eq!(outputs["b"].data["comment"], json!("可以发布"));
|
||||
assert_eq!(sm.get(&"a".to_string()), NodeStatus::Completed);
|
||||
assert_eq!(sm.get(&"b".to_string()), NodeStatus::Completed);
|
||||
}
|
||||
|
||||
/// B-03b-R8: executor 驱动 human DAG,外部 set_cancelled(模拟 cancel_workflow_node IPC)
|
||||
/// → human cancel_tick 命中 is_cancelled → Err → executor 跳过 set_failed(Cancelled 已终态,R1)
|
||||
/// → run 返回取消 Err + 状态保持 Cancelled。覆盖取消在真实 HumanNode 上的端到端(R2)。
|
||||
#[tokio::test]
|
||||
async fn end_to_end_human_approval_cancelled() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe(); // subscribe 先于 executor send
|
||||
let exec_id = "exec-e2e-cancel";
|
||||
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("h".to_string(), Box::new(HumanNode));
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine();
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor.run(&dag, json!({ "timeout_secs": 5 })).await
|
||||
});
|
||||
|
||||
// 等 human 发出 Request(确认已 subscribe + send + 进入 select! 轮询)
|
||||
let saw_request = tokio::time::timeout(Duration::from_millis(1000), async {
|
||||
while !matches!(rx.recv().await, Ok(WorkflowEvent::HumanApprovalRequest { .. })) {}
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
assert!(saw_request, "human 应先发出 HumanApprovalRequest");
|
||||
|
||||
// 外部 IPC 模拟:取消节点(写共享 state_machine,human 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();
|
||||
// executor 把节点 Err 包成 "节点 h 执行失败"(context),原 "人工审批被取消" 在 source chain
|
||||
assert!(
|
||||
err.contains("取消") || err.contains("执行失败"),
|
||||
"应返回取消相关错误,实际: {}",
|
||||
err
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_failed 覆盖为 Failed)—— R1 修法端到端验证
|
||||
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
|
||||
}
|
||||
|
||||
// ===== F-260615-01: 多选审批覆盖 =====
|
||||
|
||||
/// F-260615-01: select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
|
||||
#[tokio::test]
|
||||
async fn multiple_select_returns_decisions_array() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["A", "B", "C"],
|
||||
"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", "node-h", &["A", "C"], Some("多选")).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("A"), "decision 取首项");
|
||||
assert_eq!(out.data["decisions"], json!(["A", "C"]));
|
||||
assert_eq!(out.data["comment"], json!("多选"));
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
|
||||
#[tokio::test]
|
||||
async fn single_select_rejects_multiple_decisions_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-single-multi",
|
||||
"node-h",
|
||||
json!({ "options": ["A", "B"], "timeout_secs": 1 }), // 缺省 single
|
||||
);
|
||||
let bus_clone = bus.clone();
|
||||
|
||||
let handle = tokio::spawn(async move { HumanNode.execute(ctx).await });
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// single 模式却发多个 → 非法
|
||||
send_response_multi(&bus_clone, "exec-single-multi", "node-h", &["A", "B"], None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "single 下多 decisions 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
|
||||
#[tokio::test]
|
||||
async fn multiple_select_invalid_option_ignored_then_timeout() {
|
||||
let bus = EventBus::new();
|
||||
let ctx = make_ctx(
|
||||
&bus,
|
||||
"exec-multi-bad",
|
||||
"node-h",
|
||||
json!({
|
||||
"options": ["A", "B"],
|
||||
"select_type": "multiple",
|
||||
"timeout_secs": 1
|
||||
}),
|
||||
);
|
||||
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-bad", "node-h", &["A", "X"], None).await;
|
||||
|
||||
let err = handle.await.unwrap().unwrap_err().to_string();
|
||||
assert!(err.contains("超时"), "含非法 option 应被忽略后超时, 实际: {}", err);
|
||||
}
|
||||
|
||||
/// F-260615-01: 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
|
||||
/// (即所有未改造的现有 Request 均按 single 解析,零改动)
|
||||
#[tokio::test]
|
||||
async fn default_single_with_legacy_decision_single_value() {
|
||||
let bus = EventBus::new();
|
||||
// 注意:不传 select_type,验证缺省=Single
|
||||
let ctx = make_ctx(&bus, "exec-leg", "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;
|
||||
// 旧链路只填 decision,decisions 为空 → HumanNode 兼容回退
|
||||
send_response(&bus_clone, "exec-leg", "node-h", "同意", None).await;
|
||||
|
||||
let out = handle.await.unwrap().unwrap();
|
||||
assert_eq!(out.data["decision"], json!("同意"));
|
||||
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,39 @@ use crate::models::{
|
||||
TaskRecord, WorkflowRecord,
|
||||
};
|
||||
|
||||
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
||||
/// 统一正斜杠 + 小写。与 `df_project::scan::normalize_path` **同算法镜像**(df-storage
|
||||
/// 不依赖 df-project,故独立实现;改动须同步)。防 `C:\a\b` vs `C:/a/b/` 绕过。
|
||||
fn normalize_stored_path(p: &str) -> String {
|
||||
match std::path::Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
Err(_) => p
|
||||
.trim_end_matches(['\\', '/'])
|
||||
.replace('\\', "/")
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识库 SELECT 列清单(防 COLS 漂移)
|
||||
// ============================================================
|
||||
|
||||
/// `knowledges` 表对应 `KnowledgeRecord` 14 个字段的列名(顺序与结构体一致)。
|
||||
///
|
||||
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口(CR-260615-03):集中一处定义,
|
||||
/// 配合下方 `KNOWLEDGE_COL_COUNT` 断言,任一处加列漏改会被测试 `test_knowledge_cols_matches_record`
|
||||
/// 立即捕获(`knowledge_from_row` 按 name 取列,SELECT 漏列会运行时 rusqlite 报错,故提前断言)。
|
||||
const KNOWLEDGE_COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at";
|
||||
/// `KnowledgeRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
|
||||
/// 仅测试期消费(列漂移断言);保留为非 `cfg(test)` 以便测试外的阅读者一眼看到字段数。
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
const KNOWLEDGE_COL_COUNT: usize = 14;
|
||||
/// `search_vector` 用的列清单:KNOWLEDGE_COLS + embedding(余弦计算用,不入 KnowledgeRecord)。
|
||||
const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
|
||||
"id,kind,title,content,tags,status,confidence,reuse_count,verified,",
|
||||
"source_project,source_ref,reasoning,created_at,updated_at,embedding"
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// 辅助宏 — 消除 6 个 Repo 的重复样板
|
||||
// ============================================================
|
||||
@@ -355,11 +388,8 @@ pub fn is_allowed_column(table: &str, field: &str) -> bool {
|
||||
// ============================================================
|
||||
|
||||
fn now_millis_str() -> String {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.to_string()
|
||||
// 转发 df_core::now_millis(DRY:时间获取统一入口在 df-core,避免本 crate 与 commands 层各写一份 SystemTime 调用)
|
||||
df_core::now_millis().to_string()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -617,6 +647,27 @@ impl ProjectRepo {
|
||||
.map_err(|e| Error::Storage(e.to_string()))?
|
||||
}
|
||||
|
||||
/// 查找已绑定该规范化路径的项目(排除 exclude_id 自身)。无冲突返回 None。
|
||||
///
|
||||
/// DRY(R-PD-11):统一 project.rs::find_binding_conflict 与 tool_registry.rs::bind_dir_to_project
|
||||
/// 的「列项目→排除自身→按规范化路径比较」逻辑。本 crate 不依赖 df-project,故 norm_path 须由
|
||||
/// 调用方先经 `df_project::scan::normalize_path`(内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过)。
|
||||
pub async fn find_path_conflict(
|
||||
&self,
|
||||
norm_path: &str,
|
||||
exclude_id: Option<&str>,
|
||||
) -> Result<Option<ProjectRecord>> {
|
||||
let projects = self.list_active().await?;
|
||||
Ok(projects.into_iter().find(|p| {
|
||||
let excluded = exclude_id.is_some_and(|eid| p.id == eid);
|
||||
!excluded && p.path.as_ref().is_some_and(|pp| {
|
||||
// 路径规范化由调用方完成目标侧;这里为已存项目路径补规范化
|
||||
// (历史路径写法可能不规范,统一规范化比较避免误判/漏判)
|
||||
normalize_stored_path(pp) == norm_path
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序
|
||||
pub async fn list_deleted(&self) -> Result<Vec<ProjectRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
@@ -1068,10 +1119,9 @@ impl KnowledgeRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut results = Vec::new();
|
||||
const COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at";
|
||||
if let Some(k) = &kind {
|
||||
let mut stmt = guard
|
||||
.prepare(&format!("SELECT {COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
|
||||
@@ -1081,7 +1131,7 @@ impl KnowledgeRepo {
|
||||
}
|
||||
} else {
|
||||
let mut stmt = guard
|
||||
.prepare(&format!("SELECT {COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3"))
|
||||
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3"))
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
|
||||
@@ -1190,12 +1240,10 @@ impl KnowledgeRepo {
|
||||
let query_vec = query_vec.to_vec();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
// 显式列: 14 个 KnowledgeRecord 字段 + embedding(余弦计算用)
|
||||
const COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,\
|
||||
source_project,source_ref,reasoning,created_at,updated_at,embedding";
|
||||
// 显式列: 14 个 KnowledgeRecord 字段 + embedding(余弦计算用,不入 KnowledgeRecord)
|
||||
let mut stmt = guard
|
||||
.prepare(&format!(
|
||||
"SELECT {COLS} FROM knowledges WHERE status = 'published' AND embedding IS NOT NULL"
|
||||
"SELECT {KNOWLEDGE_COLS_WITH_EMBEDDING} FROM knowledges WHERE status = 'published' AND embedding IS NOT NULL"
|
||||
))
|
||||
.map_err(|e| Error::Storage(e.to_string()))?;
|
||||
let rows = stmt
|
||||
@@ -1410,6 +1458,33 @@ mod tests {
|
||||
use crate::db::Database;
|
||||
use crate::models::KnowledgeRecord;
|
||||
|
||||
// ---------- COLS 漂移防护(CR-260615-03) ----------
|
||||
|
||||
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
|
||||
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
|
||||
#[test]
|
||||
fn test_knowledge_cols_matches_record() {
|
||||
let count = KNOWLEDGE_COLS.split(',').count();
|
||||
assert_eq!(
|
||||
count, KNOWLEDGE_COL_COUNT,
|
||||
"KNOWLEDGE_COLS({count}列) ≠ KNOWLEDGE_COL_COUNT({KNOWLEDGE_COL_COUNT}); \
|
||||
修改一处须同步另一处"
|
||||
);
|
||||
// search_vector 多一列 embedding
|
||||
let count_with_emb = KNOWLEDGE_COLS_WITH_EMBEDDING.split(',').count();
|
||||
assert_eq!(
|
||||
count_with_emb,
|
||||
KNOWLEDGE_COL_COUNT + 1,
|
||||
"KNOWLEDGE_COLS_WITH_EMBEDDING({count_with_emb}列) ≠ KNOWLEDGE_COL_COUNT+1({}); \
|
||||
embedding 列应单独追加",
|
||||
KNOWLEDGE_COL_COUNT + 1
|
||||
);
|
||||
// 每个列名须能被 split 出来(防末尾多逗号 / 空段)
|
||||
for col in KNOWLEDGE_COLS.split(',') {
|
||||
assert!(!col.is_empty(), "KNOWLEDGE_COLS 含空列名段");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 向量纯函数 ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -61,24 +61,6 @@ impl Dag {
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取指定节点的所有上游节点 ID
|
||||
pub fn predecessors(&self, node_id: &NodeId) -> Vec<NodeId> {
|
||||
self.edges
|
||||
.iter()
|
||||
.filter(|e| &e.target == node_id)
|
||||
.map(|e| e.source.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取指定节点的所有下游节点 ID
|
||||
pub fn successors(&self, node_id: &NodeId) -> Vec<NodeId> {
|
||||
self.edges
|
||||
.iter()
|
||||
.filter(|e| &e.source == node_id)
|
||||
.map(|e| e.target.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 拓扑排序 — 返回按执行顺序排列的节点 ID 层级
|
||||
///
|
||||
/// 返回 Vec<Vec<NodeId>>,每层内的节点可以并行执行。
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::dag::Dag;
|
||||
|
||||
/// DAG 定义 — 可序列化/反序列化,用于持久化和模板
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct DagDef {
|
||||
@@ -73,28 +71,6 @@ impl DagDef {
|
||||
condition: Some(condition.into()),
|
||||
});
|
||||
}
|
||||
|
||||
/// 从已有的运行时 Dag 提取定义(忽略节点实例,仅保留元数据)
|
||||
///
|
||||
/// 注意:此方法只能提取边的定义,节点配置无法从 trait object 反推
|
||||
pub fn from_dag_edges(dag: &Dag) -> Self {
|
||||
let edges: Vec<EdgeDef> = dag.edges.iter().map(|e| EdgeDef {
|
||||
source: e.source.clone(),
|
||||
target: e.target.clone(),
|
||||
condition: e.condition.clone(),
|
||||
}).collect();
|
||||
|
||||
let nodes: HashMap<String, NodeDef> = dag.nodes.iter().map(|(id, node)| {
|
||||
(id.clone(), NodeDef {
|
||||
id: id.clone(),
|
||||
node_type: node.node_type().to_string(),
|
||||
config: serde_json::Value::Null,
|
||||
label: None,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Self { nodes, edges }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DagDef {
|
||||
|
||||
@@ -123,7 +123,13 @@ impl DagExecutor {
|
||||
for (node_id, result, duration) in results {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
self.state_machine.set_completed(node_id.clone())?;
|
||||
// 已取消的节点跳过 set_completed:
|
||||
// Ok 后 join_all 让出执行权,窗口内 cancel_workflow_node IPC → set_cancelled 改 Cancelled,
|
||||
// 随后此处 set_completed 走 transition 命中(Cancelled→Completed)非法 → bail 致已批准审批报失败(R-P1-3 TOCTOU)
|
||||
// 与 Err 分支 is_cancelled 短路对称:已取消节点保持 Cancelled 终态
|
||||
if !self.state_machine.is_cancelled(&node_id) {
|
||||
self.state_machine.set_completed(node_id.clone())?;
|
||||
}
|
||||
self.event_bus
|
||||
.send(WorkflowEvent::NodeCompleted {
|
||||
node_id: node_id.clone(),
|
||||
@@ -314,4 +320,53 @@ mod tests {
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试节点:执行中通过共享 node_status 自取消(模拟外部 IPC set_cancelled),返回 Ok
|
||||
/// 模拟 HumanNode select! 收合法 HumanApprovalResponse 后 return Ok,但 join_all 让出窗口内
|
||||
/// cancel_workflow_node IPC 把节点改 Cancelled 的 TOCTOU 场景。
|
||||
struct CancelSelfThenOkNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for CancelSelfThenOkNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
ctx.node_status.set_cancelled(ctx.node_id.clone());
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::Value::Null,
|
||||
output: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"cancel_self_then_ok"
|
||||
}
|
||||
}
|
||||
|
||||
/// R-P1-3:Ok 后取消的 TOCTOU 防护。节点返回 Ok 但执行期间已被 set_cancelled,
|
||||
/// executor Ok 分支必须跳过 set_completed(transition Cancelled→Completed 非法会 bail),
|
||||
/// 状态保持 Cancelled,run 返回 Ok(已批准审批不应误报失败)。
|
||||
#[tokio::test]
|
||||
async fn test_cancelled_node_skips_set_completed() {
|
||||
use df_core::types::NodeStatus;
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Ok 后取消应短路 set_completed 而非 bail,实际 err: {:?}",
|
||||
result.err()
|
||||
);
|
||||
// 状态保持 Cancelled(未被 set_completed 覆盖为 Completed,也不 bail)—— R-P1-3 修法核心验证
|
||||
assert_eq!(
|
||||
executor.state_machine.get(&"y".to_string()),
|
||||
NodeStatus::Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,14 +42,23 @@ impl NodeRegistry {
|
||||
pub fn build_dag(&self, def: &DagDef) -> anyhow::Result<Dag> {
|
||||
let mut dag = Dag::new();
|
||||
|
||||
// 创建所有节点实例
|
||||
// 创建所有节点实例,同时收集节点 ID 集合用于边校验
|
||||
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);
|
||||
node_ids.insert(id);
|
||||
}
|
||||
|
||||
// 添加所有边
|
||||
// 添加所有边 — 校验两端节点均存在,野边早返回
|
||||
for edge_def in &def.edges {
|
||||
if !node_ids.contains(&edge_def.source) || !node_ids.contains(&edge_def.target) {
|
||||
anyhow::bail!(
|
||||
"边 source/target 节点不存在: {} -> {}",
|
||||
edge_def.source,
|
||||
edge_def.target
|
||||
);
|
||||
}
|
||||
match &edge_def.condition {
|
||||
Some(cond) => dag.add_edge_with_condition(
|
||||
edge_def.source.clone(),
|
||||
@@ -62,16 +71,6 @@ impl NodeRegistry {
|
||||
|
||||
Ok(dag)
|
||||
}
|
||||
|
||||
/// 检查是否已注册指定类型
|
||||
pub fn is_registered(&self, type_name: &str) -> bool {
|
||||
self.factories.contains_key(type_name)
|
||||
}
|
||||
|
||||
/// 列出所有已注册的节点类型
|
||||
pub fn registered_types(&self) -> Vec<&str> {
|
||||
self.factories.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
// 不实现 Default:原 Default 注册了一个会 panic 的 script 工厂(unimplemented!),
|
||||
|
||||
@@ -83,26 +83,15 @@ impl StateMachine {
|
||||
self.transition(node_id, NodeStatus::Failed)
|
||||
}
|
||||
|
||||
/// 设置节点为等待中(暂未纳入转换校验)
|
||||
pub fn set_waiting(&self, node_id: NodeId) {
|
||||
self.states
|
||||
.lock()
|
||||
.expect("状态机锁中毒")
|
||||
.insert(node_id, NodeStatus::Waiting);
|
||||
}
|
||||
|
||||
/// 设置节点为已跳过(暂未纳入转换校验)
|
||||
pub fn set_skipped(&self, node_id: NodeId) {
|
||||
self.states
|
||||
.lock()
|
||||
.expect("状态机锁中毒")
|
||||
.insert(node_id, NodeStatus::Skipped);
|
||||
}
|
||||
|
||||
/// 设置节点为已取消(暂未纳入转换校验,同 set_waiting/set_skipped 模式)
|
||||
/// 设置节点为已取消 — 唯一受控旁路:不经 transition 合法性校验
|
||||
///
|
||||
/// 人工审批取消场景由前端 cancel_workflow_node IPC 触发;
|
||||
/// 阻塞节点(如 HumanNode)在 select! 轮询分支通过 is_cancelled 检测到后返回 Err。
|
||||
/// 设计理由:人工审批取消场景由前端 cancel_workflow_node IPC 异步触发,
|
||||
/// 此时节点可能处于 Running 之外的任意态(如 Pending 排队中、阻塞等待审批中),
|
||||
/// 若走 transition 校验会被 is_legal(Pending→Cancelled) 拒绝。
|
||||
/// 故 set_cancelled 作为唯一受控旁路直接置位 Cancelled,
|
||||
/// 阻塞节点(如 HumanNode)在 select! 轮询分支通过 is_cancelled 检测后返回 Err。
|
||||
///
|
||||
/// 注:原 set_waiting/set_skipped 同为旁路置位但全仓零调用,已删除。
|
||||
pub fn set_cancelled(&self, node_id: NodeId) {
|
||||
self.states
|
||||
.lock()
|
||||
@@ -177,7 +166,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_set_cancelled_marks_node_cancelled() {
|
||||
// set_cancelled 直接置位,不经转换校验(同 set_waiting/set_skipped 模式)
|
||||
// set_cancelled 为唯一受控旁路,不经转换校验直接置位 Cancelled
|
||||
let sm = StateMachine::new();
|
||||
let id = "human-1".to_string();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user