1#![forbid(unsafe_code)]
20
21use std::collections::BTreeMap;
22use std::sync::Arc;
23
24use async_trait::async_trait;
25use chrono::{DateTime, Duration as ChronoDuration, Timelike, Utc};
26use serde_json::{Value, json};
27use wm_core::{Context, CoreError, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
28use wm_memory::Memory;
29use wm_memory::MemoryStore;
30use wm_memory::search::SearchEngine;
31
32use super::common;
33
34const TAG_WINDOW: &str = "window";
35const TAG_ROLLUP: &str = "rollup";
36const TAG_OBSERVATION: &str = "observation";
37const TAG_FUNNEL: &str = "funnel";
38const RECORD_KINDS: [&str; 4] = [
39 "telemetry.window",
40 "telemetry.rollup",
41 "telemetry.observation",
42 "telemetry.funnel",
43];
44pub(crate) const IMPORTANCE_CEILING: f32 = 0.40;
45const SOURCE_TRUST: f32 = 0.7;
46
47fn telemetry_effects(destructive: bool, writes: bool) -> EffectRow {
48 EffectRow {
49 reads: vec![Resource::Galaxy("telemetry".into())],
50 writes: if writes {
51 vec![Resource::Galaxy("telemetry".into())]
52 } else {
53 vec![]
54 },
55 destructive,
56 ..Default::default()
57 }
58}
59
60pub(crate) fn validate_record(record: &Value) -> std::result::Result<&'static str, String> {
62 let kind = record
63 .get("kind")
64 .and_then(Value::as_str)
65 .ok_or_else(|| "record.kind is required".to_string())?;
66 let kind = RECORD_KINDS
67 .iter()
68 .find(|k| **k == kind)
69 .ok_or_else(|| format!("record.kind must be one of {RECORD_KINDS:?}, got '{kind}'"))?;
70 record
71 .get("ts")
72 .and_then(Value::as_str)
73 .ok_or_else(|| "record.ts (RFC3339) is required".to_string())?;
74 if *kind == "telemetry.funnel" {
75 let milestone = record
78 .get("milestone")
79 .and_then(Value::as_str)
80 .filter(|milestone| !milestone.is_empty())
81 .ok_or_else(|| {
82 "record.milestone (non-empty string) is required for telemetry.funnel records"
83 .to_string()
84 })?;
85 if !super::funnel::is_known_milestone(milestone) {
86 return Err(format!(
87 "record.milestone '{milestone}' is not a known funnel milestone"
88 ));
89 }
90 if let Some(channel) = record.get("channel") {
91 let channel = channel.as_str().ok_or_else(|| {
92 "record.channel (string) must be a site install channel".to_string()
93 })?;
94 if super::funnel::Channel::parse(channel).is_none() {
95 return Err(format!(
96 "record.channel '{channel}' must be one of install_sh|binary|npm|docker|cargo|source|unknown"
97 ));
98 }
99 }
100 for field in ["version", "os", "arch"] {
101 if record.get(field).is_some_and(|value| !value.is_string()) {
102 return Err(format!("record.{field} (string) is required when present"));
103 }
104 }
105 if record
106 .get("day_offset")
107 .is_some_and(|offset| offset.as_u64().is_none())
108 {
109 return Err(
110 "record.day_offset (non-negative integer) is required when present".to_string(),
111 );
112 }
113 return Ok(kind);
114 }
115 if *kind == "telemetry.observation" {
116 for field in ["policy_id", "metric", "state", "action"] {
119 if record
120 .get(field)
121 .and_then(Value::as_str)
122 .is_none_or(str::is_empty)
123 {
124 return Err(format!(
125 "record.{field} (non-empty string) is required for telemetry.observation records"
126 ));
127 }
128 }
129 if record.get("value").and_then(Value::as_f64).is_none() {
130 return Err(
131 "record.value (number) is required for telemetry.observation records".to_string(),
132 );
133 }
134 return Ok(kind);
135 }
136 let harmony = record
139 .get("harmony_score")
140 .and_then(Value::as_f64)
141 .or_else(|| {
142 record
143 .get("harmony")
144 .and_then(|h| h.get("avg"))
145 .and_then(Value::as_f64)
146 })
147 .ok_or_else(|| "record.harmony_score (0.0-1.0) is required".to_string())?;
148 if !(0.0..=1.0).contains(&harmony) {
149 return Err(format!(
150 "record.harmony_score must be in 0.0-1.0, got {harmony}"
151 ));
152 }
153 if !record.get("dims").is_some_and(Value::is_object) {
154 return Err("record.dims (object) is required".to_string());
155 }
156 if *kind == "telemetry.window" && !record.get("dim_notes").is_some_and(Value::is_object) {
160 return Err(
161 "record.dim_notes (object) is required for telemetry.window records".to_string(),
162 );
163 }
164 Ok(kind)
165}
166
167pub(crate) fn store_record(
172 store: &MemoryStore,
173 search: Option<&SearchEngine>,
174 record: &Value,
175 kind: &str,
176 source: &str,
177) -> std::result::Result<(String, bool), String> {
178 let content =
179 serde_json::to_string(record).map_err(|e| format!("record serialization failed: {e}"))?;
180 let mut memory = Memory::new(Galaxy::Telemetry, content);
181 let mut tags: Vec<String> = record
182 .get("tags")
183 .and_then(Value::as_array)
184 .map(|arr| {
185 arr.iter()
186 .filter_map(Value::as_str)
187 .map(str::to_string)
188 .collect()
189 })
190 .unwrap_or_default();
191 for required in ["telemetry", "edge"] {
192 if !tags.iter().any(|t| t == required) {
193 tags.push(required.to_string());
194 }
195 }
196 let kind_tag = match kind {
197 "telemetry.window" => TAG_WINDOW,
198 "telemetry.observation" => TAG_OBSERVATION,
199 "telemetry.funnel" => TAG_FUNNEL,
200 _ => TAG_ROLLUP,
201 };
202 if !tags.iter().any(|t| t == kind_tag) {
203 tags.push(kind_tag.to_string());
204 }
205 memory.metadata.tags = tags;
206 let importance = record
207 .get("importance")
208 .and_then(Value::as_f64)
209 .map_or(0.3_f32, |v| v as f32);
210 memory.metadata.importance = importance.clamp(0.0, IMPORTANCE_CEILING);
214 memory.metadata.title = record
215 .get("ts")
216 .and_then(Value::as_str)
217 .map(|ts| format!("telemetry {kind_tag} {ts}"));
218 memory.metadata.source = source.to_string();
219 memory.metadata.source_trust = SOURCE_TRUST;
220 memory.metadata.class =
221 wm_memory::typology::detect_class(&memory.content, &memory.metadata.tags)
222 .or(Some(wm_memory::typology::MemoryClass::Telemetry));
223 memory.metadata.tier = memory.metadata.class.map_or(
224 wm_memory::memory::Tier::Working,
225 wm_memory::typology::initial_tier,
226 );
227
228 let new_id = memory.metadata.id;
229 let stored_id = store
230 .put_dedup(Galaxy::Telemetry, &memory)
231 .map_err(|e| format!("telemetry store failed: {e}"))?;
232 let deduplicated = stored_id != new_id;
233 if !deduplicated {
234 common::index_memory(search, &memory);
235 }
236 Ok((stored_id.to_string(), deduplicated))
237}
238
239pub struct TelemetryRecordTool {
241 store: Arc<MemoryStore>,
242 search: Option<Arc<SearchEngine>>,
243 stats: ToolStats,
244 effects: EffectRow,
245}
246
247impl TelemetryRecordTool {
248 #[must_use]
250 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
251 Self {
252 store,
253 search,
254 stats: ToolStats::default(),
255 effects: telemetry_effects(false, true),
256 }
257 }
258}
259
260#[async_trait]
261impl Tool for TelemetryRecordTool {
262 fn name(&self) -> &str {
263 "telemetry.record"
264 }
265 fn gana(&self) -> Gana {
266 Gana::Ghost
267 }
268 fn effects(&self) -> &EffectRow {
269 &self.effects
270 }
271 fn description(&self) -> &str {
272 "Record one telemetry window/rollup/observation/funnel record (schema wm-telemetry-v1) into the telemetry galaxy. Args: record (object with kind/ts/harmony_score/dims), source (optional trust label), importance (optional, capped 0.40). Deduplicates on identical content."
273 }
274 fn input_schema(&self) -> Value {
275 common::schema(
276 &json!({
277 "record": {"type": "object", "description": "telemetry.window | telemetry.rollup | telemetry.observation | telemetry.funnel record (required)"},
278 "source": common::str_prop("producer label (default 'agent')"),
279 "importance": common::num_prop("0-1, capped to the telemetry class ceiling 0.40"),
280 }),
281 &["record"],
282 )
283 }
284 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
285 let record = if let Some(record) = args.get("record") {
286 record.clone()
287 } else if let Some(content) = args.get("content").and_then(Value::as_str) {
288 serde_json::from_str(content)
289 .map_err(|e| CoreError::InvalidArgs(format!("content is not JSON: {e}")))?
290 } else {
291 return Err(CoreError::InvalidArgs(
292 "record (object) or content (JSON string) is required".into(),
293 ));
294 };
295 let kind = validate_record(&record).map_err(CoreError::InvalidArgs)?;
296 let mut record = record;
297 if let Some(importance) = args.get("importance") {
298 record["importance"] = importance.clone();
299 }
300 let source = args
301 .get("source")
302 .and_then(Value::as_str)
303 .unwrap_or("agent");
304 let (id, deduplicated) =
305 store_record(&self.store, self.search.as_deref(), &record, kind, source)
306 .map_err(CoreError::Internal)?;
307 Ok(json!({
308 "status": "success",
309 "id": id,
310 "galaxy": "telemetry",
311 "kind": kind,
312 "deduplicated": deduplicated,
313 }))
314 }
315 fn stats(&self) -> &ToolStats {
316 &self.stats
317 }
318}
319
320fn fnum(record: &Value, path: &[&str]) -> Option<f64> {
321 let mut value = record;
322 for key in path {
323 value = value.get(*key)?;
324 }
325 value.as_f64()
326}
327
328fn round4(value: f64) -> f64 {
329 (value * 10_000.0).round() / 10_000.0
330}
331
332fn build_rollup(period_start: &str, period_end: &str, windows: &[Value]) -> Value {
334 let mut dim_values: BTreeMap<String, Vec<f64>> = BTreeMap::new();
335 let mut harmony: Vec<f64> = Vec::new();
336 let mut guna = json!({"sattvic": 0, "rajasic": 0, "tamasic": 0});
337 let mut topics: BTreeMap<String, u64> = BTreeMap::new();
338 let mut event_count = 0_u64;
339 for window in windows {
340 if let Some(score) = fnum(window, &["harmony_score"]) {
341 harmony.push(score);
342 }
343 if let Some(dims) = window.get("dims").and_then(Value::as_object) {
344 for (name, score) in dims {
345 if let Some(score) = score.as_f64() {
346 dim_values.entry(name.clone()).or_default().push(score);
347 }
348 }
349 }
350 if let Some(g) = window.get("guna").and_then(Value::as_object) {
351 for key in ["sattvic", "rajasic", "tamasic"] {
352 if let Some(v) = g.get(key).and_then(Value::as_u64) {
353 guna[key] = json!(guna[key].as_u64().unwrap_or(0) + v);
354 }
355 }
356 }
357 if let Some(events) = window.get("events").and_then(Value::as_array) {
358 event_count += events.len() as u64;
359 for event in events {
360 if let Some(topic) = event.get("topic").and_then(Value::as_str) {
361 *topics.entry(topic.to_string()).or_default() += 1;
362 }
363 }
364 }
365 }
366 let stats = |values: &[f64]| -> Value {
367 if values.is_empty() {
368 return json!({"avg": null, "min": null, "max": null});
369 }
370 let sum: f64 = values.iter().sum();
371 json!({
372 "avg": round4(sum / values.len() as f64),
373 "min": round4(values.iter().copied().fold(f64::INFINITY, f64::min)),
374 "max": round4(values.iter().copied().fold(f64::NEG_INFINITY, f64::max)),
375 })
376 };
377 let dims: BTreeMap<String, Value> = dim_values
378 .iter()
379 .map(|(name, values)| (name.clone(), stats(values)))
380 .collect();
381 let harmony_stats = stats(&harmony);
382 json!({
383 "kind": "telemetry.rollup",
384 "ts": period_end,
385 "period_start": period_start,
386 "period_end": period_end,
387 "hours": 1,
388 "samples": windows.len(),
389 "dims": dims,
390 "harmony": harmony_stats,
391 "guna": guna,
392 "events": {"count": event_count, "topics": topics},
393 "source": "wm-rollup",
394 "tags": ["telemetry", "edge", TAG_ROLLUP],
395 })
396}
397
398fn period_bounds(ts: &str) -> Option<(String, String)> {
399 let stamp = DateTime::parse_from_rfc3339(ts).ok()?.with_timezone(&Utc);
400 let start = stamp.date_naive().and_hms_opt(stamp.hour(), 0, 0)?;
401 let start = DateTime::<Utc>::from_naive_utc_and_offset(start, Utc);
402 let end = start + ChronoDuration::hours(1);
403 Some((
404 start.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
405 end.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
406 ))
407}
408
409pub struct TelemetryRollupTool {
411 store: Arc<MemoryStore>,
412 search: Option<Arc<SearchEngine>>,
413 stats: ToolStats,
414 effects: EffectRow,
415}
416
417impl TelemetryRollupTool {
418 #[must_use]
420 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
421 Self {
422 store,
423 search,
424 stats: ToolStats::default(),
425 effects: telemetry_effects(false, true),
426 }
427 }
428}
429
430#[async_trait]
431impl Tool for TelemetryRollupTool {
432 fn name(&self) -> &str {
433 "telemetry.rollup"
434 }
435 fn gana(&self) -> Gana {
436 Gana::Ghost
437 }
438 fn effects(&self) -> &EffectRow {
439 &self.effects
440 }
441 fn description(&self) -> &str {
442 "Aggregate completed telemetry windows into hourly rollups (deterministic; re-runs deduplicate). Args: hours (scan horizon, default 24), include_current (default false — only completed hours), dry_run (default false)."
443 }
444 fn input_schema(&self) -> Value {
445 common::schema(
446 &json!({
447 "hours": common::num_prop("how far back to scan (default 24)"),
448 "include_current": common::bool_prop("include the in-progress hour (default false)"),
449 "dry_run": common::bool_prop("report without writing (default false)"),
450 }),
451 &[],
452 )
453 }
454 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
455 let hours = args.get("hours").and_then(Value::as_f64).unwrap_or(24.0);
456 let include_current = args
457 .get("include_current")
458 .and_then(Value::as_bool)
459 .unwrap_or(false);
460 let dry_run = args
461 .get("dry_run")
462 .and_then(Value::as_bool)
463 .unwrap_or(false);
464 let cutoff = Utc::now() - ChronoDuration::minutes((hours * 60.0) as i64);
465 let current_period =
466 period_bounds(&Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
467 .map(|(start, _)| start);
468
469 let memories = self
470 .store
471 .scan_all(Galaxy::Telemetry)
472 .map_err(|e| CoreError::Internal(format!("telemetry scan failed: {e}")))?;
473 let mut windows: BTreeMap<String, Vec<Value>> = BTreeMap::new();
474 let mut skipped_current = 0_u64;
475 let mut scanned = 0_u64;
476 for memory in &memories {
477 if !memory.metadata.tags.iter().any(|t| t == TAG_WINDOW) {
478 continue;
479 }
480 let Ok(record) = serde_json::from_str::<Value>(&memory.content) else {
481 continue;
482 };
483 let Some(ts) = record.get("ts").and_then(Value::as_str) else {
484 continue;
485 };
486 let Some(stamp) = DateTime::parse_from_rfc3339(ts)
487 .ok()
488 .map(|s| s.with_timezone(&Utc))
489 else {
490 continue;
491 };
492 if stamp < cutoff {
493 continue;
494 }
495 scanned += 1;
496 let Some((start, _end)) = period_bounds(ts) else {
497 continue;
498 };
499 if !include_current && current_period.as_deref() == Some(start.as_str()) {
500 skipped_current += 1;
501 continue;
502 }
503 windows.entry(start).or_default().push(record);
504 }
505
506 let mut written = 0_u64;
507 let mut deduplicated = 0_u64;
508 let mut first_period: Option<String> = None;
509 let mut last_period: Option<String> = None;
510 for (start, records) in &windows {
511 let end = period_bounds(start).map_or_else(|| start.clone(), |(_, end)| end);
512 let rollup = build_rollup(start, &end, records);
513 first_period.get_or_insert_with(|| start.clone());
514 last_period = Some(start.clone());
515 if dry_run {
516 continue;
517 }
518 let (_, dedup) = store_record(
519 &self.store,
520 self.search.as_deref(),
521 &rollup,
522 "telemetry.rollup",
523 "wm-rollup",
524 )
525 .map_err(CoreError::Internal)?;
526 if dedup {
527 deduplicated += 1;
528 } else {
529 written += 1;
530 }
531 }
532 Ok(json!({
533 "status": "success",
534 "dry_run": dry_run,
535 "scanned": scanned,
536 "windows": scanned,
537 "periods": windows.len(),
538 "written": written,
539 "deduplicated": deduplicated,
540 "skipped_current": skipped_current,
541 "first_period": first_period,
542 "last_period": last_period,
543 }))
544 }
545 fn stats(&self) -> &ToolStats {
546 &self.stats
547 }
548}
549
550pub struct TelemetryPruneTool {
552 store: Arc<MemoryStore>,
553 stats: ToolStats,
554 effects: EffectRow,
555}
556
557impl TelemetryPruneTool {
558 #[must_use]
560 pub fn new(store: Arc<MemoryStore>) -> Self {
561 Self {
562 store,
563 stats: ToolStats::default(),
564 effects: telemetry_effects(true, true),
565 }
566 }
567}
568
569#[async_trait]
570impl Tool for TelemetryPruneTool {
571 fn name(&self) -> &str {
572 "telemetry.prune"
573 }
574 fn gana(&self) -> Gana {
575 Gana::Ghost
576 }
577 fn effects(&self) -> &EffectRow {
578 &self.effects
579 }
580 fn description(&self) -> &str {
581 "Destructive retention: delete telemetry windows/rollups older than their horizons (defaults 7 d windows / 90 d rollups). Args: windows_older_than_days, rollups_older_than_days, dry_run (default true), limit (0 = all). Requires confirm: true when dry_run is false."
582 }
583 fn input_schema(&self) -> Value {
584 common::schema(
585 &json!({
586 "windows_older_than_days": common::num_prop("window horizon in days (default 7)"),
587 "rollups_older_than_days": common::num_prop("rollup horizon in days (default 90)"),
588 "dry_run": common::bool_prop("report without deleting (default true)"),
589 "limit": common::num_prop("max records to inspect, 0 = all"),
590 }),
591 &[],
592 )
593 }
594 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
595 let window_days = args
596 .get("windows_older_than_days")
597 .and_then(Value::as_f64)
598 .unwrap_or(7.0);
599 let rollup_days = args
600 .get("rollups_older_than_days")
601 .and_then(Value::as_f64)
602 .unwrap_or(90.0);
603 let dry_run = args.get("dry_run").and_then(Value::as_bool).unwrap_or(true);
604 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(0) as usize;
605
606 let memories = self
607 .store
608 .scan_all(Galaxy::Telemetry)
609 .map_err(|e| CoreError::Internal(format!("telemetry scan failed: {e}")))?;
610 let now = Utc::now();
611 let mut scanned = 0_u64;
612 let mut candidates = 0_u64;
613 let mut deleted = 0_u64;
614 let mut window_candidates = 0_u64;
615 let mut rollup_candidates = 0_u64;
616 let mut errors: Vec<String> = Vec::new();
617 for memory in &memories {
618 if limit > 0 && scanned as usize >= limit {
619 break;
620 }
621 scanned += 1;
622 let is_window = memory.metadata.tags.iter().any(|t| t == TAG_WINDOW);
623 let is_rollup = memory.metadata.tags.iter().any(|t| t == TAG_ROLLUP);
624 let horizon = if is_window {
625 window_days
626 } else if is_rollup {
627 rollup_days
628 } else {
629 continue;
630 };
631 let age = now.signed_duration_since(memory.metadata.created_at);
632 if age < ChronoDuration::minutes((horizon * 1440.0) as i64) {
633 continue;
634 }
635 candidates += 1;
636 if is_window {
637 window_candidates += 1;
638 } else {
639 rollup_candidates += 1;
640 }
641 if dry_run {
642 continue;
643 }
644 match self.store.delete(Galaxy::Telemetry, memory.metadata.id) {
645 Ok(true) => deleted += 1,
646 Ok(false) => {}
647 Err(e) => errors.push(format!("{}: {e}", memory.metadata.id)),
648 }
649 }
650 Ok(json!({
651 "status": "success",
652 "dry_run": dry_run,
653 "scanned": scanned,
654 "candidates": candidates,
655 "deleted": deleted,
656 "by_kind": {"window": window_candidates, "rollup": rollup_candidates},
657 "horizons": {"windows_days": window_days, "rollups_days": rollup_days},
658 "errors": errors,
659 }))
660 }
661 fn stats(&self) -> &ToolStats {
662 &self.stats
663 }
664}
665
666fn retention_inventory(
670 store: &MemoryStore,
671 window_days: f64,
672 rollup_days: f64,
673) -> std::result::Result<(Value, bool), String> {
674 #[derive(Default)]
675 struct Tier {
676 count: u64,
677 eligible: u64,
678 bytes: u64,
679 oldest: Option<DateTime<Utc>>,
680 newest: Option<DateTime<Utc>>,
681 oldest_eligible: Option<DateTime<Utc>>,
682 oldest_kept: Option<DateTime<Utc>>,
683 }
684
685 fn stamp(dt: DateTime<Utc>) -> String {
686 dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
687 }
688
689 impl Tier {
690 fn observe(&mut self, created: DateTime<Utc>, eligible_now: bool, bytes: u64) {
691 self.count += 1;
692 self.bytes += bytes;
693 self.oldest = Some(self.oldest.map_or(created, |o| o.min(created)));
694 self.newest = Some(self.newest.map_or(created, |n| n.max(created)));
695 if eligible_now {
696 self.eligible += 1;
697 self.oldest_eligible =
698 Some(self.oldest_eligible.map_or(created, |o| o.min(created)));
699 } else {
700 self.oldest_kept = Some(self.oldest_kept.map_or(created, |o| o.min(created)));
701 }
702 }
703
704 fn ages(&self) -> Value {
705 json!({
706 "count": self.count,
707 "bytes": self.bytes,
708 "oldest_created": self.oldest.map(stamp),
709 "newest_created": self.newest.map(stamp),
710 })
711 }
712
713 fn json(&self, horizon_days: f64) -> Value {
714 let mut value = self.ages();
715 value["eligible"] = json!(self.eligible);
716 value["oldest_eligible"] = json!(self.oldest_eligible.map(stamp));
717 value["next_eligible_since"] = json!(
718 self.oldest_kept
719 .map(|d| stamp(d + ChronoDuration::minutes((horizon_days * 1440.0) as i64)))
720 );
721 value
722 }
723 }
724
725 let memories = store
726 .scan_all(Galaxy::Telemetry)
727 .map_err(|e| format!("telemetry scan failed: {e}"))?;
728 let now = Utc::now();
729 let mut windows = Tier::default();
730 let mut rollups = Tier::default();
731 let mut observations = Tier::default();
732 let mut funnel = Tier::default();
733 let mut unmanaged = Tier::default();
734 for memory in &memories {
735 let created = memory.metadata.created_at;
736 let bytes = memory.content.len() as u64;
737 let tags = &memory.metadata.tags;
738 if tags.iter().any(|t| t == TAG_WINDOW) {
739 let eligible = now.signed_duration_since(created)
740 >= ChronoDuration::minutes((window_days * 1440.0) as i64);
741 windows.observe(created, eligible, bytes);
742 } else if tags.iter().any(|t| t == TAG_ROLLUP) {
743 let eligible = now.signed_duration_since(created)
744 >= ChronoDuration::minutes((rollup_days * 1440.0) as i64);
745 rollups.observe(created, eligible, bytes);
746 } else if tags.iter().any(|t| t == TAG_OBSERVATION) {
747 observations.observe(created, false, bytes);
748 } else if tags.iter().any(|t| t == TAG_FUNNEL) {
749 funnel.observe(created, false, bytes);
750 } else {
751 unmanaged.observe(created, false, bytes);
752 }
753 }
754
755 let mut observation_json = observations.ages();
756 observation_json["managed"] = json!(false);
757 observation_json["note"] = json!(
758 "policy decision records are governance evidence; telemetry.prune does not delete them"
759 );
760 let mut funnel_json = funnel.ages();
761 funnel_json["managed"] = json!(false);
762 funnel_json["note"] =
763 json!("store-lifetime evidence; telemetry.prune does not delete; reset explicitly");
764 let mut unmanaged_json = unmanaged.ages();
765 unmanaged_json["note"] = json!("telemetry rows without a window/rollup/observation/funnel tag");
766
767 let prune_due = windows.eligible + rollups.eligible > 0;
768 let inventory = json!({
769 "windows": windows.json(window_days),
770 "rollups": rollups.json(rollup_days),
771 "observations": observation_json,
772 "funnel": funnel_json,
773 "unmanaged": unmanaged_json,
774 });
775 Ok((inventory, prune_due))
776}
777
778pub struct TelemetryRetentionTool {
780 store: Arc<MemoryStore>,
781 stats: ToolStats,
782 effects: EffectRow,
783}
784
785impl TelemetryRetentionTool {
786 #[must_use]
788 pub fn new(store: Arc<MemoryStore>) -> Self {
789 Self {
790 store,
791 stats: ToolStats::default(),
792 effects: telemetry_effects(false, false),
793 }
794 }
795}
796
797#[async_trait]
798impl Tool for TelemetryRetentionTool {
799 fn name(&self) -> &str {
800 "telemetry.retention"
801 }
802 fn gana(&self) -> Gana {
803 Gana::Ghost
804 }
805 fn effects(&self) -> &EffectRow {
806 &self.effects
807 }
808 fn description(&self) -> &str {
809 "Read-only retention planner: per-tier counts/ages/eligibility for telemetry windows (7 d) and rollups (90 d) under the exact horizons telemetry.prune uses, plus managed=false observation inventory. No confirm and no dharma gate — safe to run any time; act with telemetry.prune."
810 }
811 fn input_schema(&self) -> Value {
812 common::schema(
813 &json!({
814 "windows_older_than_days": common::num_prop("window horizon to project (default 7)"),
815 "rollups_older_than_days": common::num_prop("rollup horizon to project (default 90)"),
816 }),
817 &[],
818 )
819 }
820 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
821 let window_days = args
822 .get("windows_older_than_days")
823 .and_then(Value::as_f64)
824 .unwrap_or(7.0);
825 let rollup_days = args
826 .get("rollups_older_than_days")
827 .and_then(Value::as_f64)
828 .unwrap_or(90.0);
829 let (inventory, prune_due) = retention_inventory(&self.store, window_days, rollup_days)
830 .map_err(CoreError::Internal)?;
831 Ok(json!({
832 "status": "success",
833 "read_only": true,
834 "generated_at": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
835 "basis": "record created_at (identical to telemetry.prune)",
836 "horizons": {"windows_days": window_days, "rollups_days": rollup_days},
837 "inventory": inventory,
838 "prune_due": prune_due,
839 "advice": if prune_due {
840 "eligible records exist — run telemetry.prune with dry_run:false + confirm:true"
841 } else {
842 "nothing eligible — no prune needed"
843 },
844 }))
845 }
846 fn stats(&self) -> &ToolStats {
847 &self.stats
848 }
849}
850
851#[must_use]
853pub fn register_telemetry(
854 registry: &wm_dispatch::ToolRegistry,
855 store: &Arc<MemoryStore>,
856 search: Option<Arc<SearchEngine>>,
857) -> wm_dispatch::ToolRegistry {
858 registry
859 .register(Arc::new(TelemetryRecordTool::new(
860 Arc::clone(store),
861 search.clone(),
862 )))
863 .register(Arc::new(TelemetryRollupTool::new(
864 Arc::clone(store),
865 search,
866 )))
867 .register(Arc::new(TelemetryPruneTool::new(Arc::clone(store))))
868 .register(Arc::new(TelemetryRetentionTool::new(Arc::clone(store))))
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874
875 fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
876 let tmp = tempfile::tempdir().unwrap();
877 let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
878 (tmp, store)
879 }
880
881 fn window_record(ts: &str, harmony: f64) -> Value {
882 json!({
883 "kind": "telemetry.window",
884 "ts": ts,
885 "window_seconds": 60,
886 "source": "laksmi",
887 "tags": ["telemetry", "edge", "laksmi", "window"],
888 "harmony_score": harmony,
889 "dims": {"fairness": 0.5, "responsiveness": 0.8},
890 "dim_notes": {"fairness": "test"},
891 "guna": {"sattvic": 1, "rajasic": 0, "tamasic": 2},
892 "top": [],
893 "events": [{"topic": "os.telemetry.energy", "value": 0.5, "threshold": 0.9}],
894 "karma": {"total": 100.0, "delta": 0.0},
895 "dharma": {"total": 1, "blocked": 0, "delta_total": 0, "delta_blocked": 0},
896 })
897 }
898
899 #[tokio::test]
900 async fn record_validates_and_stores() {
901 let (_tmp, store) = open_store();
902 let tool = TelemetryRecordTool::new(Arc::clone(&store), None);
903 let out = tool
904 .call(
905 &mut Context::default(),
906 json!({"record": window_record("2026-09-13T10:00:00+00:00", 0.6)}),
907 )
908 .await
909 .unwrap();
910 assert_eq!(out["status"], "success");
911 assert_eq!(out["kind"], "telemetry.window");
912 assert_eq!(out["deduplicated"], false);
913 assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 1);
914
915 let again = tool
917 .call(
918 &mut Context::default(),
919 json!({"record": window_record("2026-09-13T10:00:00+00:00", 0.6)}),
920 )
921 .await
922 .unwrap();
923 assert_eq!(again["deduplicated"], true);
924 assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 1);
925
926 let bad = tool
928 .call(
929 &mut Context::default(),
930 json!({"record": {"kind": "telemetry.window", "ts": "x"}}),
931 )
932 .await;
933 assert!(bad.is_err(), "missing harmony_score/dims must error");
934
935 let mut probe = window_record("2026-09-13T11:00:00+00:00", 0.5);
937 probe.as_object_mut().unwrap().remove("dim_notes");
938 let probe_reply = tool
939 .call(&mut Context::default(), json!({"record": probe}))
940 .await;
941 assert!(probe_reply.is_err(), "windows require dim_notes");
942 }
943
944 #[tokio::test]
945 async fn record_accepts_policy_observations_and_typed_rollups() {
946 let (_tmp, store) = open_store();
947 let tool = TelemetryRecordTool::new(Arc::clone(&store), None);
948 let obs = json!({
949 "kind": "telemetry.observation",
950 "ts": "2026-09-13T12:00:00+00:00",
951 "policy_id": "energy.over_budget.v1",
952 "metric": "energy",
953 "state": "observing",
954 "action": "observe",
955 "value": 0.33,
956 "enter": 0.9,
957 "exit": 0.97,
958 "dwell_windows": 1,
959 "tags": ["telemetry", "edge", "observation", "policy"],
960 });
961 let out = tool
962 .call(&mut Context::default(), json!({"record": obs}))
963 .await
964 .unwrap();
965 assert_eq!(out["kind"], "telemetry.observation");
966 assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 1);
967
968 let bad = json!({"kind": "telemetry.observation", "ts": "x", "metric": "energy"});
970 assert!(
971 tool.call(&mut Context::default(), json!({"record": bad}))
972 .await
973 .is_err()
974 );
975
976 let rollup = json!({
979 "kind": "telemetry.rollup",
980 "ts": "2026-09-13T12:00:00+00:00",
981 "harmony": {"avg": 0.6},
982 "dims": {"fairness": {"avg": 0.5}},
983 });
984 let out = tool
985 .call(&mut Context::default(), json!({"record": rollup}))
986 .await
987 .unwrap();
988 assert_eq!(out["kind"], "telemetry.rollup");
989 assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 2);
990
991 let funnel = json!({
993 "kind": "telemetry.funnel",
994 "ts": "2026-09-18T00:00:00+00:00",
995 "milestone": "active_d2",
996 "channel": "install_sh",
997 "version": "9.1.9",
998 "os": "linux",
999 "arch": "x86_64",
1000 "day_offset": 2,
1001 });
1002 let out = tool
1003 .call(&mut Context::default(), json!({"record": funnel}))
1004 .await
1005 .unwrap();
1006 assert_eq!(out["kind"], "telemetry.funnel");
1007 assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 3);
1008 let funnel_tags = store
1009 .scan_all(Galaxy::Telemetry)
1010 .unwrap()
1011 .into_iter()
1012 .find(|m| m.metadata.tags.iter().any(|t| t == TAG_FUNNEL))
1013 .expect("funnel tag");
1014 assert!(funnel_tags.metadata.importance <= IMPORTANCE_CEILING);
1015
1016 let bad_milestone = json!({
1018 "kind": "telemetry.funnel",
1019 "ts": "2026-09-18T00:00:00+00:00",
1020 "milestone": "activation",
1021 });
1022 assert!(
1023 tool.call(&mut Context::default(), json!({"record": bad_milestone}))
1024 .await
1025 .is_err()
1026 );
1027 let bad_channel = json!({
1028 "kind": "telemetry.funnel",
1029 "ts": "2026-09-18T00:00:00+00:00",
1030 "milestone": "first_launch",
1031 "channel": "email",
1032 });
1033 assert!(
1034 tool.call(&mut Context::default(), json!({"record": bad_channel}))
1035 .await
1036 .is_err()
1037 );
1038 }
1039
1040 #[tokio::test]
1041 async fn rollup_aggregates_completed_hours_idempotently() {
1042 let (_tmp, store) = open_store();
1043 let current = Utc::now();
1044 let last_hour = (current - ChronoDuration::hours(1))
1045 .with_minute(30)
1046 .unwrap()
1047 .with_second(0)
1048 .unwrap();
1049 let prev = last_hour - ChronoDuration::hours(2);
1050 let ts_a = last_hour.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1051 let ts_b = (last_hour + ChronoDuration::minutes(5))
1052 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1053 let ts_c = prev.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1054 for (ts, harmony) in [(&ts_a, 0.4), (&ts_b, 0.6), (&ts_c, 0.9)] {
1055 let record = window_record(ts, harmony);
1056 let mem = Memory::new(Galaxy::Telemetry, serde_json::to_string(&record).unwrap());
1057 let mut mem = mem;
1058 mem.metadata.tags = vec!["telemetry".into(), "edge".into(), "window".into()];
1059 store.put(Galaxy::Telemetry, &mem).unwrap();
1060 }
1061 let tool = TelemetryRollupTool::new(Arc::clone(&store), None);
1062 let out = tool.call(&mut Context::default(), json!({})).await.unwrap();
1063 assert_eq!(out["periods"], 2);
1064 assert_eq!(out["written"], 2);
1065 let out2 = tool.call(&mut Context::default(), json!({})).await.unwrap();
1066 assert_eq!(out2["deduplicated"], 2, "re-run is idempotent");
1067 let rollups: Vec<_> = store
1068 .scan_all(Galaxy::Telemetry)
1069 .unwrap()
1070 .into_iter()
1071 .filter(|m| m.metadata.tags.iter().any(|t| t == TAG_ROLLUP))
1072 .collect();
1073 assert_eq!(rollups.len(), 2);
1074 let records: Vec<Value> = rollups
1075 .iter()
1076 .map(|m| serde_json::from_str(&m.content).unwrap())
1077 .collect();
1078 assert!(records.iter().all(|r| r["kind"] == "telemetry.rollup"));
1079 let total_events: u64 = records
1080 .iter()
1081 .map(|r| r["events"]["count"].as_u64().unwrap_or(0))
1082 .sum();
1083 assert_eq!(total_events, 3);
1084 let samples: Vec<u64> = records
1085 .iter()
1086 .map(|r| r["samples"].as_u64().unwrap_or(0))
1087 .collect();
1088 assert!(
1089 samples.contains(&2) && samples.contains(&1),
1090 "got {samples:?}"
1091 );
1092 }
1093
1094 #[tokio::test]
1095 async fn prune_respects_horizons_and_dry_run() {
1096 let (_tmp, store) = open_store();
1097 let old = Utc::now() - ChronoDuration::days(10);
1098 let ancient = Utc::now() - ChronoDuration::days(100);
1099 let recent = Utc::now() - ChronoDuration::hours(1);
1100 for (tag, created) in [
1101 ("window", old),
1102 ("window", recent),
1103 ("rollup", ancient),
1104 ("rollup", recent),
1105 ] {
1106 let mut mem = Memory::new(Galaxy::Telemetry, format!("{{\"ts\":\"{created}\"}}"));
1107 mem.metadata.tags = vec!["telemetry".into(), tag.into()];
1108 mem.metadata.created_at = created;
1109 store.put(Galaxy::Telemetry, &mem).unwrap();
1110 }
1111 let tool = TelemetryPruneTool::new(Arc::clone(&store));
1112 let dry = tool
1113 .call(&mut Context::default(), json!({"dry_run": true}))
1114 .await
1115 .unwrap();
1116 assert_eq!(dry["dry_run"], true);
1117 assert_eq!(dry["candidates"], 2, "10d window + 100d rollup");
1118 assert_eq!(dry["deleted"], 0);
1119 assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 4);
1120
1121 let wet = tool
1122 .call(&mut Context::default(), json!({"dry_run": false}))
1123 .await
1124 .unwrap();
1125 assert_eq!(wet["deleted"], 2);
1126 assert_eq!(wet["by_kind"]["window"], 1);
1127 assert_eq!(wet["by_kind"]["rollup"], 1);
1128 assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 2);
1129 }
1130
1131 #[tokio::test]
1132 async fn retention_planner_reports_tiers_read_only() {
1133 let (_tmp, store) = open_store();
1134 let now = Utc::now();
1135 for (tag, created) in [
1136 ("window", now - ChronoDuration::days(10)),
1137 ("window", now - ChronoDuration::hours(1)),
1138 ("rollup", now - ChronoDuration::days(100)),
1139 ("rollup", now - ChronoDuration::hours(2)),
1140 ("observation", now - ChronoDuration::days(1)),
1141 ("funnel", now - ChronoDuration::days(400)),
1142 ("", now - ChronoDuration::days(30)),
1143 ] {
1144 let mut mem = Memory::new(Galaxy::Telemetry, format!("{{\"ts\":\"{created}\"}}"));
1145 mem.metadata.tags = if tag.is_empty() {
1146 vec!["telemetry".into()]
1147 } else {
1148 vec!["telemetry".into(), tag.into()]
1149 };
1150 mem.metadata.created_at = created;
1151 store.put(Galaxy::Telemetry, &mem).unwrap();
1152 }
1153
1154 let tool = TelemetryRetentionTool::new(Arc::clone(&store));
1155 let out = tool.call(&mut Context::default(), json!({})).await.unwrap();
1156 assert_eq!(out["status"], "success");
1157 assert_eq!(out["read_only"], true);
1158 assert_eq!(out["prune_due"], true);
1159 assert_eq!(out["inventory"]["windows"]["count"], 2);
1160 assert_eq!(out["inventory"]["windows"]["eligible"], 1);
1161 assert_eq!(out["inventory"]["rollups"]["count"], 2);
1162 assert_eq!(out["inventory"]["rollups"]["eligible"], 1);
1163 assert_eq!(out["inventory"]["observations"]["count"], 1);
1164 assert_eq!(out["inventory"]["observations"]["managed"], false);
1165 assert_eq!(
1166 out["inventory"]["funnel"]["count"], 1,
1167 "funnel records are inventoried as a tier"
1168 );
1169 assert!(
1170 out["inventory"]["funnel"]["eligible"].is_null(),
1171 "funnel records are store-lifetime evidence — never prune-eligible"
1172 );
1173 assert_eq!(out["inventory"]["funnel"]["managed"], false);
1174 assert_eq!(out["inventory"]["unmanaged"]["count"], 1);
1175 assert!(
1176 out["inventory"]["windows"]["next_eligible_since"].is_string(),
1177 "a kept window must project its next eligibility"
1178 );
1179 assert!(out["inventory"]["windows"]["bytes"].as_u64().unwrap() > 0);
1180 assert_eq!(
1181 store.count(Galaxy::Telemetry).unwrap(),
1182 7,
1183 "the planner is read-only"
1184 );
1185
1186 let calm = tool
1188 .call(
1189 &mut Context::default(),
1190 json!({"windows_older_than_days": 365, "rollups_older_than_days": 3650}),
1191 )
1192 .await
1193 .unwrap();
1194 assert_eq!(calm["prune_due"], false);
1195 assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 7);
1196
1197 let prune = TelemetryPruneTool::new(Arc::clone(&store));
1200 let wet = prune
1201 .call(&mut Context::default(), json!({"dry_run": false}))
1202 .await
1203 .unwrap();
1204 assert_eq!(wet["deleted"], 2);
1205 assert_eq!(store.count(Galaxy::Telemetry).unwrap(), 5);
1206 assert_eq!(
1207 store
1208 .scan_all(Galaxy::Telemetry)
1209 .unwrap()
1210 .iter()
1211 .filter(|m| m.metadata.tags.iter().any(|t| t == TAG_FUNNEL))
1212 .count(),
1213 1,
1214 "prune must leave funnel records untouched"
1215 );
1216 }
1217}