Skip to main content

wm_tools/expansion/
system.rs

1//! System tools — health, config, flush.
2
3#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::sync::Arc;
9use wm_core::{Context, EffectRow, Galaxy, Gana, Tool, ToolStats};
10use wm_memory::{MemoryStore, SearchEngine};
11
12pub struct SystemHealthTool {
13    store: Arc<MemoryStore>,
14    search: Option<Arc<SearchEngine>>,
15    stats: ToolStats,
16    effects: EffectRow,
17}
18
19impl SystemHealthTool {
20    pub fn new(store: Arc<MemoryStore>) -> Self {
21        Self {
22            store,
23            search: None,
24            stats: ToolStats::default(),
25            effects: EffectRow::pure(),
26        }
27    }
28
29    /// Create with a search engine for index health reporting.
30    #[must_use]
31    pub fn with_search(store: Arc<MemoryStore>, search: Arc<SearchEngine>) -> Self {
32        Self {
33            store,
34            search: Some(search),
35            stats: ToolStats::default(),
36            effects: EffectRow::pure(),
37        }
38    }
39}
40
41#[async_trait]
42impl Tool for SystemHealthTool {
43    fn name(&self) -> &str {
44        "system.health"
45    }
46    fn gana(&self) -> Gana {
47        Gana::Horn
48    }
49    fn effects(&self) -> &EffectRow {
50        &self.effects
51    }
52    fn description(&self) -> &str {
53        "Overall system health check — galaxy counts, store path, index health"
54    }
55    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
56        let mut total = 0usize;
57        let mut galaxies_with_data = 0usize;
58        let mut failed_galaxies: Vec<String> = Vec::new();
59        for galaxy in Galaxy::all() {
60            match self.store.count(galaxy) {
61                Ok(count) => {
62                    if count > 0 {
63                        total += count;
64                        galaxies_with_data += 1;
65                    }
66                }
67                // Storage errors must not be silently converted to zero
68                // counts — the old behavior reported healthy: true with no
69                // signal that galaxy reads were failing.
70                Err(e) => failed_galaxies.push(format!("{}: {e}", galaxy.db_name())),
71            }
72        }
73
74        // Index health: report degraded state and consistency drift.
75        // When no search engine is configured, report `unavailable` so
76        // callers know search is not functional — not silently healthy.
77        let (index_health, index_consistency) = match &self.search {
78            Some(search) => {
79                let health = search.health().snapshot();
80                let consistency = wm_memory::check_consistency(&self.store, search);
81                let consistency_json = serde_json::json!({
82                    "has_drift": consistency.has_drift,
83                    "total_lmdb": consistency.total_lmdb,
84                    "total_tantivy": consistency.total_tantivy,
85                    "drifted_galaxies": consistency
86                        .galaxies
87                        .iter()
88                        .filter(|g| g.drift)
89                        .map(|g| serde_json::json!({
90                            "galaxy": g.galaxy,
91                            "lmdb_count": g.lmdb_count,
92                            "tantivy_count": g.tantivy_count,
93                        }))
94                        .collect::<Vec<_>>(),
95                });
96                (health, consistency_json)
97            }
98            None => (
99                serde_json::json!({"status": "unavailable", "degraded": true}),
100                serde_json::json!({"status": "unavailable"}),
101            ),
102        };
103
104        let index_degraded = index_health
105            .get("degraded")
106            .and_then(serde_json::Value::as_bool)
107            .unwrap_or(true);
108        let index_drift = index_consistency
109            .get("has_drift")
110            .and_then(serde_json::Value::as_bool)
111            .unwrap_or(false);
112
113        Ok(json!({
114            "status": "success",
115            "healthy": failed_galaxies.is_empty() && !index_degraded && !index_drift,
116            "store_path": self.store.path().display().to_string(),
117            "total_memories": total,
118            "galaxies_with_data": galaxies_with_data,
119            "failed_galaxies": failed_galaxies,
120            "index_health": index_health,
121            "index_consistency": index_consistency,
122            "version": env!("CARGO_PKG_VERSION"),
123        }))
124    }
125    fn stats(&self) -> &ToolStats {
126        &self.stats
127    }
128}
129
130/// `system.config` — system configuration info.
131pub struct SystemConfigTool {
132    stats: ToolStats,
133    effects: EffectRow,
134}
135
136impl SystemConfigTool {
137    #[must_use]
138    pub fn new() -> Self {
139        Self {
140            stats: ToolStats::default(),
141            effects: EffectRow::pure(),
142        }
143    }
144}
145
146impl Default for SystemConfigTool {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152#[async_trait]
153impl Tool for SystemConfigTool {
154    fn name(&self) -> &str {
155        "system.config"
156    }
157    fn gana(&self) -> Gana {
158        Gana::Horn
159    }
160    fn effects(&self) -> &EffectRow {
161        &self.effects
162    }
163    fn description(&self) -> &str {
164        "System configuration info — brain-wave states, galaxies, ganas"
165    }
166    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
167        Ok(json!({
168            "status": "success",
169            "version": env!("CARGO_PKG_VERSION"),
170            "brain_waves": ["Gamma", "Beta", "Alpha", "Theta", "Delta"],
171            "galaxies": Galaxy::COUNT,
172            "ganas": Gana::COUNT,
173            "coherence_threshold": 0.3,
174        }))
175    }
176    fn stats(&self) -> &ToolStats {
177        &self.stats
178    }
179}
180
181/// `system.flush` — flush/cleanup old memories (gentle GC).
182pub struct SystemFlushTool {
183    store: Arc<MemoryStore>,
184    search: Option<Arc<SearchEngine>>,
185    stats: ToolStats,
186    effects: EffectRow,
187}
188
189impl SystemFlushTool {
190    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
191        Self {
192            store,
193            search,
194            stats: ToolStats::default(),
195            effects: EffectRow {
196                // Flush deletes low-importance memories across all galaxies.
197                writes: super::common::memory_galaxy_writes(),
198                reads: super::common::memory_galaxy_reads(),
199                destructive: true,
200                cost: wm_core::CostEstimate {
201                    expensive: true,
202                    ..Default::default()
203                },
204                ..Default::default()
205            },
206        }
207    }
208}
209
210#[async_trait]
211impl Tool for SystemFlushTool {
212    fn name(&self) -> &str {
213        "system.flush"
214    }
215    fn gana(&self) -> Gana {
216        Gana::Root
217    }
218    fn effects(&self) -> &EffectRow {
219        &self.effects
220    }
221    fn description(&self) -> &str {
222        "Flush low-importance memories (gentle GC) — scoped: pass `galaxy` for one galaxy or `store_wide: true` to acknowledge a store-wide flush. Preview-only unless `dry_run: false`."
223    }
224    fn input_schema(&self) -> Value {
225        super::common::schema(
226            &json!({
227                "threshold": {"type": "number", "description": "Flush memories below this importance (default 0.05)"},
228                "galaxy": {"type": "string", "description": "Scope the flush to one galaxy (e.g. 'codex')"},
229                "store_wide": {"type": "boolean", "description": "Acknowledge a store-wide flush across all galaxies"},
230                "dry_run": {"type": "boolean", "description": "Preview only (default true) — count without deleting"},
231            }),
232            &[],
233        )
234    }
235    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
236        let threshold = args
237            .get("threshold")
238            .and_then(serde_json::Value::as_f64)
239            .unwrap_or(0.05) as f32;
240        // Scope is mandatory and value-checked (the firebreak seam only
241        // checks presence): exactly one of a non-empty `galaxy` or an
242        // explicit `store_wide: true`.
243        let galaxy_arg = args.get("galaxy").and_then(serde_json::Value::as_str);
244        let store_wide = args
245            .get("store_wide")
246            .and_then(serde_json::Value::as_bool)
247            .unwrap_or(false);
248        let targets: Vec<Galaxy> = match (galaxy_arg, store_wide) {
249            (Some(name), false) if !name.is_empty() => {
250                vec![super::common::parse_galaxy(name)?]
251            }
252            (None, true) => Galaxy::all().to_vec(),
253            (Some(_), true) => {
254                return Err(wm_core::CoreError::InvalidArgs(
255                    "system.flush takes exactly one scope: `galaxy` or `store_wide: true`, not both"
256                        .into(),
257                ));
258            }
259            _ => {
260                return Err(wm_core::CoreError::InvalidArgs(
261                    "system.flush requires a scope: pass `galaxy` (e.g. {\"galaxy\": \"codex\"}) or acknowledge the blast radius with `store_wide: true`"
262                        .into(),
263                ));
264            }
265        };
266        let dry_run = args
267            .get("dry_run")
268            .and_then(serde_json::Value::as_bool)
269            .unwrap_or(true);
270        let mut per_galaxy = Vec::new();
271        let mut preview: Vec<Value> = Vec::new();
272        let mut flushed = 0u32;
273        for galaxy in &targets {
274            let memories = self.store.scan(*galaxy, 10_000)?;
275            let mut count = 0u32;
276            for mem in &memories {
277                if mem.metadata.importance < threshold
278                    && !mem.metadata.tags.contains(&"system".to_string())
279                {
280                    count += 1;
281                    if preview.len() < 50 {
282                        preview.push(json!({
283                            "id": mem.metadata.id,
284                            "galaxy": galaxy.db_name(),
285                            "importance": mem.metadata.importance,
286                        }));
287                    }
288                    if !dry_run {
289                        self.store.delete(*galaxy, mem.metadata.id)?;
290                        super::common::deindex(
291                            self.search.as_deref(),
292                            &mem.metadata.id.to_string(),
293                        );
294                        flushed += 1;
295                    }
296                }
297            }
298            per_galaxy.push(json!({"galaxy": galaxy.db_name(), "candidates": count}));
299        }
300        Ok(json!({
301            "status": "success",
302            "threshold": threshold,
303            "dry_run": dry_run,
304            "scope": galaxy_arg.unwrap_or("store_wide"),
305            "per_galaxy": per_galaxy,
306            "preview": preview,
307            "flushed": flushed,
308        }))
309    }
310    fn stats(&self) -> &ToolStats {
311        &self.stats
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use wm_memory::MemoryStore;
319
320    #[tokio::test]
321    async fn system_health_reports_failures_honestly() {
322        let dir = tempfile::tempdir().unwrap();
323        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
324        // No search engine → index_health reports unavailable, degraded=true.
325        let tool = SystemHealthTool::new(store);
326
327        let v = tool.call(&mut Context::default(), json!({})).await.unwrap();
328        assert_eq!(v["status"], "success");
329        // healthy is false because index is unavailable (degraded).
330        assert_eq!(v["healthy"], false);
331        assert_eq!(v["index_health"]["status"], "unavailable");
332        assert_eq!(v["index_health"]["degraded"], true);
333        assert!(v.get("failed_galaxies").is_some());
334        assert_eq!(v["failed_galaxies"].as_array().unwrap().len(), 0);
335    }
336
337    #[tokio::test]
338    async fn system_health_with_search_reports_index_consistency() {
339        let dir = tempfile::tempdir().unwrap();
340        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
341        let tantivy_path = dir.path().join("tantivy");
342        std::fs::create_dir_all(&tantivy_path).unwrap();
343        let search = Arc::new(SearchEngine::open(&tantivy_path).unwrap());
344        let tool = SystemHealthTool::with_search(store.clone(), search.clone());
345
346        let v = tool.call(&mut Context::default(), json!({})).await.unwrap();
347        assert_eq!(v["status"], "success");
348        // No memories, no drift → healthy.
349        assert_eq!(v["healthy"], true);
350        assert_eq!(v["index_health"]["degraded"], false);
351        assert_eq!(v["index_health"]["failures"], 0);
352        assert_eq!(v["index_consistency"]["has_drift"], false);
353        assert_eq!(v["index_consistency"]["total_lmdb"], 0);
354        assert_eq!(v["index_consistency"]["total_tantivy"], 0);
355    }
356
357    #[tokio::test]
358    async fn system_health_detects_index_drift() {
359        let dir = tempfile::tempdir().unwrap();
360        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
361        let tantivy_path = dir.path().join("tantivy");
362        std::fs::create_dir_all(&tantivy_path).unwrap();
363        let search = Arc::new(SearchEngine::open(&tantivy_path).unwrap());
364
365        // Write a memory to LMDB without indexing it in Tantivy → drift.
366        let mem = wm_memory::Memory::new(Galaxy::Codex, "unindexed content".into());
367        store.put(Galaxy::Codex, &mem).unwrap();
368
369        let tool = SystemHealthTool::with_search(store.clone(), search.clone());
370        let v = tool.call(&mut Context::default(), json!({})).await.unwrap();
371        assert_eq!(v["status"], "success");
372        assert_eq!(v["healthy"], false);
373        assert_eq!(v["index_consistency"]["has_drift"], true);
374        assert_eq!(v["index_consistency"]["total_lmdb"], 1);
375        assert_eq!(v["index_consistency"]["total_tantivy"], 0);
376    }
377
378    // ── system.flush scope + dry_run hardening ──────────────────────────
379
380    fn flush_fixture() -> (tempfile::TempDir, Arc<MemoryStore>) {
381        let dir = tempfile::tempdir().unwrap();
382        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
383        // Low-importance flushable in Codex + Sessions; high-importance kept.
384        for (galaxy, importance) in [
385            (Galaxy::Codex, 0.01),
386            (Galaxy::Sessions, 0.02),
387            (Galaxy::Codex, 0.9),
388        ] {
389            let mut mem = wm_memory::Memory::new(galaxy, "flush me".into());
390            mem.metadata.importance = importance;
391            store.put(galaxy, &mem).unwrap();
392        }
393        (dir, store)
394    }
395
396    #[tokio::test]
397    async fn flush_requires_a_scope() {
398        let (_dir, store) = flush_fixture();
399        let tool = SystemFlushTool::new(store, None);
400        let err = tool
401            .call(&mut Context::default(), json!({"threshold": 0.05}))
402            .await
403            .unwrap_err();
404        assert!(matches!(err, wm_core::CoreError::InvalidArgs(_)));
405        // store_wide:false is not an acknowledgement either.
406        let err = tool
407            .call(
408                &mut Context::default(),
409                json!({"threshold": 0.05, "store_wide": false}),
410            )
411            .await
412            .unwrap_err();
413        assert!(matches!(err, wm_core::CoreError::InvalidArgs(_)));
414    }
415
416    #[tokio::test]
417    async fn flush_dry_run_previews_without_deleting() {
418        let (_dir, store) = flush_fixture();
419        let tool = SystemFlushTool::new(store.clone(), None);
420        // dry_run defaults true: 1 Codex candidate reported, nothing deleted.
421        let v = tool
422            .call(&mut Context::default(), json!({"galaxy": "codex"}))
423            .await
424            .unwrap();
425        assert_eq!(v["status"], "success");
426        assert_eq!(v["dry_run"], true);
427        assert_eq!(v["flushed"], 0);
428        assert_eq!(v["scope"], "codex");
429        assert_eq!(v["per_galaxy"][0]["candidates"], 1);
430        assert_eq!(store.count(Galaxy::Codex).unwrap(), 2);
431        assert_eq!(store.count(Galaxy::Sessions).unwrap(), 1);
432    }
433
434    #[tokio::test]
435    async fn flush_galaxy_scope_isolates() {
436        let (_dir, store) = flush_fixture();
437        let tool = SystemFlushTool::new(store.clone(), None);
438        let v = tool
439            .call(
440                &mut Context::default(),
441                json!({"galaxy": "codex", "dry_run": false}),
442            )
443            .await
444            .unwrap();
445        assert_eq!(v["flushed"], 1);
446        // Sessions untouched by a Codex-scoped flush.
447        assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
448        assert_eq!(store.count(Galaxy::Sessions).unwrap(), 1);
449    }
450
451    #[tokio::test]
452    async fn flush_store_wide_acknowledged() {
453        let (_dir, store) = flush_fixture();
454        let tool = SystemFlushTool::new(store.clone(), None);
455        let v = tool
456            .call(
457                &mut Context::default(),
458                json!({"store_wide": true, "dry_run": false}),
459            )
460            .await
461            .unwrap();
462        assert_eq!(v["flushed"], 2);
463        assert_eq!(store.count(Galaxy::Codex).unwrap(), 1);
464        assert_eq!(store.count(Galaxy::Sessions).unwrap(), 0);
465    }
466}