//! C2 GUI 侧接点:轮询 df-mcp 落库的变更标记,把外部写库转成 `df-data-changed`。 //! //! 背景:stdio 形态的 MCP 是独立进程,无 AppHandle,写库后 GUI 无从经事件感知, //! 造成双端数据断层(P0-2)。df-mcp 每次成功写工具后向 [`MCP_DATA_CHANGED_KEY`] //! 覆盖写入 `{"at": <毫秒>, "tool": "<工具名>"}`(跨进程落库信令)。 //! //! 本模块周期读该 KV,`at` 推进即视为外部改动过库,再按 `tool` 复用 //! [`emit_data_changed`] 的 entity 映射补发 `df-data-changed` 刷新前端列表。 //! 桌面内嵌 HTTP 形态走 on_tool_call 直接 emit,不经此路(标记仅 stdio 写)。 //! //! 接线:lib.rs setup 调 [`start`] 挂载;接线落地前本模块无外部调用方, //! 先 allow(dead_code) 保留入口(零调用方≠垃圾)。 #![allow(dead_code)] use std::time::Duration; use tauri::{AppHandle, Manager}; use super::audit::emit_data_changed; use crate::state::AppState; use df_mcp::MCP_DATA_CHANGED_KEY; /// 轮询周期(秒)。变更标记是低频兜底信令(非实时事件队列),10s 内感知即够。 const POLL_SECS: u64 = 10; /// 变更标记体(KV 值反序列化目标)。 #[derive(serde::Deserialize)] struct Marker { /// 写入时刻(毫秒时间戳),用于判定是否为新的一次外部改动。 at: i64, /// 触发写入的工具名,用于复用 entity 映射。 tool: String, } /// 解析 KV 原值为标记;缺失/坏值返 None(调用方保持基线,不下发也不推进)。 fn parse_marker(raw: Option<&str>) -> Option { serde_json::from_str(raw?).ok() } /// 变更标记观察器:维护上次 `at` 基线,判定是否需补发。 /// /// 纯逻辑(只持 `last_at`,无 IO),便于单测;IO/事件下发留在 [`start`] 循环。 struct MarkerWatcher { last_at: Option, } impl MarkerWatcher { fn new() -> Self { Self { last_at: None } } /// 观察一次 KV 原值:返回需补发 `df-data-changed` 的工具名,无则 None。 /// /// - 首次读到有效标记只建基线不补发(避免每次 GUI 启动空刷一次列表) /// - `at` 未变 → None;`at` 推进 → 更新基线并返回工具名 /// - 缺失/坏值 → None(基线不动,下轮有效值仍能判定) fn observe(&mut self, raw: Option<&str>) -> Option { let marker = parse_marker(raw)?; match self.last_at { Some(prev) if prev == marker.at => None, Some(_) => { self.last_at = Some(marker.at); Some(marker.tool) } None => { self.last_at = Some(marker.at); None } } } } /// GUI 侧接点挂载入口(lib.rs setup 调用一次)。 /// /// 常驻轮询循环;读库失败静默跳过(下轮再试),不 panic 拖垮后台任务。 pub fn start(app: AppHandle) { tauri::async_runtime::spawn(async move { let mut watcher = MarkerWatcher::new(); let mut interval = tokio::time::interval(Duration::from_secs(POLL_SECS)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; let raw = { let state = app.state::(); state.settings.get(MCP_DATA_CHANGED_KEY).await.ok().flatten() }; if let Some(tool) = watcher.observe(raw.as_deref()) { tracing::debug!(tool = %tool, "mcp 变更标记推进,补发 df-data-changed"); emit_data_changed(&app, &tool); } } }); } #[cfg(test)] mod tests { use super::*; #[test] fn parse_rejects_missing_or_bad() { assert!(parse_marker(None).is_none()); assert!(parse_marker(Some("not json")).is_none()); assert!(parse_marker(Some(r#"{"at":1}"#)).is_none()); // 缺 tool } #[test] fn parse_ok() { let m = parse_marker(Some(r#"{"at":123,"tool":"create_task"}"#)).unwrap(); assert_eq!(m.at, 123); assert_eq!(m.tool, "create_task"); } #[test] fn first_read_baselines_without_emit() { let mut w = MarkerWatcher::new(); assert_eq!(w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)), None); } #[test] fn advanced_at_emits_tool() { let mut w = MarkerWatcher::new(); w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)); assert_eq!( w.observe(Some(r#"{"at":200,"tool":"update_project"}"#)), Some("update_project".to_string()) ); } #[test] fn unchanged_at_no_emit() { let mut w = MarkerWatcher::new(); w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)); assert_eq!(w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)), None); assert_eq!(w.observe(Some(r#"{"at":100,"tool":"other"}"#)), None); } #[test] fn missing_or_bad_keeps_baseline() { let mut w = MarkerWatcher::new(); w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)); assert_eq!(w.observe(None), None); assert_eq!(w.observe(Some("garbage")), None); // 基线未动:随后真推进仍能补发 assert_eq!( w.observe(Some(r#"{"at":101,"tool":"create_task"}"#)), Some("create_task".to_string()) ); } }