use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Context;
use notify::{RecursiveMode, Watcher};
use notify_debouncer_full::{new_debouncer, DebounceEventResult};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub const DEFAULT_WATCH_PATHS: &[&str] = &["agents.yaml", "agents.d", "llm.yaml", "runtime.yaml"];
pub fn spawn_config_watcher(
config_dir: PathBuf,
extra_paths: Vec<String>,
debounce: Duration,
shutdown: CancellationToken,
) -> anyhow::Result<mpsc::Receiver<()>> {
let (tx, rx) = mpsc::channel::<()>(16);
let notify_tx = tx.clone();
tokio::task::spawn_blocking(move || {
let result = (|| -> anyhow::Result<()> {
let mut debouncer = new_debouncer(debounce, None, move |res: DebounceEventResult| {
match res {
Ok(events) if !events.is_empty() => {
let _ = notify_tx.try_send(());
}
Ok(_) => {}
Err(errs) => {
for e in errs {
tracing::warn!(error = %e, "config watcher error");
}
}
}
})
.context("spawn notify-debouncer-full")?;
let mut targets: Vec<PathBuf> = DEFAULT_WATCH_PATHS
.iter()
.map(|p| config_dir.join(p))
.collect();
for p in &extra_paths {
targets.push(config_dir.join(p));
}
let mut watched_any = false;
for target in &targets {
if !target.exists() {
tracing::debug!(
path = %target.display(),
"config watch target does not exist — skipping"
);
continue;
}
let mode = if target.is_dir() {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
match debouncer.watcher().watch(target, mode) {
Ok(_) => {
watched_any = true;
tracing::info!(path = %target.display(), mode = ?mode, "config watcher attached");
}
Err(e) => {
tracing::warn!(
path = %target.display(),
error = %e,
"config watcher failed to attach — skipping",
);
}
}
}
if !watched_any {
tracing::warn!(
config_dir = %config_dir.display(),
"config watcher has no live targets — auto reload disabled until the files appear and the process restarts"
);
}
loop {
if shutdown.is_cancelled() {
break;
}
std::thread::sleep(Duration::from_millis(200));
}
drop(debouncer);
Ok(())
})();
if let Err(e) = result {
tracing::warn!(error = %e, "config watcher terminated");
}
});
let _ = tx; Ok(rx)
}
pub fn planned_watch_paths(config_dir: &Path, extra: &[String]) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = DEFAULT_WATCH_PATHS
.iter()
.map(|p| config_dir.join(p))
.collect();
for p in extra {
out.push(config_dir.join(p));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn planned_paths_include_defaults_and_extras() {
let dir = tempfile::tempdir().unwrap();
let paths = planned_watch_paths(
dir.path(),
&["custom.yaml".to_string(), "nested/file.yaml".to_string()],
);
assert_eq!(paths.len(), DEFAULT_WATCH_PATHS.len() + 2);
assert!(paths.iter().any(|p| p.ends_with("agents.yaml")));
assert!(paths.iter().any(|p| p.ends_with("custom.yaml")));
assert!(paths.iter().any(|p| p.ends_with("nested/file.yaml")));
}
#[tokio::test]
async fn watcher_fires_on_file_write() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("agents.yaml"), "agents: []\n").unwrap();
let shutdown = CancellationToken::new();
let mut rx = spawn_config_watcher(
dir.path().to_path_buf(),
Vec::new(),
Duration::from_millis(100),
shutdown.clone(),
)
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
fs::write(dir.path().join("agents.yaml"), "agents: [{}]\n").unwrap();
let fired = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("watcher must fire within 2s");
assert_eq!(fired, Some(()));
shutdown.cancel();
}
}