deepstrike_sdk/runtime/skill_watcher.rs
1use std::path::Path;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::Arc;
4
5use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
6
7/// Background watcher for a skill directory.
8///
9/// Uses the OS-native FS notification API (kqueue on macOS, inotify on Linux).
10/// Callers snapshot `version()` at the start of each SM iteration and re-scan
11/// the directory whenever the value has changed — no polling thread needed.
12pub struct SkillWatcher {
13 // Keep the watcher alive for the lifetime of this struct; dropping it
14 // unregisters the OS watch.
15 _watcher: RecommendedWatcher,
16 version: Arc<AtomicU64>,
17}
18
19impl SkillWatcher {
20 /// Start watching `dir`. Returns `None` if the path doesn't exist or the
21 /// OS watcher cannot be created (e.g. unavailable on this platform).
22 pub fn start(dir: &Path) -> Option<Self> {
23 let version = Arc::new(AtomicU64::new(0));
24 let version2 = Arc::clone(&version);
25
26 let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
27 let Ok(event) = res else { return };
28 let is_relevant = matches!(
29 event.kind,
30 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
31 ) && event.paths.iter().any(|p| {
32 matches!(
33 p.extension().and_then(|e| e.to_str()),
34 Some("md") | Some("json") | Some("py")
35 )
36 });
37 if is_relevant {
38 version2.fetch_add(1, Ordering::Relaxed);
39 }
40 })
41 .ok()?;
42
43 watcher.watch(dir, RecursiveMode::NonRecursive).ok()?;
44
45 Some(Self {
46 _watcher: watcher,
47 version,
48 })
49 }
50
51 /// Monotonically-increasing counter; increments whenever a `.md`, `.json`,
52 /// or `.py` file in the watched directory is created, modified, or removed.
53 ///
54 /// Callers should snapshot this at the start of each loop iteration and
55 /// compare against the previous snapshot; a change means the skill catalog
56 /// should be refreshed.
57 pub fn version(&self) -> u64 {
58 self.version.load(Ordering::Relaxed)
59 }
60}