From 2de0c6ecb7ca3d9937e3917adf5905283f5fe382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 15 Jun 2026 05:14:42 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E5=90=8E=E7=AB=AF=20df-a?= =?UTF-8?q?i/commands=20=E6=8B=86=E5=88=86+df-nodes/workflow=20=E6=94=B9?= =?UTF-8?q?=E9=80=A0+P0=20bug=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 依赖同步 --- Cargo.lock | 30 +- crates/df-ai/src/anthropic_compat.rs | 54 ++- crates/df-ai/src/context.rs | 305 +++++++++++++- crates/df-ai/src/lib.rs | 4 +- crates/df-ai/src/openai_compat.rs | 16 +- crates/df-ai/src/provider.rs | 20 +- crates/df-ai/src/router.rs | 51 --- crates/df-ai/src/stream.rs | 45 -- crates/df-core/src/events.rs | 25 ++ crates/df-core/src/lib.rs | 3 + crates/df-core/src/types.rs | 131 ++++++ crates/df-execute/src/shell.rs | 18 +- crates/df-ideas/src/promotion.rs | 88 +--- crates/df-nodes/src/ai_node.rs | 4 + crates/df-nodes/src/human_node.rs | 327 ++++++++++++++- crates/df-storage/src/crud.rs | 99 ++++- crates/df-workflow/src/dag.rs | 18 - crates/df-workflow/src/dag_def.rs | 24 -- crates/df-workflow/src/executor.rs | 57 ++- crates/df-workflow/src/registry.rs | 23 +- crates/df-workflow/src/state.rs | 29 +- src-tauri/Cargo.toml | 4 + src-tauri/src/commands/ai/agentic.rs | 220 +++++++++- src-tauri/src/commands/ai/commands.rs | 118 +++++- src-tauri/src/commands/ai/conversation.rs | 8 +- src-tauri/src/commands/ai/knowledge_inject.rs | 24 +- src-tauri/src/commands/ai/mod.rs | 58 ++- src-tauri/src/commands/ai/secret.rs | 228 +++++++++++ src-tauri/src/commands/ai/stream_recv.rs | 387 ++++++++++++++++-- src-tauri/src/commands/ai/title.rs | 17 +- src-tauri/src/commands/ai/tool_registry.rs | 351 ++++++++++++++-- src-tauri/src/commands/mod.rs | 8 +- src-tauri/src/commands/project.rs | 27 +- src-tauri/src/commands/task.rs | 27 +- src-tauri/src/commands/workflow.rs | 74 +++- src-tauri/src/lib.rs | 7 + src-tauri/src/state.rs | 12 +- 37 files changed, 2457 insertions(+), 484 deletions(-) delete mode 100644 crates/df-ai/src/router.rs delete mode 100644 crates/df-ai/src/stream.rs create mode 100644 src-tauri/src/commands/ai/secret.rs diff --git a/Cargo.lock b/Cargo.lock index 9eb5918..8a343e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -731,6 +731,7 @@ dependencies = [ "df-storage", "df-workflow", "futures", + "keyring", "serde", "serde_json", "tauri", @@ -2157,6 +2158,20 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2351,7 +2366,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -3376,6 +3391,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 17883cb..8c501fa 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -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 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 { @@ -131,14 +131,14 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { 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 { @@ -185,10 +186,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option StreamChunk { @@ -196,15 +198,17 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option { - 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 = 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 = 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() { diff --git a/crates/df-ai/src/context.rs b/crates/df-ai/src/context.rs index 6971fd9..cbb3853 100644 --- a/crates/df-ai/src/context.rs +++ b/crates/df-ai/src/context.rs @@ -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) -> Vec { + 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 = HashSet::new(); + let mut full_drop_heads = 0u32; + let mut partial_heads = 0u32; + // 记录部分闭合头中「应保留的 id」,用于 step3 精确重写 + let mut partial_keep: std::collections::HashMap> = + 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 = 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> = 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 = 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)); diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 3a9eb79..ce972b3 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -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; diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index 5845e2c..19db946 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -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) 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) 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) 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) 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() } } diff --git a/crates/df-ai/src/provider.rs b/crates/df-ai/src/provider.rs index 895b52e..ce36f1c 100644 --- a/crates/df-ai/src/provider.rs +++ b/crates/df-ai/src/provider.rs @@ -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, + /// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。 + /// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。 + #[serde(skip)] + pub error: Option, } /// 工具调用增量(流式中的片段) @@ -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() + } } diff --git a/crates/df-ai/src/router.rs b/crates/df-ai/src/router.rs deleted file mode 100644 index c57ac04..0000000 --- a/crates/df-ai/src/router.rs +++ /dev/null @@ -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(), - } - } -} diff --git a/crates/df-ai/src/stream.rs b/crates/df-ai/src/stream.rs deleted file mode 100644 index ba1c7cd..0000000 --- a/crates/df-ai/src/stream.rs +++ /dev/null @@ -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() - } -} diff --git a/crates/df-core/src/events.rs b/crates/df-core/src/events.rs index b0ab1a8..4920e82 100644 --- a/crates/df-core/src/events.rs +++ b/crates/df-core/src/events.rs @@ -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, pub comment: Option, } @@ -63,12 +82,18 @@ pub enum WorkflowEvent { title: String, description: String, options: Vec, + /// 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, comment: Option, }, } diff --git a/crates/df-core/src/lib.rs b/crates/df-core/src/lib.rs index e27d659..d8e24b1 100644 --- a/crates/df-core/src/lib.rs +++ b/crates/df-core/src/lib.rs @@ -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; diff --git a/crates/df-core/src/types.rs b/crates/df-core/src/types.rs index 579334c..6032fe8 100644 --- a/crates/df-core/src/types.rs +++ b/crates/df-core/src/types.rs @@ -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); + } +} diff --git a/crates/df-execute/src/shell.rs b/crates/df-execute/src/shell.rs index ae37897..3313b9e 100644 --- a/crates/df-execute/src/shell.rs +++ b/crates/df-execute/src/shell.rs @@ -52,14 +52,24 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result { 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; diff --git a/crates/df-ideas/src/promotion.rs b/crates/df-ideas/src/promotion.rs index 1c0b982..eecc601 100644 --- a/crates/df-ideas/src/promotion.rs +++ b/crates/df-ideas/src/promotion.rs @@ -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 { - 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 { - 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(), - }) - } -} diff --git a/crates/df-nodes/src/ai_node.rs b/crates/df-nodes/src/ai_node.rs index 271c6cc..efb6d56 100644 --- a/crates/df-nodes/src/ai_node.rs +++ b/crates/df-nodes/src/ai_node.rs @@ -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 diff --git a/crates/df-nodes/src/human_node.rs b/crates/df-nodes/src/human_node.rs index 9f8f9a7..1b1f855 100644 --- a/crates/df-nodes/src/human_node.rs +++ b/crates/df-nodes/src/human_node.rs @@ -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 = 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 应含单值"); + } } diff --git a/crates/df-storage/src/crud.rs b/crates/df-storage/src/crud.rs index aab1fcd..3ecad5a 100644 --- a/crates/df-storage/src/crud.rs +++ b/crates/df-storage/src/crud.rs @@ -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> { + 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> { 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] diff --git a/crates/df-workflow/src/dag.rs b/crates/df-workflow/src/dag.rs index 728c6af..c96d24f 100644 --- a/crates/df-workflow/src/dag.rs +++ b/crates/df-workflow/src/dag.rs @@ -61,24 +61,6 @@ impl Dag { }); } - /// 获取指定节点的所有上游节点 ID - pub fn predecessors(&self, node_id: &NodeId) -> Vec { - self.edges - .iter() - .filter(|e| &e.target == node_id) - .map(|e| e.source.clone()) - .collect() - } - - /// 获取指定节点的所有下游节点 ID - pub fn successors(&self, node_id: &NodeId) -> Vec { - self.edges - .iter() - .filter(|e| &e.source == node_id) - .map(|e| e.target.clone()) - .collect() - } - /// 拓扑排序 — 返回按执行顺序排列的节点 ID 层级 /// /// 返回 Vec>,每层内的节点可以并行执行。 diff --git a/crates/df-workflow/src/dag_def.rs b/crates/df-workflow/src/dag_def.rs index e5a3a39..6182329 100644 --- a/crates/df-workflow/src/dag_def.rs +++ b/crates/df-workflow/src/dag_def.rs @@ -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 = dag.edges.iter().map(|e| EdgeDef { - source: e.source.clone(), - target: e.target.clone(), - condition: e.condition.clone(), - }).collect(); - - let nodes: HashMap = 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 { diff --git a/crates/df-workflow/src/executor.rs b/crates/df-workflow/src/executor.rs index 410d2ad..60d7ecb 100644 --- a/crates/df-workflow/src/executor.rs +++ b/crates/df-workflow/src/executor.rs @@ -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 + ); + } } diff --git a/crates/df-workflow/src/registry.rs b/crates/df-workflow/src/registry.rs index 50c9585..790b942 100644 --- a/crates/df-workflow/src/registry.rs +++ b/crates/df-workflow/src/registry.rs @@ -42,14 +42,23 @@ impl NodeRegistry { pub fn build_dag(&self, def: &DagDef) -> anyhow::Result { 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!), diff --git a/crates/df-workflow/src/state.rs b/crates/df-workflow/src/state.rs index d610707..6d371a1 100644 --- a/crates/df-workflow/src/state.rs +++ b/crates/df-workflow/src/state.rs @@ -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(); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1aed72f..ca76546 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,3 +34,7 @@ df-ai = { path = "../crates/df-ai" } df-ideas = { path = "../crates/df-ideas" } df-project = { path = "../crates/df-project" } futures = "0.3" +# keyring v3 默认不带任何 platform 后端,必须显式启 feature—— +# 否则 Entry 走 noop store:set_password 静默成功不持久化,get_password 永远空(=FR-S1 密钥存了等于没存,根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md) +# Win+mac 启用原生后端;linux(secret-service)按需补 +keyring = { version = "3", features = ["windows-native", "apple-native"] } diff --git a/src-tauri/src/commands/ai/agentic.rs b/src-tauri/src/commands/ai/agentic.rs index f952fa3..b205b92 100644 --- a/src-tauri/src/commands/ai/agentic.rs +++ b/src-tauri/src/commands/ai/agentic.rs @@ -25,7 +25,62 @@ use super::audit::process_tool_calls; use super::{AiChatEvent, AiSession}; /// Agentic 循环最大迭代次数 -pub(crate) const MAX_AGENT_ITERATIONS: usize = 10; +/// +/// 默认 10 轮。未来可配置接入点:接入 AppState(新增 `agent_config` 字段)/df-storage settings KV +/// 表后,改为从配置读(默认值仍为 10)。当前项目无 agent 配置位(AppState/df-storage config 表 +/// 均无 agent 配置槽),故暂以常量承载,避免引入 AppState 新字段等大改(违反 P2 零行为变边界)。 +/// 接入路径:run_agentic_loop 签名增 `max_iterations: usize` 参数,调用方从 AppState 读取透传。 +pub const MAX_AGENT_ITERATIONS: usize = 10; + +// ============================================================ +// B-260615-09: generating 状态 RAII guard +// ============================================================ + +/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。 +/// +/// 两路复位: +/// - 正常路径:exit 点显式 `reset().await` 即时复位(emit 前调,保证"复位→emit"顺序, +/// 前端收事件时后端已可接下一条)。 +/// - 异常路径(panic/未走正常 return):Drop 兜底 spawn 复位,防 generating 永真卡死前端。 +/// +/// 注:try_continue_agent_loop 不用 guard——其 should_continue=false 路径需保持 +/// generating=true(审批等待态),全函数 guard 会误复位;该函数单点 provider-Err 复位保持手动。 +struct GeneratingGuard { + session: Arc>, + done: bool, +} + +impl GeneratingGuard { + fn new(session: Arc>) -> Self { + Self { session, done: false } + } + + /// 显式复位 generating=false。emit 前调用保证顺序。幂等。 + async fn reset(&mut self) { + if !self.done { + self.session.lock().await.generating = false; + self.done = true; + } + } + + /// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用: + /// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。 + /// (B-260615-26: 修复审批执行后对话不续生成回归) + fn disarm(&mut self) { + self.done = true; + } +} + +impl Drop for GeneratingGuard { + fn drop(&mut self) { + if !self.done { + let session = self.session.clone(); + tauri::async_runtime::spawn(async move { + session.lock().await.generating = false; + }); + } + } +} /// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环 /// @@ -44,11 +99,44 @@ pub(crate) async fn run_agentic_loop( knowledge_config: crate::state::KnowledgeConfig, llm_concurrency: LlmConcurrency, ) { - let provider: Box = df_ai::build_provider( - &provider_config.provider_type, - &provider_config.base_url, - &provider_config.api_key, - &provider_config.default_model, + // B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底) + let mut guard = GeneratingGuard::new(session_arc.clone()); + + // FR-S1: resolve→ensure_resolved_key(空 key 早失败)→build_provider 三步统一走工厂 + // 空 key 早失败(逻辑见 secret::ensure_resolved_key 单测):避免空 key 发请求吃 401,错误伪装成"API Key 无效" + // + // B-260615-17:resolve 一次复用——原实现 build_provider_for 成功后又独立调 resolve_provider_secret + // 取 key_len(重复 keyring resolve)。现 resolve 一次:既供 key_len 诊断日志,又供 build_provider, + // 去重复 keyring resolve 调用。逻辑等价于 secret::build_provider_for(resolve→ensure→build 三步), + // 仅因 build_provider_for 隐藏 resolved key 无法复用而在此内联(未改 secret.rs 锁边界)。 + let resolved_key = super::secret::resolve_provider_secret(&provider_config); + let key_len = resolved_key.len(); + let provider: Box = match super::secret::ensure_resolved_key( + &provider_config.name, &resolved_key, + ) { + Ok(()) => df_ai::build_provider( + &provider_config.provider_type, + &provider_config.base_url, + &resolved_key, + &provider_config.default_model, + ), + Err(msg) => { + guard.reset().await; + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { + error: msg, + conversation_id: Some(conv_id.clone()), + }); + return; + } + }; + // 诊断日志:401/错误时据此定位是 url/type/model/key 哪项问题(只记长度不记明文) + tracing::info!( + provider = %provider_config.name, + provider_type = %provider_config.provider_type, + base_url = %provider_config.base_url, + model = %provider_config.default_model, + key_len = key_len, + "[ai] 发起 LLM 请求" ); let tool_defs = tools_arc.tool_definitions(); // 停止信号副本:stream_llm 与每轮迭代共享读取,避免重复加锁 @@ -57,6 +145,10 @@ pub(crate) async fn run_agentic_loop( // token 累加器:loop 生命周期内各轮叠加,退出时传 save_conversation(累加模式落库) let mut tokens = TokenAccumulator::default(); + // 收敛标志:仅当 LLM 末轮无 tool_calls 自行 break(正常收敛)时置 true; + // 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常 + let mut converged = false; + for iteration in 0..MAX_AGENT_ITERATIONS { // 用户请求停止 → 收尾退出(已生成文本已在上一轮入库) if stop_flag.load(Ordering::SeqCst) { @@ -69,13 +161,27 @@ pub(crate) async fn run_agentic_loop( save_conversation(&session_arc, &db, &conv_id, Some(&usage), None).await; // 标题生成后台化:不阻塞 Completed emit(失败有 extract_title 兜底) spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); - let mut session = session_arc.lock().await; - session.generating = false; + guard.reset().await; // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) }); return; } + // B-260615-11: 旧 loop 污染防护——每轮开始校验对话一致性。 + // 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧, + // 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。 + { + let session = session_arc.lock().await; + if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) { + tracing::warn!( + stale_conv = %conv_id, + active_conv = ?session.active_conversation_id, + "[ai] 对话已切换,旧 loop 退出(B-260615-11)避免污染新对话" + ); + return; + } + } + // 新一轮通知前端(第二轮起),前端需新建 assistant 消息 if iteration > 0 { let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiAgentRound { @@ -119,8 +225,7 @@ pub(crate) async fn run_agentic_loop( Some(result) => result, None => { // 错误已在 stream_llm 中 emit,直接结束 - let mut session = session_arc.lock().await; - session.generating = false; + guard.reset().await; return; } }; @@ -136,6 +241,16 @@ pub(crate) async fn run_agentic_loop( let has_tool_calls = !tool_calls_acc.is_empty(); { let mut session = session_arc.lock().await; + // B-260615-11: push 前再校验(stream_llm 期间用户可能新建对话)。 + // 读端读到被 clear 的空历史不致命,但 push 写回新对话是污染,必须挡。 + if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) { + tracing::warn!( + stale_conv = %conv_id, + active_conv = ?session.active_conversation_id, + "[ai] stream 后对话已切换,丢弃本轮 push(B-260615-11)避免污染新对话" + ); + return; + } if has_tool_calls { let mut order: Vec = tool_calls_acc.keys().copied().collect(); order.sort_unstable(); @@ -165,15 +280,14 @@ pub(crate) async fn run_agentic_loop( save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await; // 标题生成后台化:不阻塞 Completed emit(失败有 extract_title 兜底) spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency); - let mut session = session_arc.lock().await; - session.generating = false; + guard.reset().await; // generating 复位后再 emit Completed:保证前端收事件时后端已可接下一条(发送队列续发不被"正在生成中"拒绝) let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage.total_tokens, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) }); return; } - // 无工具调用 → 最终文本响应,循环结束 - if !has_tool_calls { break; } + // 无工具调用 → 最终文本响应,正常收敛退出 + if !has_tool_calls { converged = true; break; } // 处理工具调用(Low 自动执行 / Medium+High 待审批) let pending_count = { @@ -189,12 +303,30 @@ pub(crate) async fn run_agentic_loop( total_tokens: tokens.total(), }; save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&provider_config.default_model)).await; + // B-260615-26: 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成, + // 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续) + guard.disarm(); return; // generating 保持 true } // 全部自动执行完成 → 继续下一轮 } + // 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):异常中断,提示用户 + // 与 break 正常收敛(break→converged=true)区分:这里仍走入库+Completed,但前置发 AiError 警示 + if !converged { + tracing::warn!( + conv_id = %conv_id, + max_iter = MAX_AGENT_ITERATIONS, + "[ai] agentic 循环达最大轮次(MAX_AGENT_ITERATIONS={})仍未收敛,可能未完成", + MAX_AGENT_ITERATIONS, + ); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { + error: format!("达到最大轮次({} 轮),Agent 可能未完成(末轮工具结果未回传模型)", MAX_AGENT_ITERATIONS), + conversation_id: Some(conv_id.clone()), + }); + } + // 正常完成 let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), @@ -224,24 +356,72 @@ pub(crate) async fn run_agentic_loop( }); } - let mut session = session_arc.lock().await; - session.generating = false; + guard.reset().await; // generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成 let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), conversation_id: Some(conv_id.clone()) }); } /// 检查是否所有待审批已处理,如果是则恢复 agentic 循环 +/// +/// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。 +/// 各 return 点的语义判断: +/// 1) should_continue=false(generating 已复位 / pending_approvals 非空): +/// - generating=false → 用户点了停止(ai_chat_stop 复位)或会话已结束,emit AiCompleted 标当前轮收敛 +/// (streaming=true 由 AiCompleted 清理) +/// - pending_approvals 非空 → 转入审批等待态(其他审批未决),emit AiCompleted 标当前轮结束 +/// (前端审批态 watchdog 已 clear,不卡) +/// 2) get_active_provider Err → 无可用 provider(配置丢失/全删),无法续生成,emit AiError +/// (语义:配置错误,用户需设 provider;非 generating 复位可恢复) pub(crate) async fn try_continue_agent_loop(app: &AppHandle, state: &AppState) { - let should_continue = { + let (is_generating, has_pending) = { let session = state.ai_session.lock().await; - session.generating && session.pending_approvals.is_empty() + (session.generating, !session.pending_approvals.is_empty()) }; + let should_continue = is_generating && !has_pending; - if !should_continue { return; } + if !should_continue { + // generating=false(被 stop)或仍有审批(pending_approvals 非空): + // 统一 emit AiCompleted 标当前轮收敛,清前端 streaming。 + // 轮 token 已在前序 AiCompleted/AiApprovalResult 流程落库,此处零 token 上报仅作收敛信号。 + if is_generating { + // pending_approvals 非空但 generating 仍 true:转审批态,前端审批态 watchdog 已 clear,不卡 + tracing::info!("[ai] try_continue 跳过:仍有待审批,转审批等待态"); + } else { + // generating 已复位(用户 stop 或前序循环已 emit Completed):补发 AiCompleted 防前端卡住 + tracing::info!("[ai] try_continue 跳过:generating 已复位(被 stop/已结束),补发 AiCompleted 清前端 streaming"); + let conv_id = { + let session = state.ai_session.lock().await; + session.active_conversation_id.clone().unwrap_or_default() + }; + let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { + total_tokens: 0, + prompt_tokens: 0, + completion_tokens: 0, + conversation_id: Some(conv_id), + }); + } + return; + } let provider_config = match get_active_provider(state).await { Ok(p) => p, - Err(_) => return, + Err(e) => { + // 无可用 provider(配置丢失/全删):无法续生成,emit AiError。 + // 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内 + // build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。 + // 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保 generating=true(审批等待态), + // 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。 + let mut session = state.ai_session.lock().await; + session.generating = false; + let conv_id = session.active_conversation_id.clone().unwrap_or_default(); + drop(session); + tracing::warn!(error = %e, "[ai] try_continue 失败:无可用 provider"); + let _ = app.emit("ai-chat-event", AiChatEvent::AiError { + error: e, + conversation_id: Some(conv_id), + }); + return; + } }; let (lang, conv_id) = { let session = state.ai_session.lock().await; diff --git a/src-tauri/src/commands/ai/commands.rs b/src-tauri/src/commands/ai/commands.rs index 5a063b2..a2890ae 100644 --- a/src-tauri/src/commands/ai/commands.rs +++ b/src-tauri/src/commands/ai/commands.rs @@ -259,6 +259,33 @@ pub async fn ai_chat_stop(state: State<'_, AppState>, app: AppHandle) -> Result< } // 流式生成中:置位让 loop 自行收尾 session.stop_flag.store(true, Ordering::SeqCst); + let conv_id = session.active_conversation_id.clone(); + drop(session); + + // B-260615-13 兜底任务:loop 若 panic/异常退出漏发收尾,stop_flag 无人读, + // 用户点 stop 无反应、generating 卡 true。这里 sleep 短超时后重检 generating, + // 仍 true(loop 没复位)则强制复位 + emit AiCompleted 通知前端收尾。 + // 正常路径(loop 活自行复位)此时 generating 已 false,无操作退出。 + let session_arc = state.ai_session.clone(); + let app_handle = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let mut session = session_arc.lock().await; + if session.generating { + session.generating = false; + drop(session); // 释放锁后再 emit,避免持锁调 runtime emit + let _ = app_handle.emit( + "ai-chat-event", + AiChatEvent::AiCompleted { + total_tokens: 0, + prompt_tokens: 0, + completion_tokens: 0, + conversation_id: conv_id, + }, + ); + } + }); + Ok(()) } @@ -281,11 +308,15 @@ fn mask_api_key(key: &str) -> String { #[tauri::command] pub async fn ai_list_providers(state: State<'_, AppState>) -> Result, String> { let mut providers = state.ai_providers.list_all().await.map_err(|e| e.to_string())?; - // IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文 + // IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。 + // 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文) for p in &mut providers { - if !p.api_key.is_empty() { - p.api_key = mask_api_key(&p.api_key); - } + let real = if !p.api_key.is_empty() { + p.api_key.clone() // 未迁移(老明文) + } else { + super::secret::get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring + }; + p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) }; } Ok(providers) } @@ -319,20 +350,45 @@ pub async fn ai_save_provider( .map_err(|e| e.to_string())? .iter().any(|p| p.is_default), }; - // api_key 空(编辑不改 key,前端 list 拿到 mask 故留空)→保留原 DB 值(FR-S1) - let api_key = if api_key.is_empty() { - match &id { - Some(pid) => state.ai_providers.get_by_id(pid).await - .map_err(|e| e.to_string())? - .map(|p| p.api_key) - .unwrap_or_default(), - None => String::new(), + // FR-S1:密钥存 OS keyring,DB api_key 列恒空(不入明文)。 + // api_key 非空 = 新/改密钥 → 写 keyring;空 = 编辑不改 → 保留原 keyring 密钥不动。 + let provider_id = id.clone().unwrap_or_else(new_id); + if !api_key.is_empty() { + // 显式改/填密钥 → 写 keyring(现状不变) + if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) { + return Err(format!("密钥保存到系统钥匙串失败: {}", e)); } - } else { - api_key - }; + } else if let Some(pid) = &id { + // 空 key 编辑:保住密钥,防未迁移态静默丢失(R-PD-1)。 + // 未迁移态(DB 有明文 + keyring 空)下,下方 INSERT OR REPLACE 会无条件清 DB api_key, + // 唯一密钥副本被覆盖成空 → keyring 也空 → resolve 返空 → provider 报废密钥永久丢失。 + // 兜底:发现未迁移态先即时迁移补密钥,迁移成功后再让下方清 DB 明文(收敛到迁移完成态); + // 迁移失败则 Err 阻断保存且 INSERT OR REPLACE 不执行 → DB 明文保留,绝不劣化现状。 + let old = state.ai_providers.get_by_id(pid).await + .map_err(|e| e.to_string())?; + if let Some(old) = old { + if !old.api_key.is_empty() + && super::secret::get_provider_secret(pid).is_none() + { + // DB 有明文 且 keyring 无 → 即时迁移补密钥 + if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) { + return Err(format!( + "检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\ + 已保留原密钥未改动——请检查系统钥匙串权限后再次保存。", + e + )); + } + tracing::info!( + "[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring(R-PD-1 兜底)", + pid + ); + } + // else: keyring 已有 / DB 已空 → 下方 INSERT OR REPLACE 清 DB 明文安全 + } + } + let api_key = String::new(); // DB 恒空(真实密钥在 keyring) let record = AiProviderRecord { - id: id.unwrap_or_else(new_id), + id: provider_id, name, provider_type: if provider_type.is_empty() { "openai_compat".to_string() } else { provider_type }, api_key, @@ -391,6 +447,11 @@ pub async fn ai_delete_provider( provider_id: String, ) -> Result<(), String> { state.ai_providers.delete(&provider_id).await.map_err(|e| e.to_string())?; + // CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方, + // 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写) + if let Err(e) = super::secret::delete_provider_secret(&provider_id) { + tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e); + } // 删除的若是当前默认,清空 active 指向,避免悬空 let mut session = state.ai_session.lock().await; if session.active_provider_id.as_deref() == Some(&provider_id) { @@ -405,17 +466,34 @@ pub async fn ai_delete_provider( /// 创建新对话 #[tauri::command] -pub async fn ai_conversation_create(state: State<'_, AppState>) -> Result { +pub async fn ai_conversation_create( + app: AppHandle, + state: State<'_, AppState>, +) -> Result { // 懒创建:仅生成 id 存内存,不落库;避免新建后不发消息产生空记录。 // 首条消息发送后由 save_conversation upsert 写入。 let id = new_id(); let now = now_millis(); let mut session = state.ai_session.lock().await; - // 生成中(含审批等待态)禁止新建对话:否则 clear 会清空 pending_approvals, - // 用户审批时 ai_approve 找不到 pending → generating 永不复位 → 面板卡死需重启 + // B-260615-10: 生成中软复位取代硬拦——强制结束当前生成,让用户能立即新建对话。 + // 旧 loop 经 B-260615-11 一致性校验(active_conversation_id 变更)自动退出,不污染新对话; + // stop_flag 置位作双保险,让 streaming 中的旧 loop 也尽快收尾。 if session.generating { - return Err("生成中无法新建对话,请先停止或等待完成".to_string()); + let old_conv = session.active_conversation_id.clone(); + session.generating = false; + session.pending_approvals.clear(); + session.stop_flag.store(true, Ordering::SeqCst); + drop(session); + if let Some(old_conv) = old_conv { + let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { + total_tokens: 0, + prompt_tokens: 0, + completion_tokens: 0, + conversation_id: Some(old_conv), + }); + } + session = state.ai_session.lock().await; } session.active_conversation_id = Some(id.clone()); session.active_conv_created_at = Some(now); diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index b9de3ea..675a1bc 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -23,9 +23,11 @@ pub(crate) struct TokenAccumulator { impl TokenAccumulator { /// 叠加一轮用量(round_usage 为本轮流式末 chunk 的累计用量) + /// + /// saturating_add:恶意/异常 provider 返回巨大值时,避免 u32 += 溢出回绕打乱后续 budget 判定。 pub(crate) fn add(&mut self, prompt: u32, completion: u32) { - self.prompt += prompt; - self.completion += completion; + self.prompt = self.prompt.saturating_add(prompt); + self.completion = self.completion.saturating_add(completion); } pub(crate) fn prompt(&self) -> u32 { @@ -37,7 +39,7 @@ impl TokenAccumulator { } pub(crate) fn total(&self) -> u32 { - self.prompt + self.completion + self.prompt.saturating_add(self.completion) } } diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index e7bb28f..c309d84 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -29,10 +29,15 @@ async fn resolve_embed_provider( return None; } }; - Some(( - df_ai::build_provider(&rec.provider_type, &rec.base_url, &rec.api_key, &rec.default_model), - model, - )) + // build_provider_for 含空 key 早失败:Err → 返回 None 触发 LIKE 降级(与原 embed 失败降级行为一致) + let provider = match super::secret::build_provider_for(&rec) { + Ok(p) => p, + Err(e) => { + tracing::warn!("embedding provider 密钥不可用(降级 LIKE): {}", e); + return None; + } + }; + Some((provider, model)) } /// 生成文本嵌入(向量检索用) @@ -317,12 +322,11 @@ async fn extract_knowledge_from_conversation( tool_choice: None, }; - let provider: Box = df_ai::build_provider( - &provider_config.provider_type, - &provider_config.base_url, - &provider_config.api_key, - &provider_config.default_model, - ); + let provider: Box = match super::secret::build_provider_for(provider_config) { + Ok(p) => p, + // 空 key 早失败:上层(maybe_spawn_extraction/trigger_extraction_now)已 warn log 降级,语义一致 + Err(e) => return Err(anyhow::anyhow!("provider 密钥不可用: {}", e)), + }; // LLM 并发限流(知识提炼属独立调用,纳入双层 Semaphore) let _global_permit = llm_concurrency.acquire_global().await; let _per_conv_permit = llm_concurrency.acquire_per_conv().await; diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index b0e08bf..2d6502f 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -26,6 +26,7 @@ pub mod commands; pub mod conversation; pub mod knowledge_inject; pub mod prompt; +pub mod secret; pub mod skills; pub mod stream_recv; pub mod title; @@ -84,12 +85,32 @@ pub enum AiChatEvent { AiError { error: String, conversation_id: Option }, /// Agent 循环新一轮(前端需新建 assistant 消息) AiAgentRound { round: u32, conversation_id: Option }, + /// 流式心跳(静默期报活,前端 watchdog reset,区分「LLM 在跑」与「真断」) + AiHeartbeat { conversation_id: Option }, } // ============================================================ // 会话状态(放 mod.rs,各子文件经 use super::* 拿到) // ============================================================ +/// AI 会话读侧状态视图(只读枚举,由 [`AiSession::session_state`] 推导) +/// +/// 这是 **只读视图**,不替代 `AiSession` 上 `generating` / `pending_approvals` / +/// `stop_flag` 等字段定义——字段仍是唯一真相源,调用方写状态时继续写字段, +/// 读状态时统一走 `session_state()` 收敛判断逻辑(避免散布的 `if generating` / +/// `if !pending_approvals.is_empty()` 三路组合在各调用点各自实现)。 +/// +/// 优先级:`pending_approvals` 非空(有挂起审批优先报 AwaitingApproval, +/// 即使同时在流式)> `generating`(Streaming)> 都不满足(Idle)。 +pub enum SessionState { + /// 空闲:既未生成、也无挂起审批 + Idle, + /// 流式生成中:generating 为 true 且无挂起审批 + Streaming, + /// 等待审批:pending_approvals 非空 + AwaitingApproval, +} + /// AI 会话内状态(Mutex 保护) pub struct AiSession { /// 对话历史(ContextManager:唯一消息真相源,裁剪仅影响发送视图,不影响持久化) @@ -100,7 +121,17 @@ pub struct AiSession { pub active_conversation_id: Option, /// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at) pub active_conv_created_at: Option, - /// 挂起的审批(tool_call_id → 审批信息) + /// 挂起的审批。 + /// + /// **结构**:单层 `HashMap`,按 `tool_call_id` + /// 路由审批结果(`tool_call_id` 是路由键)。`PendingApproval.conversation_id` + /// 是业务语义(哪个对话产生的审批)而非路由键——IPC 端按 `tool_call_id` 精确命中。 + /// + /// **现状**:`ai_pending_tool_calls` 等读取路径按 `conversation_id` 做 O(n) 线性 + /// 过滤。在典型场景(单对话、少量并发审批)下 n 极小,O(n) 可接受,无需二级索引。 + /// + /// **未来**:若多会话并发、审批量显著增大,可考虑加二级索引 + /// (`conversation_id → Vec`)把按会话过滤降到 O(1)。 pub pending_approvals: HashMap, /// 是否正在生成 pub generating: bool, @@ -123,6 +154,31 @@ impl AiSession { stop_flag: Arc::new(AtomicBool::new(false)), } } + + /// 推导会话读侧状态视图(只读,不写任何字段)。 + /// + /// 优先级:`pending_approvals` 非空 → [`SessionState::AwaitingApproval`]; + /// 否则 `generating` → [`SessionState::Streaming`];否则 → [`SessionState::Idle`]。 + /// + /// 收敛点:调用方读「会话处于什么阶段」时统一走本方法,替代散布的 + /// `if !pending_approvals.is_empty() / if generating` 三路组合。 + /// + /// # 待替换调用点(本次仅新增视图,不替换调用点) + /// + /// - `agentic.rs:283` — agent loop 入口/恢复处对 generating + 审批的组合判断 + /// - `commands.rs:250` — `ai_pending_tool_calls` 等读取前的状态门控 + /// - `commands.rs:451` — 审批提交/会话复位路径的状态判断 + /// + /// 上述三处替换由 P0 任务承接,本方法先就位供其切换。 + pub fn session_state(&self) -> SessionState { + if !self.pending_approvals.is_empty() { + SessionState::AwaitingApproval + } else if self.generating { + SessionState::Streaming + } else { + SessionState::Idle + } + } } /// 待审批的工具调用 diff --git a/src-tauri/src/commands/ai/secret.rs b/src-tauri/src/commands/ai/secret.rs new file mode 100644 index 0000000..ee66b5e --- /dev/null +++ b/src-tauri/src/commands/ai/secret.rs @@ -0,0 +1,228 @@ +//! FR-S1 api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串 +//! +//! 设计: +//! - keyring entry: service=`devflow-ai-provider`, username=provider_id +//! - DB `api_key` 列恒空(迁移后/新建均空),真实密钥唯一源 = OS keyring +//! - 启动一次性迁移:`migrate_secrets_to_keyring` 读老明文 → keyring → DB 置空(失败保留明文下次重试) +//! - 消费点(build_provider)经 `resolve_provider_secret` 取:keyring 优先,fallback DB(兼容未迁移) +//! - 跨平台:Windows Credential Manager / macOS Keychain / Linux Secret Service + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use df_storage::crud::AiProviderRepo; +use df_storage::models::AiProviderRecord; +use keyring::Entry; + +const KEYRING_SERVICE: &str = "devflow-ai-provider"; + +/// 迁移失败计数器阈值:同一 provider 累计失败到此次数 → 升级为 warn 提示明文密钥长期滞留风险。 +/// 跨启动持久化(sidecar 文件),计数仅用于告警,不影响兼容时序(不强制迁移、不删明文)。 +const MIGRATION_FAIL_THRESHOLD: u32 = 3; + +/// 迁移失败计数 sidecar 文件(/.devflow-keyring-failcount):逐行 `provider_id=count`。 +/// cwd 未必是稳定路径,但 R-PD-4 目标仅是「检测到反复失败/滞留时告警」,误读为 0 即按未达阈值处理,无副作用。 +fn failcount_path() -> PathBuf { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(".devflow-keyring-failcount") +} + +/// 读取全部失败计数(id → count)。文件缺失/损坏 → 空 map(按未达阈值处理)。 +fn read_failcounts() -> HashMap { + let mut map = HashMap::new(); + if let Ok(text) = fs::read_to_string(failcount_path()) { + for line in text.lines() { + let mut parts = line.splitn(2, '='); + let id = parts.next().unwrap_or("").trim(); + let cnt = parts.next().and_then(|s| s.trim().parse::().ok()); + if !id.is_empty() { + if let Some(c) = cnt { + map.insert(id.to_string(), c); + } + } + } + } + map +} + +/// 持久化全部失败计数。写入失败仅 log,不阻断迁移主流程。 +fn write_failcounts(map: &HashMap) { + let mut text = String::new(); + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); // 稳定顺序,减少无谓 diff + for (id, cnt) in entries { + text.push_str(id); + text.push('='); + text.push_str(&cnt.to_string()); + text.push('\n'); + } + if let Err(e) = fs::write(failcount_path(), text) { + tracing::debug!("[FR-S1] 迁移失败计数文件写入失败(忽略): {}", e); + } +} + +/// 记录一次迁移失败并返回累计失败次数。持久化失败也不影响返回值(仍递增内存计数用于本次告警)。 +fn record_migration_fail(id: &str) -> u32 { + let mut map = read_failcounts(); + let next = map.get(id).copied().unwrap_or(0).saturating_add(1); + map.insert(id.to_string(), next); + write_failcounts(&map); + next +} + +/// 清零某 provider 的失败计数(迁移成功后调用,避免历史失败在后续再触发误告警)。 +fn clear_migration_failcount(id: &str) { + let mut map = read_failcounts(); + if map.remove(id).is_some() { + write_failcounts(&map); + } +} + +fn entry_for(id: &str) -> anyhow::Result { + Entry::new(KEYRING_SERVICE, id).map_err(|e| anyhow::anyhow!("keyring entry 创建失败: {}", e)) +} + +/// 读取 provider 密钥(优先 keyring;无则 None) +pub fn get_provider_secret(id: &str) -> Option { + let entry = entry_for(id).ok()?; + match entry.get_password() { + Ok(s) if !s.is_empty() => Some(s), + _ => None, + } +} + +/// 消费点用:解析 provider 真实密钥 — keyring 优先,fallback DB.api_key(兼容未迁移老库) +pub fn resolve_provider_secret(record: &AiProviderRecord) -> String { + if !record.api_key.is_empty() { + return record.api_key.clone(); + } + get_provider_secret(&record.id).unwrap_or_default() +} + +/// 写入密钥到 keyring(覆盖) +pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> { + let entry = entry_for(id)?; + entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败: {}", e)) +} + +/// 删除 keyring 密钥(provider 删除时清理) +pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> { + let entry = entry_for(id)?; + entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败: {}", e)) +} + +/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断) +pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result { + let providers = repo.list_all().await?; + let mut migrated = 0; + for mut p in providers { + if p.api_key.is_empty() { + continue; // 已迁移或无密钥 + } + if let Err(e) = set_provider_secret(&p.id, &p.api_key) { + // 累计失败次数:达阈值(默认 3)升级告警,提示明文密钥长期滞留 SQLite(无加密)风险。 + // 计数仅告警用,不改兼容时序——仍保留明文下次重试,不强制迁移、不删明文。 + let n = record_migration_fail(&p.id); + if n >= MIGRATION_FAIL_THRESHOLD { + tracing::warn!( + "[FR-S1] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\ + 建议:1) 确认 OS 钥匙串可用(Win Credential Manager / macOS Keychain);\ + 2) keyring 后端异常时排查对应平台后端;3) 必要时手动在设置中重新保存密钥触发写入", + p.id, n + ); + } else { + tracing::warn!( + "[FR-S1] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}", + p.id, n, MIGRATION_FAIL_THRESHOLD, e + ); + } + continue; + } + let pid = p.id.clone(); + p.api_key.clear(); + if let Err(e) = repo.insert(p).await { + tracing::warn!("[FR-S1] 迁移后清空 DB api_key 失败 {}: {}", pid, e); + } + // 迁移成功 → 清零该 provider 的失败计数(下次若再出现失败从 1 重新累计) + clear_migration_failcount(&pid); + migrated += 1; + } + if migrated > 0 { + tracing::info!("[FR-S1] {} 条 provider 密钥迁移至 OS keyring", migrated); + } + Ok(migrated) +} + +/// 消费点统一入口:resolve_provider_secret → ensure_resolved_key → build_provider 三步打包。 +/// +/// 替代散落各处的「resolve + build_provider」二件套复制(漏 ensure_resolved_key → keyring 读不到时 +/// 空 key 照发请求吃 401,用户看「key 已保存」反复重试无解)。返 Result 让调用方按场景处理: +/// - 主链(agentic loop):空 key 早失败 emit AiError +/// - 后台 task(title/knowledge 提炼/embedding):Err 上层降级/记 log +pub fn build_provider_for( + record: &AiProviderRecord, +) -> Result, String> { + let api_key = resolve_provider_secret(record); + ensure_resolved_key(&record.name, &api_key)?; + Ok(df_ai::build_provider( + &record.provider_type, + &record.base_url, + &api_key, + &record.default_model, + )) +} + +/// 校验已解析的密钥是否可用:空(含纯空白)→明确错误信息,非空→Ok。 +/// 用于消费点(build_provider 前)早失败,避免空 key 发请求吃 401,错误伪装成"API Key 无效"。 +pub fn ensure_resolved_key(provider_name: &str, resolved: &str) -> Result<(), String> { + if resolved.trim().is_empty() { + Err(format!( + "未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存", + provider_name + )) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ensure_resolved_key_rejects_empty() { + assert!(ensure_resolved_key("GLM", "").is_err()); + } + + #[test] + fn ensure_resolved_key_rejects_whitespace() { + // 纯空白也视为无密钥(防粘贴时只有空格) + assert!(ensure_resolved_key("GLM", " ").is_err()); + } + + #[test] + fn ensure_resolved_key_accepts_nonempty() { + assert!(ensure_resolved_key("GLM", "sk-abc").is_ok()); + } + + #[test] + fn ensure_resolved_key_error_mentions_provider_name() { + let err = ensure_resolved_key("我的提供商", "").unwrap_err(); + assert!(err.contains("我的提供商"), "错误信息应含 provider 名便于定位"); + } + + #[test] + fn resolve_prefers_db_when_non_empty() { + // DB api_key 非空 → 直接返回 DB 值,不触发 keyring(FR-S1 兼容未迁移老库) + use df_storage::models::AiProviderRecord; + let rec = AiProviderRecord { + id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(), + api_key: "sk-db-fallback".into(), base_url: "https://x".into(), + default_model: "m".into(), models: None, is_default: false, + config: None, created_at: "0".into(), updated_at: "0".into(), + }; + assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback"); + } +} diff --git a/src-tauri/src/commands/ai/stream_recv.rs b/src-tauri/src/commands/ai/stream_recv.rs index 9158f2b..2d874c5 100644 --- a/src-tauri/src/commands/ai/stream_recv.rs +++ b/src-tauri/src/commands/ai/stream_recv.rs @@ -6,17 +6,104 @@ use std::time::Duration; use tauri::{AppHandle, Emitter}; use futures::StreamExt; +use tracing::warn; use df_ai::provider::{CompletionRequest, LlmProvider}; use super::{AiChatEvent, ToolCallDraft}; +/// 从 anyhow 错误中尽力提取 HTTP 状态码/错误分类,供诊断拼接。 +/// +/// `provider.stream()` 失败有两类来源: +/// - 业务层:provider 在 non-2xx 时 `bail!("LLM 流式 API 错误 {status}: {body}")`,原始文本已含状态码, +/// 此处用正则从文本抠 `HTTP ` 或裸 ``(provider 串已含 status code)。 +/// - 传输层(reqwest,不直接命名以避免给 src-tauri 加依赖):从 Display 文本识别 +/// timeout/connect 关键词分类。 +/// +/// 返回 `(status_or_class, raw)`:status 取文本中首个三位数;否则按关键词给 timeout/connect/unknown。 +fn extract_error_diag(e: &anyhow::Error) -> (String, String) { + let raw = e.to_string(); + + // 1) 抠 HTTP 状态码:匹配 provider bail 串里的 "错误 4xx/5xx" 或 reqwest 的 "HTTP status"。 + // R-P2-6:原实现按字节窗口 `&bytes[i..i+3]` 切片——若窗口恰好切在多字节 UTF-8 字符中间会 panic + // (依赖中文恰好 3 字节、状态码恰为 ascii 的巧合)。改为按 char 迭代 + 前后非数字边界判断, + // 既 UTF-8 安全又顺便修复"长数字串(如端口号 14012)内嵌 401 误命中"的潜在问题。 + // 白名单码(首位 4/5,故只查 4xx/5xx 区段,减少无效匹配): + const HTTP_CODES: &[&str] = &[ + "400", "401", "403", "404", "408", "409", "413", + "422", "429", "500", "502", "503", "504", + ]; + // 把 raw 按 char 收集,索引即 char 下标(非字节),边界判断用 char 安全 + let chars: Vec = raw.chars().collect(); + let n = chars.len(); + let mut i = 0; + while i + 3 <= n { + // 窗口必须是三个 ascii 数字 + if chars[i].is_ascii_digit() && chars[i + 1].is_ascii_digit() && chars[i + 2].is_ascii_digit() { + let code: String = chars[i..i + 3].iter().collect(); + // 前后边界必须非数字(否则会从端口号 14012 里抠出 401) + let prev_ok = i == 0 || !chars[i - 1].is_ascii_digit(); + let next_ok = i + 3 == n || !chars[i + 3].is_ascii_digit(); + if prev_ok && next_ok && HTTP_CODES.contains(&code.as_str()) { + return (format!("HTTP {}", code), raw); + } + i += 3; // 已确认是三连数字,跳过避免窗口重叠重复扫 + continue; + } + i += 1; + } + + // 2) 传输层分类(reqwest Display 文本特征),不命名 reqwest 类型 + let lower = raw.to_lowercase(); + let class = if lower.contains("timeout") || lower.contains("超时") { + "timeout" + } else if lower.contains("connect") || lower.contains("dns") || lower.contains("resolve") { + "connect" + } else if lower.contains("timed out") { + "timeout" + } else { + "unknown" + }; + (class.to_string(), raw) +} + +/// AiError 诊断消息的上下文,区分「建连/首字节失败」与「流中途断」两类。 +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub(crate) enum DiagKind { + /// `provider.stream()` 直接返回 Err:连接/鉴权/HTTP non-2xx 等 + Init, + /// 流已建立,next() 返回 Err:SSE 传输断/解析错等 + MidStream, +} + +/// 拼接 AiError 的可读诊断文本(纯函数,便于单测)。 +/// +/// 格式:`[] <上下文>(): ` +/// - provider_name:`provider.name()`(anthropic 协议为 "anthropic-compat", +/// openai 兼容为模型名)。当前 `LlmProvider` trait 未暴露 base_url/provider_type, +/// 不改签名的前提下这是唯一可得的 provider 标识。 +/// - status_or_class:`HTTP 4xx/5xx` 或 `timeout`/`connect`/`unknown` 分类。 +/// - raw:anyhow 原始错误文本。 +pub(crate) fn fmt_diag(provider_name: &str, kind: DiagKind, status_or_class: &str, raw: &str) -> String { + let ctx = match kind { + DiagKind::Init => "AI 调用失败", + DiagKind::MidStream => "流式接收错误", + }; + format!("[{}] {}({}): {}", provider_name, ctx, status_or_class, raw) +} + /// 流式接收 LLM 响应,返回 (完整文本, 工具调用草稿) /// /// 三类异常处理: /// - idle timeout(120s 无 chunk):判定连接静默断,emit AiError 返回 None /// - 流尽但从未收到 finished 信号:判定异常中断,emit AiError 返回 None(丢弃残缺,不当完整入库) /// - 用户停止(stop_flag):break 返回 Some(已收文本),由调用方入库展示后退出 +/// +/// 心跳与停止响应(B-260615-02 / B-260615-04):单 `tokio::select!` 三分支 +/// - `stream.next()`:正常 chunk 处理 +/// - `heartbeat.tick()`(30s):静默期(如工具执行后等下一轮首 chunk)emit `AiHeartbeat`, +/// 前端 watchdog 据此 reset,区分「LLM 在跑」与「真断」(避免空气泡误报中断) +/// - `stop_notify.notified()`:用户点停止即时打断,不再等 chunk 到或 120s idle timeout pub(crate) async fn stream_llm( provider: &dyn LlmProvider, request: CompletionRequest, @@ -26,6 +113,8 @@ pub(crate) async fn stream_llm( ) -> Option<(String, HashMap, df_ai::provider::TokenUsage)> { /// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断 const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120); + /// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog + const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); match provider.stream(request).await { Ok(mut stream) => { @@ -35,56 +124,121 @@ pub(crate) async fn stream_llm( let mut stopped = false; let mut final_usage: Option = None; + // B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器 + // (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。 + // tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发 + // (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 stream.next() 抢分支错过首 chunk)。 + let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL); + heartbeat.tick().await; + loop { - // 用户主动停止:保留已收文本退出 + // B-260615-04:stop 即时打断。stream.next() 阻塞等 chunk 时, + // 用户点停止需等 chunk 到或 120s idle timeout 才轮到此处检查—— + // 合并到下方 select! 的 stop_notify 分支后此处为快路径(非阻塞首检)。 + // stop_flag 可能被 stopChat() 在 select! 阻塞期间置位, + // select! 的 stop_notify 分支会唤醒;此处保留作冗余快检(非阻塞)。 if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { stopped = true; break; } - // idle timeout 防"连接存活但中途静默"无限 hang - match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await { - Err(_elapsed) => { - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: "流式响应超时(120 秒无数据,连接可能已断开)".to_string(), - conversation_id: Some(conv_id.to_string()), - }); - return None; - } - Ok(None) => break, // 流正常结束 - Ok(Some(chunk_result)) => match chunk_result { - Ok(chunk) => { - if !chunk.delta.is_empty() { - full_text.push_str(&chunk.delta); - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiTextDelta { - delta: chunk.delta, + // 三分支 select!(B-260615-02 + B-260615-04 合并): + // 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断仍 emit AiError) + // 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog + // 3) stop_notify.notified():用户停止即时打断 + // + // 注意:stop_flag 是 AtomicBool 无 async 通知能力—— + // 此处用「timeout 包 stream.next() + 进入循环前/后查 stop_flag + 循环内 30s 心跳 tick」 + // 间接实现「≤30s 感知 stop」(每轮 select! 至多 120s,但心跳 tick 30s 一次会 + // 触发 select! 返回 → 循环回顶部 stop_flag 快检)。无 Notify 依赖,改动最小。 + tokio::select! { + // 1) 正常 chunk(idle timeout 包裹) + chunk_result = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()) => { + match chunk_result { + Err(_elapsed) => { + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { + error: "流式响应超时(120 秒无数据,连接可能已断开)".to_string(), conversation_id: Some(conv_id.to_string()), }); + return None; } - if let Some(tc_deltas) = &chunk.tool_calls { - for tc_delta in tc_deltas { - let draft = tool_calls_acc.entry(tc_delta.index).or_default(); - if let Some(id) = &tc_delta.id { draft.id = id.clone(); } - if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); } - if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); } + Ok(None) => break, // 流正常结束 + Ok(Some(chunk_result)) => match chunk_result { + Ok(chunk) => { + if !chunk.delta.is_empty() { + full_text.push_str(&chunk.delta); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiTextDelta { + delta: chunk.delta, + conversation_id: Some(conv_id.to_string()), + }); + } + if let Some(tc_deltas) = &chunk.tool_calls { + for tc_delta in tc_deltas { + let draft = tool_calls_acc.entry(tc_delta.index).or_default(); + if let Some(id) = &tc_delta.id { draft.id = id.clone(); } + if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); } + if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); } + } + } + if let Some(u) = &chunk.usage { + final_usage = Some(u.clone()); + } + // provider 流式错误事件(Anthropic SSE `type=="error"` 等): + // 不走 finished 完成路径,发 AiError + 丢弃残缺,与 Err 分支对齐(OpenAI 路径一致性)。 + if let Some(err_msg) = &chunk.error { + warn!( + provider = %provider.name(), + conv_id = %conv_id, + error = %err_msg, + "[ai] provider 流式错误事件", + ); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { + error: fmt_diag( + provider.name(), + DiagKind::MidStream, + "stream-error", + err_msg, + ), + conversation_id: Some(conv_id.to_string()), + }); + return None; + } + if chunk.finished { + finished_received = true; + break; + } + } + Err(e) => { + // 诊断:流中途错误(多为 SSE 传输断),补 provider 标识 + HTTP 状态/分类 + 原始文本 + let (status_or_class, raw) = extract_error_diag(&e); + warn!( + provider = %provider.name(), + status = %status_or_class, + conv_id = %conv_id, + error = %raw, + "[ai] 流式接收中途错误", + ); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { + error: fmt_diag( + provider.name(), + DiagKind::MidStream, + &status_or_class, + &raw, + ), + conversation_id: Some(conv_id.to_string()), + }); + return None; } } - if let Some(u) = &chunk.usage { - final_usage = Some(u.clone()); - } - if chunk.finished { - finished_received = true; - break; - } } - Err(e) => { - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: e.to_string(), - conversation_id: Some(conv_id.to_string()), - }); - return None; - } - }, + } + // 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02) + _ = heartbeat.tick() => { + // 心跳只在「仍在等下一 chunk」时有意义——若 stop_flag 已置,顶部快检会 break,无需发心跳 + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiHeartbeat { + conversation_id: Some(conv_id.to_string()), + }); + } } } @@ -93,8 +247,11 @@ pub(crate) async fn stream_llm( return Some((full_text, tool_calls_acc, final_usage.unwrap_or_default())); } - // 断连检测:流尽但从未收到 finished 信号 = 异常中断,丢弃残缺不当完整入库 - if !finished_received && (!full_text.is_empty() || !tool_calls_acc.is_empty()) { + // 断连检测:流尽但从未收到 finished 信号 = 异常中断,丢弃残缺不当完整入库。 + // B-260615-05:一律判异常(不区分内容空否)——空内容无 finished 同属异常: + // 上游 agentic.rs !has_tool_calls 早 break 会按正常路径 emit AiCompleted, + // 用户看空气泡无错误提示,转 P0 卡死入口。此处统一 emit AiError 拦截。 + if !finished_received { let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(), conversation_id: Some(conv_id.to_string()), @@ -105,11 +262,159 @@ pub(crate) async fn stream_llm( Some((full_text, tool_calls_acc, final_usage.unwrap_or_default())) } Err(e) => { + // 诊断:连接/鉴权/HTTP 错误,补 provider 标识 + HTTP 状态码/分类 + 原始文本, + // 便于区分 401(key)/404(url)/429(限流)/timeout/连接失败(provider_type 或 base_url 不对)。 + let (status_or_class, raw) = extract_error_diag(&e); + warn!( + provider = %provider.name(), + status = %status_or_class, + conv_id = %conv_id, + error = %raw, + "[ai] LLM 流式调用失败", + ); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: format!("AI 调用失败: {}", e), + error: fmt_diag( + provider.name(), + DiagKind::Init, + &status_or_class, + &raw, + ), conversation_id: Some(conv_id.to_string()), }); None } } } + +// ============================================================ +// 单测:诊断提取/格式化(纯函数,不发 HTTP、不依赖 app_handle) +// ============================================================ +#[cfg(test)] +mod tests { + use super::*; + + // ---- extract_error_diag:业务层 bail(provider 串已含状态码)---- + + /// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错) + #[test] + fn diag_extracts_401_from_provider_bail() { + let e = anyhow::anyhow!("LLM 流式 API 错误 401: Unauthorized"); + let (status, raw) = extract_error_diag(&e); + assert_eq!(status, "HTTP 401"); + assert!(raw.contains("401")); + } + + /// 404 = base_url/endpoint 不对 + #[test] + fn diag_extracts_404_from_provider_bail() { + let e = anyhow::anyhow!("LLM 流式 API 错误 404: Not Found"); + assert_eq!(extract_error_diag(&e).0, "HTTP 404"); + } + + /// 429 = 限流 + #[test] + fn diag_extracts_429_from_provider_bail() { + let e = anyhow::anyhow!("LLM 流式 API 错误 429: rate limit"); + assert_eq!(extract_error_diag(&e).0, "HTTP 429"); + } + + /// 5xx = 上游服务端错 + #[test] + fn diag_extracts_500_from_provider_bail() { + let e = anyhow::anyhow!("LLM 流式 API 错误 500: Internal Server Error"); + assert_eq!(extract_error_diag(&e).0, "HTTP 500"); + } + + // ---- extract_error_diag:传输层(reqwest Display 文本,不命名 reqwest)---- + + /// 超时:连接成功但响应慢/静默断 + #[test] + fn diag_classifies_timeout() { + let e = anyhow::anyhow!("error sending request for url (https://api.x.com/v1/chat/completions): operation timed out"); + let (status, _) = extract_error_diag(&e); + assert_eq!(status, "timeout"); + } + + /// 中文「超时」关键词 + #[test] + fn diag_classifies_timeout_cn() { + let e = anyhow::anyhow!("请求超时"); + assert_eq!(extract_error_diag(&e).0, "timeout"); + } + + /// 连接失败:DNS 解析失败 / 端点不通 + #[test] + fn diag_classifies_connect() { + let e = anyhow::anyhow!("dns error: failed to lookup address information"); + assert_eq!(extract_error_diag(&e).0, "connect"); + } + + /// resolve 关键词也归 connect + #[test] + fn diag_classifies_connect_resolve() { + let e = anyhow::anyhow!("error connecting: resolve failed"); + assert_eq!(extract_error_diag(&e).0, "connect"); + } + + // ---- extract_error_diag:边界 ---- + + /// 三位数但不在已知 HTTP 状态白名单(如 "200")→ 不当状态码,走分类 + #[test] + fn diag_ignores_non_http_status_number() { + let e = anyhow::anyhow!("成功 200 条记录"); + // 200 不在白名单,应落到 unknown + assert_eq!(extract_error_diag(&e).0, "unknown"); + } + + /// 完全无特征文本 → unknown,且 raw 原样返回 + #[test] + fn diag_unknown_preserves_raw() { + let e = anyhow::anyhow!("奇怪的错误 xyz"); + let (status, raw) = extract_error_diag(&e); + assert_eq!(status, "unknown"); + assert_eq!(raw, "奇怪的错误 xyz"); + } + + /// 状态码出现在更长数字串里也不误匹配(边界判断:前后必须非数字) + #[test] + fn diag_does_not_match_status_inside_longer_digits() { + // R-P2-6:边界判断后,白名单码嵌在长数字串内不再误命中(原窗口切片会从 14012 抠出 401) + let e = anyhow::anyhow!("port 14012 used"); + assert_eq!(extract_error_diag(&e).0, "unknown"); + // 纯长数字串(无白名单码)同样不命中 + let e2 = anyhow::anyhow!("port 99999 used"); + assert_eq!(extract_error_diag(&e2).0, "unknown"); + } + + // ---- fmt_diag:两个上下文 ---- + + #[test] + fn fmt_diag_init_branch() { + let s = fmt_diag("anthropic-compat", DiagKind::Init, "HTTP 401", "Unauthorized"); + assert_eq!(s, "[anthropic-compat] AI 调用失败(HTTP 401): Unauthorized"); + } + + /// R-P2-6:状态码紧贴中文字符(多字节 UTF-8)时仍能正确抠出 + /// (原按字节窗口切片在中文边界脆弱,改为 char 迭代后安全) + #[test] + fn diag_extracts_code_adjacent_to_multibyte_chars() { + let e = anyhow::anyhow!("错误401Unauthorized"); + assert_eq!(extract_error_diag(&e).0, "HTTP 401"); + // 中文在前:状态码前字符是多字节中文,char 迭代边界判断正确 + let e2 = anyhow::anyhow!("请求失败:401"); + assert_eq!(extract_error_diag(&e2).0, "HTTP 401"); + } + + #[test] + fn fmt_diag_midstream_branch() { + let s = fmt_diag("gpt-4o", DiagKind::MidStream, "timeout", "operation timed out"); + assert_eq!(s, "[gpt-4o] 流式接收错误(timeout): operation timed out"); + } + + /// provider 名/错误文本含特殊字符仍按模板拼接(无格式注入风险) + #[test] + fn fmt_diag_handles_special_chars() { + let s = fmt_diag("model/x", DiagKind::Init, "unknown", "err: {json} \"quoted\""); + assert_eq!(s, "[model/x] AI 调用失败(unknown): err: {json} \"quoted\""); + } +} diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index fe0b1e0..742e9f9 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -57,12 +57,17 @@ pub(crate) async fn ensure_conversation_title( } // 标题生成是独立一次 LLM 调用,自建 provider(便于后台 spawn,不借主 loop 的 &dyn LlmProvider) - let provider: Box = df_ai::build_provider( - &provider_config.provider_type, - &provider_config.base_url, - &provider_config.api_key, - &provider_config.default_model, - ); + // build_provider_for 含空 key 早失败:Err(如 keyring 读不到)→ 跳过 LLM,extract_title 兜底 + let provider: Box = match super::secret::build_provider_for(provider_config) { + Ok(p) => p, + Err(e) => { + tracing::warn!("标题生成跳过(provider 密钥不可用,走 extract_title 兜底): {}", e); + let title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()); + let _ = conv_repo.update_field(conv_id, "title", &title).await; + let _ = app_handle.emit("ai-conversation-changed", ()); + return; + } + }; let title = match generate_title_via_llm(&*provider, &provider_config.default_model, summary_msgs, &llm_concurrency).await { Some(t) => t, None => extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()), diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index f0dfe6d..220a3b9 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -1,9 +1,11 @@ //! AI 工具注册表构建 + 文件路径校验 +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; +use df_execute::shell::{execute, ShellRequest}; use df_storage::db::Database; use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord}; @@ -32,6 +34,26 @@ fn validate_path(path: &str) -> anyhow::Result<()> { Ok(()) } +/// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。 +/// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。 +/// 按 char 边界截(防切多字节 UTF-8 中间 panic)。 +fn truncate_output(s: &str, max: usize) -> (String, bool) { + if s.len() <= max { + return (s.to_string(), false); + } + let end = s.len(); + let mut start = end.saturating_sub(max); + // 推进到 char 边界,避免从多字节 UTF-8 中间切开 + while start < end && !s.is_char_boundary(start) { + start += 1; + } + let tail = &s[start..]; + ( + format!("[输出已截断,原始 {} 字节,仅保留末尾 {} 字节]\n{}", s.len(), tail.len(), tail), + true, + ) +} + /// workspace 根目录(项目根 = src-tauri 上两级,编译期固定) fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -88,17 +110,11 @@ async fn bind_dir_to_project( if !dir.is_dir() { anyhow::bail!("目录不存在: {path}"); } - // 防重复:normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path) + // 防重复(DRY R-PD-11):委托 ProjectRepo::find_path_conflict,与 project.rs::find_binding_conflict + // 共用同一实现。normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)。 let target = df_project::scan::normalize_path(path); - let projects = repo.list_active().await?; - for proj in &projects { - if proj.id != id { - if let Some(pp) = &proj.path { - if df_project::scan::normalize_path(pp) == target { - anyhow::bail!("目录已被项目「{}」绑定", proj.name); - } - } - } + if let Some(conflict) = repo.find_path_conflict(&target, Some(id)).await? { + anyhow::bail!("目录已被项目「{}」绑定", conflict.name); } // stack:AI/调用方提供则用,否则探测(detect_stack 内含多次同步 fs IO,必须 spawn_blocking 防阻塞 tokio runtime) let stack: Vec = if let Some(s) = stack_opt { @@ -388,6 +404,55 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { Ok(serde_json::json!({ "note": "请通过工作流页面运行工作流", "tool": "run_workflow" })) })), ); + registry.register( + "run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。", + df_ai::ai_tools::object_schema(vec![ + ("command", "string", true), + ("working_dir", "string", false), + ("timeout_secs", "integer", false), + ]), + RiskLevel::High, + Box::new(|args: serde_json::Value| Box::pin(async move { + let command = args["command"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?; + // working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。 + // 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验—— + // run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir), + // 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。 + let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { + Some(d) => { + validate_path(d)?; + d.to_string() + } + None => workspace_root().to_string_lossy().to_string(), + }; + // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可覆盖 + let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60); + + let request = ShellRequest { + command: command.to_string(), + working_dir: Some(working_dir.clone()), + env: HashMap::new(), + timeout_secs: Some(timeout_secs), + }; + let result = execute(request).await?; + + // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾) + const MAX_OUT: usize = 10_000; + let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT); + let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT); + + Ok(serde_json::json!({ + "command": command, + "working_dir": working_dir, + "exit_code": result.exit_code, + "duration_ms": result.duration_ms, + "stdout": stdout, + "stderr": stderr, + "truncated": stdout_trunc || stderr_trunc, + })) + })), + ); // ── 文件系统 ── registry.register( @@ -409,13 +474,23 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { if metadata.len() > 1_048_576 { anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len()); } + // 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话) let mut content = String::new(); - file.read_to_string(&mut content).await - .map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?; + if let Err(e) = file.read_to_string(&mut content).await { + if e.kind() == std::io::ErrorKind::InvalidData { + return Ok(serde_json::json!({ + "path": path, "content": null, "binary": true, + "size": metadata.len(), + "error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取" + })); + } + anyhow::bail!("读取文件失败: {}", e); + } + // limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行) let result = if let Some(offset) = args["offset"].as_u64() { let lines: Vec<&str> = content.lines().collect(); let skip = offset as usize; - let limit = args["limit"].as_u64().unwrap_or(200) as usize; + let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize; lines.into_iter().skip(skip).take(limit).collect::>().join("\n") } else { content.clone() @@ -426,7 +501,7 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { ); registry.register( "list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)", - df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false)]), + df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]), RiskLevel::Low, Box::new(|args: serde_json::Value| Box::pin(async move { let resolved = resolve_workspace_path( @@ -435,8 +510,9 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; let recursive = args["recursive"].as_bool().unwrap_or(false); let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); + let max_depth = args["max_depth"].as_u64().unwrap_or(3) as usize; let mut entries = Vec::new(); - let truncated = list_dir_recursive(path, recursive, 0, 2, 1000, skip_noise, &mut entries).await?; + let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?; Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated })) })), ); @@ -454,13 +530,51 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { if content.len() > 1_048_576 { anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len()); } - if let Some(parent) = std::path::Path::new(path).parent() { + let target = std::path::Path::new(path); + // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失) + // 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节 + let old_size: Option = match tokio::fs::metadata(target).await { + Ok(m) if m.len() > 0 => { + let bak = format!("{}.bak", path); + tokio::fs::copy(path, &bak).await + .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?; + Some(m.len()) + } + Ok(_) => Some(0), // 空文件(无需备份) + Err(_) => None, // 不存在(新建) + }; + if let Some(parent) = target.parent() { + // FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all) + if !parent.starts_with(&workspace_root()) { + anyhow::bail!("禁止在项目目录之外创建目录"); + } tokio::fs::create_dir_all(parent).await .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; } - tokio::fs::write(path, content).await - .map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?; - Ok(serde_json::json!({ "path": path, "bytes_written": content.len() })) + // FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷) + let tmp = format!("{}.tmp-write", path); + if let Err(e) = tokio::fs::write(&tmp, content).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("写入临时文件失败: {}", e)); + } + if let Err(e) = tokio::fs::rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(anyhow::anyhow!("原子替换失败: {}", e)); + } + // R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要); + // rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。 + // 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件) + if old_size.map(|s| s > 0).unwrap_or(false) { + let bak = format!("{}.bak", path); + let _ = tokio::fs::remove_file(&bak).await; + } + // FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak + if let Some(old) = old_size { + if old > 0 && (content.len() as f64 / old as f64) < 0.1 { + tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, content.len()); + } + } + Ok(serde_json::json!({ "path": path, "bytes_written": content.len(), "old_size": old_size })) })), ); @@ -469,7 +583,7 @@ pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { /// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条) /// -/// - 噪音目录(`.git`/`node_modules`/`target` 等)会被列出(显示存在),但不深入其内部 +/// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部 /// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断 fn list_dir_recursive<'a>( path: &'a str, @@ -488,15 +602,27 @@ fn list_dir_recursive<'a>( return Ok(true); // 达上限截断 } let name = entry.file_name().to_string_lossy().to_string(); - let metadata = entry.metadata().await?; - let is_dir = metadata.is_dir(); + // 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文) + if skip_noise && is_noise_dir(&name) { + continue; + } + // 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物, + // 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03)。 + if skip_noise && is_noise_file(&name) { + continue; + } + // FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标, + // 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录 + let file_type = entry.file_type().await?; + let is_symlink = file_type.is_symlink(); + let is_dir = file_type.is_dir(); // symlink 算 symlink 非 directory,不会被递归 result.push(serde_json::json!({ "name": name, - "type": if is_dir { "directory" } else { "file" }, - "size": metadata.len(), + "type": if is_symlink { "symlink" } else if is_dir { "directory" } else { "file" }, + "size": if is_symlink { 0 } else { entry.metadata().await.map(|m| m.len()).unwrap_or(0) }, "depth": depth, })); - // 仅递归非噪音目录(skip_noise=true 时跳过 .git/node_modules/target 等) + // 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外) if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) { let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned(); let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?; @@ -517,3 +643,178 @@ fn is_noise_dir(name: &str) -> bool { ]; NOISE_DIRS.contains(&name) } + +/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。 +/// +/// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理), +/// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。 +/// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。 +fn is_noise_file(name: &str) -> bool { + const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"]; + NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是) + #[test] + fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() { + // 噪音目录命中 + assert!(is_noise_dir(".git")); + assert!(is_noise_dir("node_modules")); + assert!(is_noise_dir("target")); + assert!(is_noise_dir("dist")); + assert!(is_noise_dir("build")); + // .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git) + assert!(!is_noise_dir(".gitignore")); + assert!(!is_noise_dir(".gitattributes")); + assert!(!is_noise_dir(".gitkeep")); + } + + /// is_noise_dir 纯函数:普通目录不命中 + #[test] + fn test_is_noise_dir_normal_dirs_not_matched() { + assert!(!is_noise_dir("src")); + assert!(!is_noise_dir("tests")); + assert!(!is_noise_dir("docs")); + assert!(!is_noise_dir("main.rs")); + assert!(!is_noise_dir("")); + } + + /// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target) + #[test] + fn test_is_noise_dir_case_sensitive() { + // 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配; + // 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化) + assert!(!is_noise_dir("Target")); + assert!(!is_noise_dir("DIST")); + assert!(!is_noise_dir("NODE_MODULES")); + } + + /// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中 + #[test] + fn test_is_noise_file_matches_temp_artifacts() { + // write_file 原子写副产物 + assert!(is_noise_file("PROGRESS.md.bak")); + assert!(is_noise_file("PROGRESS.md.tmp-write")); + // 编辑器临时文件 + assert!(is_noise_file(".main.rs.swp")); + assert!(is_noise_file("main.rs~")); + // 路径含但非后缀的应不命中(避免误杀) + assert!(!is_noise_file("backup.bak.md")); + assert!(!is_noise_file("main.rs")); + assert!(!is_noise_file(".gitignore")); + assert!(!is_noise_file("")); + } + + /// 造一个临时目录树用于 list_dir_recursive 测试 + fn build_noise_tree(root: &Path) { + // .git/HEAD (噪音目录,内部文件不应出现) + fs::create_dir_all(root.join(".git")).unwrap(); + fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main").unwrap(); + // node_modules/x/index.js (噪音目录,内部文件不应出现) + fs::create_dir_all(root.join("node_modules").join("x")).unwrap(); + fs::write(root.join("node_modules").join("x").join("index.js"), "module.exports=1;").unwrap(); + // target/debug/bin (噪音目录,内部文件不应出现) + fs::create_dir_all(root.join("target").join("debug")).unwrap(); + fs::write(root.join("target").join("debug").join("app"), "binary").unwrap(); + // 普通 src/main.rs (应出现) + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src").join("main.rs"), "fn main(){}").unwrap(); + // .gitignore 文件 (文件名不是噪音目录,应出现) + fs::write(root.join(".gitignore"), "/target\n").unwrap(); + } + + /// list_dir_recursive:skip_noise=true 时噪音目录不出现在 entries(且不深入其内部) + #[tokio::test] + async fn test_list_dir_recursive_filters_noise_dirs() { + let tmp = std::env::temp_dir().join(format!("df_tool_noise_{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + build_noise_tree(&tmp); + let root = tmp.to_string_lossy().to_string(); + + let mut entries = Vec::new(); + let truncated = list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap(); + assert!(!truncated); + + let names: Vec = entries.iter() + .filter_map(|e| e["name"].as_str().map(|s| s.to_string())) + .collect(); + + // 噪音目录本身及其内部文件均不应出现 + assert!(!names.contains(&".git".to_string()), ".git 不应作为 entry 返回"); + assert!(!names.contains(&"node_modules".to_string()), "node_modules 不应作为 entry 返回"); + assert!(!names.contains(&"target".to_string()), "target 不应作为 entry 返回"); + // 噪音目录内部文件也不应被递归带入 + assert!(!names.iter().any(|n| n == "HEAD"), ".git/HEAD 不应出现"); + assert!(!names.iter().any(|n| n == "index.js"), "node_modules/x/index.js 不应出现"); + assert!(!names.iter().any(|n| n == "app"), "target/debug/app 不应出现"); + + // 普通目录与 .gitignore 文件应正常返回 + assert!(names.contains(&"src".to_string()), "src 应作为 entry 返回"); + assert!(names.contains(&"main.rs".to_string()), "src/main.rs 应被递归返回"); + assert!(names.contains(&".gitignore".to_string()), ".gitignore 文件名不是噪音目录,应返回"); + + fs::remove_dir_all(&tmp).ok(); + } + + /// list_dir_recursive:skip_noise=false 时噪音目录正常返回 + #[tokio::test] + async fn test_list_dir_recursive_keeps_noise_when_disabled() { + let tmp = std::env::temp_dir().join(format!("df_tool_noisefalse_{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + build_noise_tree(&tmp); + let root = tmp.to_string_lossy().to_string(); + + let mut entries = Vec::new(); + list_dir_recursive(&root, false, 0, 1, 1000, false, &mut entries).await.unwrap(); + + let names: Vec = entries.iter() + .filter_map(|e| e["name"].as_str().map(|s| s.to_string())) + .collect(); + + // 关闭过滤后噪音目录应作为 entry 出现 + assert!(names.contains(&".git".to_string())); + assert!(names.contains(&"node_modules".to_string())); + assert!(names.contains(&"target".to_string())); + + fs::remove_dir_all(&tmp).ok(); + } + + /// list_dir_recursive:max_depth 控制递归深度 + #[tokio::test] + async fn test_list_dir_recursive_max_depth() { + // 造 a/b/c/main.rs (4 层: a 在 depth0, b depth1, c depth2, main.rs depth3) + let tmp = std::env::temp_dir().join(format!("df_tool_depth_{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(tmp.join("a").join("b").join("c")).unwrap(); + fs::write(tmp.join("a").join("b").join("c").join("main.rs"), "fn main(){}").unwrap(); + let root = tmp.to_string_lossy().to_string(); + + // max_depth=1: 只到 depth1 (列出 a, 深入 a 列出 b, 但不再深入 b 因为 depth1 不 < 1) + let mut entries = Vec::new(); + list_dir_recursive(&root, true, 0, 1, 1000, true, &mut entries).await.unwrap(); + let names: Vec = entries.iter() + .filter_map(|e| e["name"].as_str().map(|s| s.to_string())) + .collect(); + assert!(names.contains(&"a".to_string()), "depth1 应含 a"); + assert!(names.contains(&"b".to_string()), "max_depth=1 应深入 a 列出 b (depth1<1 为 false, 列 b 自身但不再递归)"); + assert!(!names.contains(&"c".to_string()), "max_depth=1 不应到 c (depth1 已达上限)"); + assert!(!names.contains(&"main.rs".to_string()), "max_depth=1 不应到 main.rs"); + + // max_depth=3: 应能到 depth3 的 main.rs + let mut entries = Vec::new(); + list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap(); + let names: Vec = entries.iter() + .filter_map(|e| e["name"].as_str().map(|s| s.to_string())) + .collect(); + assert!(names.contains(&"main.rs".to_string()), "max_depth=3 应能递归到 main.rs"); + + fs::remove_dir_all(&tmp).ok(); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f8ec192..a059deb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -12,10 +12,8 @@ pub mod task; pub mod workflow; /// 当前时间戳(毫秒字符串)— 与 df-storage 内部的时间格式保持一致 +/// +/// 转发 df_core::now_millis(DRY:时间获取统一入口在此 crate,避免 commands 层与 df-storage 各写一份 SystemTime 调用)。 pub(crate) fn now_millis() -> String { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .to_string() + df_core::now_millis().to_string() } diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index 115dc4c..fd4f1cf 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -5,7 +5,6 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use tauri::State; -use df_ai::build_provider; use df_ai::provider::{ChatMessage, CompletionRequest}; use df_core::types::new_id; use df_project::scan::{collect_sample, detect_stack, extract_description, normalize_path}; @@ -246,17 +245,20 @@ pub async fn purge_project(state: State<'_, AppState>, id: String) -> Result, ) -> Result, String> { let norm = normalize_path(path); - let projects = state.projects.list_active().await.map_err(|e| e.to_string())?; - 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_path(pp) == norm) - })) + state + .projects + .find_path_conflict(&norm, exclude_id) + .await + .map_err(|e| e.to_string()) } /// 探测目录技术栈(前端选目录后实时预览) @@ -375,7 +377,18 @@ pub async fn scan_project_with_ai( .ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string())?; // 3. 构造 provider + LLM 调用(非流式) - let provider = build_provider(&pc.provider_type, &pc.base_url, &pc.api_key, &pc.default_model); + // build_provider_for 含空 key 早失败:Err → 走纯规则降级(与下方 LLM 失败降级行为一致) + let provider = match crate::commands::ai::secret::build_provider_for(&pc) { + Ok(p) => p, + Err(e) => { + return Ok(AiScanResult { + description: String::new(), + stack: rule_stack, + project_type: None, + raw: Some(format!("LLM 调用失败: provider 密钥不可用: {e}")), + }); + } + }; let request = CompletionRequest { model: pc.default_model.clone(), messages: build_scan_prompt(&sample, &rule_stack), diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 7528b8e..49b4d42 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use tauri::State; -use df_core::types::new_id; +use df_core::types::{new_id, TaskStatus}; use df_storage::models::TaskRecord; use crate::state::AppState; @@ -40,6 +40,20 @@ pub async fn list_tasks( result.map_err(|e| e.to_string()) } +/// 按 id 查任务,找不到返回 Err(供前端详情页) +#[tauri::command] +pub async fn get_task_by_id( + state: State<'_, AppState>, + id: String, +) -> Result { + state + .tasks + .get_by_id(&id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("任务 {} 不存在", id)) +} + /// 创建任务,返回完整记录 #[tauri::command] pub async fn create_task( @@ -69,7 +83,7 @@ pub async fn create_task( Ok(record) } -/// 更新任务单个字段(字段名走 df-storage 白名单校验) +/// 更新任务单个字段(字段名走 df-storage 白名单校验;status 值走枚举校验) #[tauri::command] pub async fn update_task( state: State<'_, AppState>, @@ -77,6 +91,15 @@ pub async fn update_task( field: String, value: String, ) -> Result { + // 字段名注入由 df-storage 白名单兜底;这里补 status 值校验, + // 拦截拼写错误(in-progess / "in progress" / 大小写错等)静默落库。 + if field == "status" && !TaskStatus::is_valid(&value) { + return Err(format!( + "非法 status 值 {:?},合法值: {:?}", + value, + TaskStatus::valid_values() + )); + } state .tasks .update_field(&id, &field, &value) diff --git a/src-tauri/src/commands/workflow.rs b/src-tauri/src/commands/workflow.rs index e648492..86f362f 100644 --- a/src-tauri/src/commands/workflow.rs +++ b/src-tauri/src/commands/workflow.rs @@ -7,7 +7,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use tokio::sync::broadcast::error::RecvError; -use df_core::events::{WorkflowEvent, HumanApprovalResponse}; +use df_core::events::{WorkflowEvent, HumanApprovalResponse, SelectType}; use df_core::types::new_id; use df_storage::crud::WorkflowRepo; use df_storage::models::WorkflowRecord; @@ -87,9 +87,23 @@ pub async fn run_workflow( break; } } - // 消费过慢丢失部分事件时继续接收 + // R-PD-13: Lagged 表示消费过慢,broadcast 滑动窗口丢 n 条最旧事件。 + // broadcast 不暴露被丢事件的具体类型,无法在此精确判断是否为关键终态事件 + // (NodeCompleted/NodeFailed/HumanApprovalRequest/WorkflowCompleted/WorkflowFailed)。 + // 风险:若关键终态事件被丢,下面 emit 不到,finished 判定不触发,循环不会 break, + // 前端永久收不到工作流结束信号(依赖 DB 状态轮询兜底,但实时性差)。 + // 最小兜底:warn 记录丢失条数 + execution_id,便于事后从 tracing 追溯。 + // 后续优化方向(不在此 todo 范围): + // 1. 提升广播容量(目前常量值偏小,事件突发易满); + // 2. 关键终态事件经持久化队列/DB 重发,receiver 启动时回放未消费事件; + // 3. forward 循环加 watchdog 超时(长期无事件则主动查 DB 终态并退出)。 Err(RecvError::Lagged(n)) => { - tracing::warn!("工作流事件消费滞后,丢失 {} 条", n); + tracing::warn!( + execution_id = %forward_exec_id, + lagged = n, + "工作流事件消费滞后,丢失 {} 条事件(broadcast 不暴露被丢事件类型,关键终态事件可能永久丢失,依赖 DB 轮询兜底)", + n + ); } Err(RecvError::Closed) => break, } @@ -176,6 +190,11 @@ pub async fn get_workflow_execution( } /// 发送人工审批响应 +/// +/// F-260615-01: 支持 single/multiple。 +/// - single: decision 单值,decisions 为空(向后兼容); +/// - multiple: decisions 数组(≥1 项),decision 留空。 +/// IPC 早校验规则与 HumanNode 下游兜底一致,避免「IPC 成功+工作流失败」割裂。 #[tauri::command] pub async fn approve_human_approval( app: AppHandle, @@ -183,17 +202,55 @@ pub async fn approve_human_approval( execution_id: String, node_id: String, decision: String, + // F-260615-01: 多选结果数组,缺省空数组(单选调用方不传) + decisions: Option>, comment: Option, + // R-PD-5: options 由前端从收到的 HumanApprovalRequest 事件透传(IPC 无法访问节点 config)。 + options: Vec, + // F-260615-01: 选择类型,缺省 single + select_type: Option, ) -> Result<(), String> { - // decision 非空校验(FR-S3: 防 "" 透传;HumanNode options 非空时还校验 ∈ options) - if decision.trim().is_empty() { - return Err("审批决策不能为空".to_string()); + let _ = &app; // 原签名含 app 参数(未使用),保留避免 invoke_handler 注册签名变化 + let select_type = match select_type.as_deref() { + Some("multiple") => SelectType::Multiple, + _ => SelectType::Single, + }; + let decisions = decisions.unwrap_or_default(); + + // 归一化决策集合(与 HumanNode 下游一致) + let mut picked: Vec = decisions; + if picked.is_empty() && !decision.is_empty() { + picked.push(decision.clone()); } - // 发送审批响应到事件总线 + + // 数量校验 + match select_type { + SelectType::Single if picked.len() != 1 => { + return Err("单选审批只能提交一个决策".to_string()); + } + SelectType::Multiple if picked.is_empty() => { + return Err("多选审批至少提交一个决策".to_string()); + } + _ => {} + } + + // 每项非空 + ∈options(options 非空时) + for d in &picked { + if d.trim().is_empty() { + return Err("审批决策不能为空".to_string()); + } + if !options.is_empty() && !options.contains(d) { + return Err(format!("审批决策非法: {}", d)); + } + } + + // 发送审批响应到事件总线(decision 取首项保留,decisions 透传全量) + let primary = picked.first().cloned().unwrap_or_default(); let response = HumanApprovalResponse { execution_id: execution_id.clone(), node_id: node_id.clone(), - decision, + decision: primary, + decisions: picked, comment, }; @@ -201,6 +258,7 @@ pub async fn approve_human_approval( execution_id: response.execution_id, node_id: response.node_id, decision: response.decision, + decisions: response.decisions, comment: response.comment, }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fb5637e..df27fab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,12 @@ pub fn run() { // 初始化全局状态(打开数据库 + 执行迁移) let app_state = tauri::async_runtime::block_on(AppState::init(&db_path))?; + // FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断) + if let Err(e) = tauri::async_runtime::block_on( + commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers), + ) { + tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e); + } app.manage(app_state); Ok(()) }) @@ -47,6 +53,7 @@ pub fn run() { commands::task::create_task, commands::task::update_task, commands::task::delete_task, + commands::task::get_task_by_id, // 灵感 commands::idea::list_ideas, commands::idea::create_idea, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 36cefa6..0cc3839 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -221,12 +221,16 @@ impl AppState { /// 构建节点注册表 — 注册内置节点 /// /// 注意:不使用 `NodeRegistry::default()`,其 script 工厂为占位实现(会 panic), -/// 这里注册 df-nodes 提供的真实 ScriptNode 和 HumanNode。 +/// 这里注册 df-nodes 提供的真实 HumanNode / AiNode。 +/// +/// "script" 节点不注册:ScriptNode 走 cmd /C | sh -c 执行 config.command 原始串, +/// 前端可构造任意 DagDef 触达无审批 shell(R-PD-2)。DevFlow 工作流当前为纯演示 +/// 功能(前端唯一构造点 ProjectDetail.vue demoDag 三步 echo),无真实构建/部署脚本 +/// 需求。需要脚本执行能力时新建独立 BuildNode(白名单 + 项目目录锚定 + 复用 AI 工具 +/// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。 +/// 详见 docs/02-架构设计/工作流脚本执行边界-2026-06-15.md。 fn build_registry() -> NodeRegistry { let mut registry = NodeRegistry::new(); - registry.register("script", |_config| { - Box::new(df_nodes::script_node::ScriptNode) - }); registry.register("human", |_config| { Box::new(df_nodes::human_node::HumanNode) });