//! Passive drift observer. //! //! Watches `crates/`, `.ontology/`, and `adrs/` for changes. After a debounce //! window, spawns `./ontoref sync scan` followed by `./ontoref sync diff`. If //! the diff output contains MISSING, STALE, DRIFT, or BROKEN items it pushes a //! custom notification into every registered project's notification ring //! buffer. //! //! Intentionally read-only: no `apply` step is ever triggered automatically. use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher}; use tokio::sync::mpsc; use tracing::{debug, info, warn}; use crate::notifications::NotificationStore; const DEBOUNCE: Duration = Duration::from_secs(15); pub struct DriftWatcher { _watcher: RecommendedWatcher, _task: tokio::task::JoinHandle<()>, } impl DriftWatcher { /// Start watching `project_root` for source and ontology changes. /// /// Watches `crates/`, `.ontology/`, `adrs/` (and `reflection/modes/`). /// When drift is detected after `./ontoref sync scan && sync diff`, emits /// a custom notification via `notifications`. pub fn start( project_root: &Path, project_name: String, notifications: Arc, ) -> Result { let root = project_root.to_path_buf(); let (tx, rx) = mpsc::channel::(64); let tx2 = tx.clone(); let mut watcher = RecommendedWatcher::new( move |res: std::result::Result| match res { Ok(event) => { for path in event.paths { let _ = tx2.try_send(path); } } Err(e) => warn!(error = %e, "drift watcher notify error"), }, Config::default(), ) .map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?; // Watch relevant subtrees; ignore missing dirs gracefully. for subdir in &["crates", ".ontology", "adrs", "reflection/modes"] { let path = root.join(subdir); if path.exists() { watcher .watch(&path, RecursiveMode::Recursive) .map_err(|e| crate::error::DaemonError::Watcher(e.to_string()))?; debug!(path = %path.display(), "drift watcher: watching"); } } info!(root = %root.display(), "drift watcher started"); let task = tokio::spawn(drift_loop(rx, root, project_name, notifications)); Ok(Self { _watcher: watcher, _task: task, }) } } async fn drift_loop( mut rx: mpsc::Receiver, root: PathBuf, project: String, notifications: Arc, ) { loop { // Block until first change arrives. let Some(_first) = rx.recv().await else { return; }; // Drain events within debounce window. tokio::time::sleep(DEBOUNCE).await; while rx.try_recv().is_ok() {} info!( project, "drift watcher: change detected — running scan+diff" ); run_scan_diff(&root, &project, ¬ifications).await; } } async fn run_scan_diff(root: &Path, project: &str, notifications: &Arc) { let ontoref_bin = root.join("ontoref"); if !ontoref_bin.exists() { debug!("drift watcher: ontoref binary not found, skipping scan+diff"); return; } // Phase 1: scan. let scan_ok = tokio::process::Command::new(&ontoref_bin) .args(["sync", "scan"]) .current_dir(root) .output() .await .map(|o| o.status.success()) .unwrap_or(false); if !scan_ok { warn!(project, "drift watcher: sync scan failed"); return; } // Phase 2: diff — capture stdout to detect drift. let diff_out = match tokio::process::Command::new(&ontoref_bin) .args(["sync", "diff"]) .current_dir(root) .output() .await { Ok(o) => o, Err(e) => { warn!(project, error = %e, "drift watcher: sync diff spawn failed"); return; } }; let stdout = String::from_utf8_lossy(&diff_out.stdout); let stderr = String::from_utf8_lossy(&diff_out.stderr); let combined = format!("{stdout}{stderr}"); let counts = DriftCounts::parse(&combined); if counts.is_clean() { debug!(project, "drift watcher: no drift detected"); return; } let summary = counts.summary(); info!(project, %summary, "drift watcher: ontology drift detected"); notifications.push_custom( project, "ontology_drift", format!("Ontology drift detected — {summary}"), Some(serde_json::json!({ "kind": "drift", "counts": { "missing": counts.missing, "stale": counts.stale, "drift": counts.drift, "broken": counts.broken, }, "hint": "Run `./ontoref sync-ontology` to review and apply patches.", })), Some("drift-watcher".to_string()), None, ); } struct DriftCounts { missing: usize, stale: usize, drift: usize, broken: usize, } impl DriftCounts { /// Parse diff output looking for MISSING / STALE / DRIFT / BROKEN markers. /// /// The format from `./ontoref sync diff` uses these keywords as line /// prefixes or inline labels. We count distinct occurrences. fn parse(output: &str) -> Self { let missing = count_marker(output, "MISSING"); let stale = count_marker(output, "STALE"); let drift = count_marker(output, "DRIFT"); let broken = count_marker(output, "BROKEN"); Self { missing, stale, drift, broken, } } fn is_clean(&self) -> bool { self.missing == 0 && self.stale == 0 && self.drift == 0 && self.broken == 0 } fn summary(&self) -> String { let mut parts = Vec::new(); if self.missing > 0 { parts.push(format!("{} MISSING", self.missing)); } if self.stale > 0 { parts.push(format!("{} STALE", self.stale)); } if self.drift > 0 { parts.push(format!("{} DRIFT", self.drift)); } if self.broken > 0 { parts.push(format!("{} BROKEN", self.broken)); } parts.join(", ") } } fn count_marker(output: &str, marker: &str) -> usize { output.lines().filter(|l| l.contains(marker)).count() }