diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index 9938ef1..331df97 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -463,6 +463,55 @@ impl AiToolExecutionRepo { .await .map_err(storage_err)? } + + /// 按工具聚合执行统计(status 分布计数,AC-5 运行时失败率画像数据源)。 + /// + /// 单条 GROUP BY 取 `(tool_name, status, count)` 三元组;内存聚合与失败率口径 + /// (failed_rate = failed / (completed + failed))在命令层完成(record.rs + /// `tool_failure_stats`),本层只负责取数,不掺展示逻辑。 + /// `from`:可选时间下限(millis,`requested_at >= from`),None = 全量。 + /// + /// 与本表其他查询同理走专用 SELECT(通用 query 宏硬编码 ORDER BY created_at, + /// 本表无该列)。`requested_at` 存毫秒数字符串,`CAST AS INTEGER` 数值比较 + /// (对齐 `cleanup_stale_pending` 同口径)。参数化绑定防注入。 + pub async fn stats_by_tool(&self, from: Option) -> Result> { + let conn = self.conn.clone(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + // 条件分支仅差 WHERE + 参数,一条 GROUP BY 复用(match 分支互斥,stmt 借用安全) + let (sql, param): (&str, Option) = match from { + Some(f) => ( + "SELECT tool_name, status, COUNT(*) FROM ai_tool_executions \ + WHERE CAST(requested_at AS INTEGER) >= ?1 GROUP BY tool_name, status", + Some(f), + ), + None => ( + "SELECT tool_name, status, COUNT(*) FROM ai_tool_executions \ + GROUP BY tool_name, status", + None, + ), + }; + let row_map = |row: &rusqlite::Row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }; + let mut stmt = guard.prepare(sql).map_err(storage_err)?; + let mut rows = match param { + Some(f) => stmt.query_map(params![f], row_map).map_err(storage_err)?, + None => stmt.query_map([], row_map).map_err(storage_err)?, + }; + let mut out = Vec::new(); + for r in &mut rows { + out.push(r.map_err(storage_err)?); + } + Ok(out) + }) + .await + .map_err(storage_err)? + } } // AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。 diff --git a/docs/todo.md b/docs/todo.md index ece14b2..b085dbc 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -388,7 +388,7 @@ graph TD - [x] ✅ **P1-c** 列表状态持久化 — **2026-08-09 核验销账**:usePersistedRef 已接入 Tasks/Projects/Knowledge/Ideas/AuditLog;ProjectDetail Tab 手写 localStorage 按 projectId 隔离(动态 key,usePersistedRef 静态 key 不适用)保留 - [x] ✅ **P1-d** AuditLog 后端 WHERE 筛选 + 真实 total — **2026-08-09 核验销账**:list_tool_executions(record.rs:182)支持 status/risk_level/tool_keyword WHERE + count_by_query 独立 COUNT 真实 total + limit/offset 分页;前端 AuditLog.vue buildQuery 传后端 + usePersistedRef 持久化 - [x] ✅ **P1-e** 审批体系一致 — **全部落地(2026-08-09)**:挂起计时✅(ApprovalPopup :191 MM:SS)/ 授权粒度统一✅(ToolCard/ApprovalPopup/DirAuthDialog 同 i18n)/ deny 二次确认✅(DirAuthDialog 补)/ **失败 toast ✅ 本批落地**(useAiApproval catch emit('ai-toast') + AiChat listen;ApprovalPopup 窗口内自包含 useToast) -- [~] **P1-f** cmd 闪烁 N² — **部分(2026-08-09 核验)**:前端展开缓存已做(FileExplorer loadedChildren 去重);**后端每次展开新目录仍整仓 git status 扫描(module.rs:654)待架构级缓存,登记待设计** +- [x] ✅ **P1-f** cmd 闪烁 N² — **已落地(2026-08-09 141777b)**:module.rs 新增仓库级 git status 缓存(GIT_STATUS_CACHE,key=仓库根 path,TTL 5s,锁内不跑 git,非 git 不缓存;get_module_file_tree + get_module_git_status 两处收口;删除/更新 module 清缓存);前端联动未做(5s TTL 自然过期 + FileExplorer 刷新已重拉目录树,无独立缺口) - [x] ✅ **P1-g** store.tasks.filter 污染 — **2026-08-09 核验+补全销账**:ProjectDetail 独立 taskApi.list 已修;ChatInput @项目 enrichment 独立拉取(projectTasksCache)已修 + @任务联想本批改独立全量缓存 taskSuggestionCache(隔离 Tasks 页筛选子集)+ mentionTotal 对齐 **P2(打磨)**:幽灵 `--df-primary` / git 状态色 token / 控件统一(原问题 8 延伸)/ 死代码清理(PlanProgress/isLastUser) @@ -406,8 +406,8 @@ graph TD - [x] **AC-1** ✅ 已落地(2026-08-09 核验):双层拦截——调用级 `cache.rs:251 find_cached_readonly_result`(同 tool_name+canonical_args 已 completed 即返缓存+警告头,第 2 次即命中)+ 轮级 `is_repetitive_exploration`(helpers.rs:304-332,单签名≥3/唯一率<40%)→ `check_stall_breaker`(警示→硬熔断 emit AiHelpRequired) - [x] **AC-2** ✅ 已落地(2026-08-09 核验,提交 fe780c0):`MAX_TOOL_CALLS_PER_ROUND=8`(agentic/mod.rs:245)+ 目录列举去重(readonly_cache_args_key 只取 path,返目录专用警告)+ `detect_listing_bypass`(run_command 含列举命令且引用已列目录→警告,保守式不硬拒) - [x] **AC-3** ✅ 已落地(2026-08-09 核验):prompt.rs:304/323 项目/任务清单已含 id + 注明语(projects_listed_note "引用用 id 非名称");**兜底机制超要求**:entity_resolve.rs name→id 自动解析(RESOLVE_MAP 12 工具 + UUID 跳过 + 0 命中/重名返可行动错误 + 9 单测,audit/mod.rs process_tool_calls 单点漏斗) -- [~] **AC-4** 🟡 部分(2026-08-09 核验):read_file **符号级命中**提示已补强(tools/file.rs:179-196 用 code_intel contains_definition 检测内容含定义节点才提示,替代旧按扩展名)+ 三态本体(code_intel.rs:130)+ 缓存兜底 + 单测 4;**缺口**:无按模型分级、无采用率回测(待评估) -- [~] **AC-5** 🟡 部分(2026-08-09 核验):画像复盘已做(docs/AI工具失败画像复盘-2026-08-08.md)+ 4/5 自愈机制落地(advance_task legal_targets / patch_file 相近锚点 + **本批补 read_file/read_symbol NotFound 相近文件名候选 + search_files total=0 引导**);**缺口**:无运行时按工具失败率统计机制(待评估) +- [~] **AC-4** 🟡 部分(2026-08-09 核验):read_file **符号级命中**提示已补强(tools/file.rs:179-196 用 code_intel contains_definition 检测内容含定义节点才提示,替代旧按扩展名)+ 三态本体(code_intel.rs:130)+ 缓存兜底 + 单测 4;**模型分级已评估=保持现状**(穿模型需改 process_tool_calls 多路径签名波及 ai_approve/trust_hits/low_risk,ROI 低——提示一行且 token 节省可忽略,启发式判弱模型脆弱) +- [~] **AC-5** 🟡 部分(2026-08-09 核验):画像复盘已做(docs/AI工具失败画像复盘-2026-08-08.md)+ 4/5 自愈机制落地(advance_task legal_targets / patch_file 相近锚点 / read_file·read_symbol 相近文件名候选 / search_files total=0 引导);**运行时失败率统计 ✅ 已落地(141777b)**:tool_failure_stats IPC 命令(stats_by_tool 按工具 status 聚合,failed_rate=failed/(completed+failed),rejected 不计分母,可选 from 时间下限) ### 💡 2026-08-04 MCP 多进程架构潜在问题(分析登记) diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 80764f1..b1971b1 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -43,7 +43,10 @@ pub mod record; #[allow(unused_imports)] pub(crate) use record::{audit_tool_call, query_audit_history, record_audit}; #[allow(unused_imports)] -pub use record::{list_tool_executions, ToolExecutionDto, ToolExecutionPage, ToolExecQuery}; +pub use record::{ + list_tool_executions, tool_failure_stats, ToolExecutionDto, ToolExecutionPage, ToolExecQuery, + ToolFailureStat, ToolFailureStats, ToolFailureStatsQuery, +}; // reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason) // 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。 diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs index 0f11d26..1db35c5 100644 --- a/src-tauri/src/commands/ai/audit/record.rs +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -10,6 +10,7 @@ //! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。 use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use tauri::State; use df_ai::ai_tools::RiskLevel; @@ -223,3 +224,206 @@ pub async fn list_tool_executions( has_more, }) } + +// ============================================================ +// AC-5 按工具失败率统计(诊断 IPC,替代 ad-hoc 查库) +// ============================================================ + +/// 按工具失败率统计查询入参(前端透传,空值=全量)。 +/// +/// `from`:可选时间下限(millis,`requested_at >= from`),None = 全量。 +/// AC-5 诊断期 ad-hoc 查库无运行时统计机制,本命令提供聚合统计供审计面板/诊断查询。 +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ToolFailureStatsQuery { + pub from: Option, +} + +/// 单工具执行统计(AC-5 失败率画像)。 +/// +/// `failed_rate` = failed / (completed + failed)——仅"真正执行过"的算成功率分母; +/// rejected/skipped_retry 是用户/AI 决策非执行失败,不计分母,但单独计数展示。 +/// completed+failed=0(从未真正执行,如纯决策挂起)时 failed_rate=0。 +#[derive(Debug, Clone, Serialize)] +pub struct ToolFailureStat { + pub tool_name: String, + pub total: i64, + pub completed: i64, + pub failed: i64, + pub rejected: i64, + pub interrupted: i64, + pub skipped_retry: i64, + pub failed_rate: f64, +} + +/// 失败率统计聚合结果(跨工具汇总)。 +#[derive(Debug, Clone, Serialize)] +pub struct ToolFailureStats { + /// 按 total 降序(使用最多的工具在前,面板聚焦高流量工具) + pub stats: Vec, + pub total_executions: i64, + pub total_failed: i64, +} + +/// 把 `stats_by_tool` 的 (tool_name, status, count) 三元组聚合为 DTO(纯函数,命令 + 单测共用)。 +/// +/// 内存聚合(单条 GROUP BY 已按 tool,status 分组):HashMap 二次归并 → 每工具 status 分布, +/// 计算 failed_rate(分母 completed+failed,四舍五入到 4 位小数防浮点长尾)。排序按 total 降序、 +/// tool_name 升序破平。`total_executions` = 全工具 total 之和,`total_failed` = failed 之和。 +fn aggregate_tool_stats(rows: Vec<(String, String, i64)>) -> ToolFailureStats { + let mut by_tool: HashMap> = HashMap::new(); + for (tool, status, cnt) in rows { + *by_tool.entry(tool).or_default().entry(status).or_insert(0) += cnt; + } + let mut total_executions = 0i64; + let mut total_failed = 0i64; + let mut stats: Vec = by_tool + .into_iter() + .map(|(tool_name, m)| { + let completed = m.get("completed").copied().unwrap_or(0); + let failed = m.get("failed").copied().unwrap_or(0); + let rejected = m.get("rejected").copied().unwrap_or(0); + let interrupted = m.get("interrupted").copied().unwrap_or(0); + let skipped_retry = m.get("skipped_retry").copied().unwrap_or(0); + let total: i64 = m.values().sum(); + total_executions += total; + total_failed += failed; + let failed_rate = if completed + failed > 0 { + ((failed as f64 / (completed + failed) as f64) * 10_000.0).round() / 10_000.0 + } else { + 0.0 + }; + ToolFailureStat { + tool_name, + total, + completed, + failed, + rejected, + interrupted, + skipped_retry, + failed_rate, + } + }) + .collect(); + stats.sort_by(|a, b| b.total.cmp(&a.total).then_with(|| a.tool_name.cmp(&b.tool_name))); + ToolFailureStats { stats, total_executions, total_failed } +} + +/// AC-5 运行时失败率统计:按工具聚合 ai_tool_executions 的 status 分布与失败率。 +/// +/// 只读诊断 IPC:`query.from` 可选时间下限(millis),默认全量。数据源 ai_tool_executions 表 +/// (GUI audit 模块写,已完成记录落盘)。口径:failed_rate = failed / (completed + failed); +/// rejected/skipped_retry/interrupted 单独计数展示(不计失败率分母,属用户/AI 决策非执行失败)。 +#[tauri::command] +pub async fn tool_failure_stats( + state: State<'_, AppState>, + query: Option, +) -> Result { + let from = query.and_then(|q| q.from); + let rows = state + .ai_tool_executions + .stats_by_tool(from) + .await + .map_err(err_str)?; + Ok(aggregate_tool_stats(rows)) +} + +#[cfg(test)] +mod tests { + use super::*; + use df_storage::db::Database; + + /// 插入一条指定 tool/status/requested_at 的审计记录(测试构造数据用)。 + /// requested_at 传毫秒(与生产 `audit_tool_call` 落 now_millis() 同口径)。 + async fn insert_record(repo: &AiToolExecutionRepo, tool: &str, status: &str, t: i64) { + repo.insert(AiToolExecutionRecord { + id: new_id(), + conversation_id: None, + message_id: None, + tool_call_id: new_id(), + tool_name: tool.to_string(), + arguments: "{}".to_string(), + result: None, + status: status.to_string(), + risk_level: "low".to_string(), + requested_at: t.to_string(), + executed_at: None, + decided_by: None, + }) + .await + .expect("测试数据插入应成功"); + } + + /// failed_rate 分母口径:rejected/skipped_retry/interrupted 不计分母,单独计数。 + /// + /// read_file: 8 completed + 2 failed + 3 rejected → rate=2/10=0.2,rejected=3 + /// patch_file: 1 completed + 4 failed → rate=4/5=0.8 + /// run_command: 2 skipped_retry + 1 rejected + 1 interrupted(无 completed/failed)→ rate=0 + #[tokio::test] + async fn tool_failure_stats_denominator_and_counts() { + let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); + let repo = AiToolExecutionRepo::new(&db); + let t = 2_000_000_000_000i64; + for _ in 0..8 { insert_record(&repo, "read_file", "completed", t).await; } + for _ in 0..2 { insert_record(&repo, "read_file", "failed", t).await; } + for _ in 0..3 { insert_record(&repo, "read_file", "rejected", t).await; } + insert_record(&repo, "patch_file", "completed", t).await; + for _ in 0..4 { insert_record(&repo, "patch_file", "failed", t).await; } + for _ in 0..2 { insert_record(&repo, "run_command", "skipped_retry", t).await; } + insert_record(&repo, "run_command", "rejected", t).await; + insert_record(&repo, "run_command", "interrupted", t).await; + + let out = aggregate_tool_stats(repo.stats_by_tool(None).await.unwrap()); + assert_eq!(out.total_executions, 8 + 2 + 3 + 1 + 4 + 2 + 1 + 1); + assert_eq!(out.total_failed, 6); + + let rf = out.stats.iter().find(|s| s.tool_name == "read_file").unwrap(); + assert_eq!(rf.total, 13); + assert_eq!(rf.completed, 8); + assert_eq!(rf.failed, 2); + assert_eq!(rf.rejected, 3); + assert_eq!(rf.failed_rate, 0.2, "rejected 不计分母,failed/(completed+failed)=2/10"); + + let pf = out.stats.iter().find(|s| s.tool_name == "patch_file").unwrap(); + assert_eq!(pf.failed_rate, 0.8, "4/(1+4)=0.8"); + + let rc = out.stats.iter().find(|s| s.tool_name == "run_command").unwrap(); + assert_eq!(rc.completed + rc.failed, 0, "无真正执行记录"); + assert_eq!(rc.failed_rate, 0.0, "分母为 0 应归零"); + assert_eq!(rc.skipped_retry, 2); + assert_eq!(rc.interrupted, 1); + + // 排序:total 降序 → read_file(13) 应在 patch_file(5) 之前 + assert_eq!(out.stats[0].tool_name, "read_file"); + } + + /// from 时间过滤:仅统计 requested_at >= from 的记录。 + /// + /// patch_file 追加一条更早(early)的 completed → from=late 时其不计入, + /// completed 由 2 降为 1,failed_rate 由 4/6≈0.6667 变为 4/5=0.8。 + #[tokio::test] + async fn tool_failure_stats_from_time_filter() { + let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); + let repo = AiToolExecutionRepo::new(&db); + let t_late = 2_000_000_000_000i64; + let t_early = 1_000_000_000_000i64; + + for _ in 0..8 { insert_record(&repo, "read_file", "completed", t_late).await; } + for _ in 0..2 { insert_record(&repo, "read_file", "failed", t_late).await; } + insert_record(&repo, "patch_file", "completed", t_late).await; + for _ in 0..4 { insert_record(&repo, "patch_file", "failed", t_late).await; } + insert_record(&repo, "patch_file", "completed", t_early).await; + + // 全量:patch_file completed=2(early+late) + let all = aggregate_tool_stats(repo.stats_by_tool(None).await.unwrap()); + let pf_all = all.stats.iter().find(|s| s.tool_name == "patch_file").unwrap(); + assert_eq!(pf_all.completed, 2); + assert_eq!(pf_all.failed_rate, 0.6667, "4/(2+4)≈0.6667"); + + // from=t_late:early 不计,patch_file completed=1 + let late = aggregate_tool_stats(repo.stats_by_tool(Some(t_late)).await.unwrap()); + let pf_late = late.stats.iter().find(|s| s.tool_name == "patch_file").unwrap(); + assert_eq!(pf_late.completed, 1, "early 记录应被 from 过滤"); + assert_eq!(pf_late.failed_rate, 0.8, "4/(1+4)=0.8"); + assert_eq!(late.total_executions, 8 + 2 + 1 + 4, "不含 early 记录"); + } +} diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index 7c546a0..0c005c9 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -15,6 +15,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use tauri::State; @@ -209,6 +211,10 @@ pub async fn update_project_module( if path.is_empty() { return Err("path 不能为空".to_string()); } + // path 变更 → 清旧路径的 git status 缓存(避免后续同路径复用脏状态) + if existing.path != path { + invalidate_git_status_cache(&existing.path); + } existing.path = path; } existing.git_url = trim_opt(input.git_url); @@ -245,6 +251,10 @@ pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Re if id.is_empty() { return Err("id 不能为空".to_string()); } + // 删除前取 path,删除后清 git status 缓存(避免同路径复用脏状态) + if let Ok(Some(module)) = state.project_modules.get_by_id(&id).await { + invalidate_git_status_cache(&module.path); + } state.project_modules.delete(&id).await.map_err(err_str)?; Ok(()) } @@ -301,6 +311,61 @@ pub async fn list_project_modules( Ok(modules) } +// ============================================================ +// 仓库级 git status 缓存(P1-f:cmd 闪烁 N² 修复) +// ============================================================ + +/// git status 缓存 TTL(5 秒)。 +/// +/// 背景:`get_module_file_tree` 展开新目录 + `get_module_git_status` 切换变更视图都会在仓库根 +/// 跑整仓 `git status --porcelain`;前端 `loadedChildren` 只对「同目录展开」去重,跨目录展开 +/// N 次 = N 次整仓扫描(cmd 闪烁 N²)。git status 天然幂等(输出反映当前工作区),TTL 秒级失效 +/// 可接受(用户改文件后刷新触发重扫兜底),不做实时 watcher(过度设计)。 +const GIT_STATUS_CACHE_TTL: Duration = Duration::from_secs(5); + +/// 仓库根路径 → (采集时间戳, git status --porcelain 输出 map)。 +/// 键 = module.path(仓库根);值 = (Instant, {posix_rel: status})。 +/// 进程级共享(spawn_blocking 内 std Mutex 访问),跨 IPC 请求复用。 +static GIT_STATUS_CACHE: LazyLock)>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// 判断缓存条目是否新鲜(TTL 内)。抽出纯函数便于单测。 +fn is_git_status_cache_fresh(collected_at: Instant, now: Instant) -> bool { + now.duration_since(collected_at) < GIT_STATUS_CACHE_TTL +} + +/// 取仓库 git status map:TTL 内命中直接复用缓存,未命中/过期才跑 git status 并更新缓存。 +/// 非 git 仓库 → 空 map 且不入缓存(.git 判断廉价,且避免「git init 后 TTL 内误返空」)。 +fn git_status_map_cached(dir: &str) -> HashMap { + // 非 git 仓库快速返回(不缓存:git init 后 TTL 内重扫才拿得到真实状态) + if !Path::new(dir).join(".git").exists() { + return HashMap::new(); + } + let now = Instant::now(); + // 命中 TTL 内缓存 → 直接复用(锁内只查表,不跑 git,快速返回) + { + let cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((ts, map)) = cache.get(dir) { + if is_git_status_cache_fresh(*ts, now) { + return map.clone(); + } + } + } + // 未命中/过期 → 跑 git(锁外执行,避免持锁阻塞其他目录的缓存命中;完成后更新缓存) + let map = collect_git_status_map(dir); + let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + cache.insert(dir.to_string(), (now, map.clone())); + map +} + +/// 清空指定仓库根的 git status 缓存条目(工程删除 / path 变更时防脏数据复用)。 +fn invalidate_git_status_cache(dir: &str) { + GIT_STATUS_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(dir); +} + // ============================================================ // IPC 命令 — Git 状态查询(实时派生,不进表) // ============================================================ @@ -448,8 +513,6 @@ pub async fn list_branches( /// /// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。 fn run_git_status(dir: &str) -> GitStatus { - use std::time::Duration; - let path = std::path::Path::new(dir); if !path.join(".git").exists() { return empty_status(); @@ -463,24 +526,14 @@ fn run_git_status(dir: &str) -> GitStatus { .filter(|s| !s.is_empty() && s != "HEAD") .unwrap_or_default(); - // 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码) - let mut changed_files = Vec::new(); - if let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) { - for line in out.lines() { - if line.len() < 4 { - continue; - } - // porcelain 格式:"XY path"(X/Y 各一字符 + 一空格 + path) - let status = line[..2].to_string(); - let file_path = line[3..].trim().to_string(); - if !file_path.is_empty() { - changed_files.push(GitChangedFile { - status, - path: file_path, - }); - } - } - } + // 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)。 + // 走仓库级缓存(与文件树共用同一份扫描,TTL 内展开/切换不再整仓重扫)。 + let mut changed_files: Vec = git_status_map_cached(dir) + .into_iter() + .map(|(path, status)| GitChangedFile { status, path }) + .collect(); + // map 无序,按路径排序保证前端展示顺序稳定(git status 输出本身即按路径有序)。 + changed_files.sort_by(|a, b| a.path.cmp(&b.path)); // 3) 最近提交:`git log -50 --format="%h %ct %an %s"`(hash + 时间戳 + 作者 + subject) let mut recent_commits = Vec::new(); @@ -652,7 +705,7 @@ pub async fn get_module_file_tree( // 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。 // 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。 let dir_for_git = module.path.clone(); - let git_map = tokio::task::spawn_blocking(move || collect_git_status_map(&dir_for_git)) + let git_map = tokio::task::spawn_blocking(move || git_status_map_cached(&dir_for_git)) .await .map_err(|e| format!("git status 采集任务失败: {e}"))?; // 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。 @@ -1362,3 +1415,42 @@ pub async fn detect_module_cycles( } Ok(cycle_nodes.into_iter().collect()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + /// P1-f:git status 缓存 TTL 判定 — 5s 内新鲜,超时失效。 + #[test] + fn test_is_git_status_cache_fresh() { + let now = Instant::now(); + // TTL 内 → 新鲜 + assert!(is_git_status_cache_fresh(now - Duration::from_millis(4000), now)); + // 恰好 TTL → 不新鲜(duration_since < TTL 严格小于) + assert!(!is_git_status_cache_fresh(now - Duration::from_secs(5), now)); + // 超过 TTL → 失效 + assert!(!is_git_status_cache_fresh(now - Duration::from_secs(6), now)); + } + + /// P1-f:git status 缓存同一仓库路径复用同一 map 条目、不同路径独立(纯逻辑,不经 git)。 + #[test] + fn test_git_status_cache_shared_per_repo() { + let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let now = Instant::now(); + let mut map_a = HashMap::new(); + map_a.insert("src/main.rs".to_string(), " M".to_string()); + let mut map_b = HashMap::new(); + map_b.insert("README.md".to_string(), "??".to_string()); + cache.insert("repoA".to_string(), (now, map_a.clone())); + cache.insert("repoB".to_string(), (now, map_b.clone())); + // 同路径命中同一份缓存,不同路径互不串扰 + let (_, cached_a) = cache.get("repoA").unwrap(); + assert_eq!(cached_a, &map_a); + assert_ne!(cached_a, &map_b); + // 清理后不再命中 + cache.remove("repoA"); + assert!(cache.get("repoA").is_none()); + } +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3865d32..e981794 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -465,6 +465,8 @@ pub fn run() { commands::ai::ai_authorize_dir, // 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断) commands::ai::audit::record::list_tool_executions, + // AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围) + commands::ai::audit::record::tool_failure_stats, // 知识库 commands::knowledge::knowledge_list, commands::knowledge::knowledge_get,