1#![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 #[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 Err(e) => failed_galaxies.push(format!("{}: {e}", galaxy.db_name())),
71 }
72 }
73
74 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
130pub 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
181pub 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 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 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 let tool = SystemHealthTool::new(store);
326
327 let v = tool.call(&mut Context::default(), json!({})).await.unwrap();
328 assert_eq!(v["status"], "success");
329 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 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 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 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 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 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 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 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}