219 lines
6.6 KiB
Rust
219 lines
6.6 KiB
Rust
//! 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<NotificationStore>,
|
|
) -> Result<Self, crate::error::DaemonError> {
|
|
let root = project_root.to_path_buf();
|
|
let (tx, rx) = mpsc::channel::<PathBuf>(64);
|
|
|
|
let tx2 = tx.clone();
|
|
let mut watcher = RecommendedWatcher::new(
|
|
move |res: std::result::Result<Event, notify::Error>| 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<PathBuf>,
|
|
root: PathBuf,
|
|
project: String,
|
|
notifications: Arc<NotificationStore>,
|
|
) {
|
|
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<NotificationStore>) {
|
|
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()
|
|
}
|