//! D2a 工具结果过期外置:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。 //! //! 设计(对齐 u-claw 成熟实践「大内容外置+摘要留指针」): //! - 超阈值(8KB)的审计 result 正文落盘至 {app_data}/tool-results/{yyyy-MM}/{id}.txt, //! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。 //! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧 //! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。 //! - 归档清理已彻底移除(2026-10-11):原 90 天归档设计(2026-10-06 提出,2026-10-09 停用) //! 现整体删除,外置文件只增不减、永不清理。read_offloaded 读回入口已接线 //! (IPC ai_read_offloaded_result),句柄永远可读回。 //! - 历史数据默认保留禁清理(全局规则),与本次决策一致,外置派生文件同样保留。 use std::path::PathBuf; use std::sync::OnceLock; /// 外置根目录(进程级一次注入;lib.rs setup 调 init_base)。未注入时 offload_result 直通。 static BASE: OnceLock = OnceLock::new(); /// 注入外置根目录(app data dir)。幂等,首次生效。 pub fn init_base(base: PathBuf) { let _ = BASE.set(base); } /// 无参包装:build_audit_record 等审计写路径统一走此入口。 /// base 未注入或失败降级时原样返回(不外置不丢数据)。 pub fn offload_result(id: &str, tool_name: &str, result: &str) -> String { match BASE.get() { Some(b) => offload_if_large(b, id, tool_name, result), None => result.to_string(), } } /// 外置阈值:审计 result 超 8KB 落盘(DB 只留句柄)。namespace 侧阈值 2048B 更激进 /// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。 pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024; /// 句柄 JSON 字段名(读取降级判据)。 const HANDLE_MARK: &str = "__offloaded__"; /// 计算外置目录({base}/tool-results/{yyyy-MM}/)。base 由调用方传入(app data dir), /// 本模块不做 Tauri 依赖(纯函数可测)。 fn month_dir(base: &PathBuf, id: &str) -> PathBuf { // id 前缀含时间成分则用之,否则统一放 unsorted/;目录按月仅为人读友好 let month = id.get(0..7).filter(|s| s.len() == 7 && s.as_bytes()[4] == b'-').unwrap_or("unsorted"); base.join("tool-results").join(month) } /// 落盘并生成句柄。失败(磁盘/权限)时返回原内容(降级:不外置也不丢数据)。 pub fn offload_if_large(base: &PathBuf, id: &str, tool_name: &str, result: &str) -> String { if result.len() <= OFFLOAD_THRESHOLD_BYTES { return result.to_string(); } let dir = month_dir(base, id); if std::fs::create_dir_all(&dir).is_err() { return result.to_string(); } let file = dir.join(format!("{id}.txt")); if std::fs::write(&file, result).is_err() { return result.to_string(); } let head: String = result.chars().take(200).collect(); // meta 旁路文件:标记本模块自产物(带 offloaded 标记),供溯源识别 let meta = serde_json::json!({ HANDLE_MARK: true, "id": id, "tool": tool_name }); let _ = std::fs::write(file.with_extension("meta.json"), meta.to_string()); let handle = serde_json::json!({ HANDLE_MARK: true, "path": file.to_string_lossy(), "size_bytes": result.len(), "tool": tool_name, "head": head, "hint": "结果过大已外置落盘,需要细节时按 path 读取原文" }); handle.to_string() } /// 读外置文件(前端/查询侧展开句柄用)。文件缺失时返回带语义的错误(降级不 panic)。 /// /// 注:90 天归档清理已停用(2026-10-09 用户决策,外置文件全部保留),故此路径 /// 正常应恒可读;报错仅当文件被外部误删或路径失效。 pub fn read_offloaded(path: &str) -> Result { std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(文件可能已被外部删除): {e}")) } #[cfg(test)] mod tests { use super::*; #[test] fn small_result_not_offloaded() { let base = std::env::temp_dir().join(format!("d2a-test-{}", std::process::id())); let out = offload_if_large(&base, "2026-10-07-x", "read_file", "short"); assert_eq!(out, "short"); let _ = std::fs::remove_dir_all(base.join("tool-results")); } #[test] fn large_result_offloaded_with_handle() { let base = std::env::temp_dir().join(format!("d2a-test-{}", std::process::id())); let big = "x".repeat(10 * 1024); let out = offload_if_large(&base, "2026-10-07-y", "run_command", &big); let v: serde_json::Value = serde_json::from_str(&out).unwrap(); assert_eq!(v.get(HANDLE_MARK).and_then(|b| b.as_bool()), Some(true)); let path = v.get("path").and_then(|p| p.as_str()).unwrap(); // 原文可找回 assert_eq!(read_offloaded(path).unwrap(), big); let _ = std::fs::remove_dir_all(base.join("tool-results")); } #[test] fn month_dir_parses_id_prefix() { let base = PathBuf::from("/tmp"); assert!(month_dir(&base, "2026-10-07-abcd").ends_with("tool-results/2026-10")); assert!(month_dir(&base, "weird").ends_with("tool-results/unsorted")); } }