Skip to main content

clawft_core/agent/
skill_watcher.rs

1//! File-system watcher for automatic skill hot-reload.
2//!
3//! Watches skill directories for changes and triggers [`SkillRegistry`] rebuilds
4//! with debouncing to avoid redundant work during rapid file operations.
5//!
6//! # Architecture
7//!
8//! The watcher runs as a background `tokio::spawn` task that:
9//! 1. Receives events from the [`notify`] crate via an `mpsc` channel.
10//! 2. Debounces rapid changes (default: 500ms).
11//! 3. Acquires a write lock on [`SharedSkillRegistry`] only during rebuild.
12//! 4. Releases the write lock immediately after rebuild completes.
13//!
14//! # Concurrency
15//!
16//! - Reads: Every agent loop iteration reads skills (high frequency).
17//! - Writes: Only on file-system changes (low frequency).
18//! - [`tokio::sync::RwLock`] is appropriate: many concurrent readers, rare
19//!   exclusive writers. Write starvation is not a concern given low write
20//!   frequency.
21
22use std::path::PathBuf;
23use std::time::Duration;
24
25use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
26use tokio::sync::mpsc;
27use tracing::{debug, info, warn};
28
29use clawft_types::skill::SkillDefinition;
30
31use super::skills_v2::SharedSkillRegistry;
32
33/// Configuration for the skill file-system watcher.
34#[derive(Debug, Clone)]
35pub struct SkillWatcherConfig {
36    /// Workspace skill directory (highest priority).
37    pub workspace_dir: Option<PathBuf>,
38    /// User skill directory (medium priority).
39    pub user_dir: Option<PathBuf>,
40    /// Additional directories to watch (e.g. plugin-shipped skill dirs).
41    pub extra_dirs: Vec<PathBuf>,
42    /// Debounce duration for rapid file changes.
43    pub debounce: Duration,
44    /// Built-in skills (lowest priority, compiled into binary).
45    pub builtin_skills: Vec<SkillDefinition>,
46    /// Whether workspace skills are trusted.
47    pub trust_workspace: bool,
48}
49
50impl Default for SkillWatcherConfig {
51    fn default() -> Self {
52        Self {
53            workspace_dir: None,
54            user_dir: None,
55            extra_dirs: Vec::new(),
56            debounce: Duration::from_millis(500),
57            builtin_skills: Vec::new(),
58            trust_workspace: false,
59        }
60    }
61}
62
63impl SkillWatcherConfig {
64    /// Collect all directories that should be watched.
65    fn watch_dirs(&self) -> Vec<&PathBuf> {
66        let mut dirs = Vec::new();
67        if let Some(ref d) = self.workspace_dir {
68            dirs.push(d);
69        }
70        if let Some(ref d) = self.user_dir {
71            dirs.push(d);
72        }
73        for d in &self.extra_dirs {
74            dirs.push(d);
75        }
76        dirs
77    }
78}
79
80/// Handle to a running skill watcher. Drop to stop watching.
81pub struct SkillWatcherHandle {
82    /// Sends a shutdown signal to the watcher task.
83    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
84}
85
86impl SkillWatcherHandle {
87    /// Stop the watcher gracefully.
88    pub fn stop(mut self) {
89        if let Some(tx) = self.shutdown_tx.take() {
90            let _ = tx.send(());
91        }
92    }
93}
94
95impl Drop for SkillWatcherHandle {
96    fn drop(&mut self) {
97        if let Some(tx) = self.shutdown_tx.take() {
98            let _ = tx.send(());
99        }
100    }
101}
102
103/// Start watching skill directories for changes.
104///
105/// Returns a [`SkillWatcherHandle`] that can be used to stop watching.
106/// The watcher runs in a background tokio task and will automatically
107/// rebuild the registry when files change.
108///
109/// # Errors
110///
111/// Returns an error if the file-system watcher cannot be created.
112pub fn start_watching(
113    config: SkillWatcherConfig,
114    registry: SharedSkillRegistry,
115) -> Result<SkillWatcherHandle, notify::Error> {
116    let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
117    let (event_tx, mut event_rx) = mpsc::channel::<Event>(100);
118
119    // Create OS file watcher.
120    let mut watcher = RecommendedWatcher::new(
121        move |res: notify::Result<Event>| {
122            if let Ok(event) = res {
123                let _ = event_tx.blocking_send(event);
124            }
125        },
126        notify::Config::default(),
127    )?;
128
129    // Watch all configured directories that exist.
130    for dir in config.watch_dirs() {
131        if dir.exists() {
132            if let Err(e) = watcher.watch(dir, RecursiveMode::Recursive) {
133                warn!(
134                    path = %dir.display(),
135                    error = %e,
136                    "failed to watch skill directory"
137                );
138            } else {
139                debug!(path = %dir.display(), "watching skill directory");
140            }
141        } else {
142            debug!(path = %dir.display(), "skill directory does not exist, skipping watch");
143        }
144    }
145
146    let debounce = config.debounce;
147    let ws_dir = config.workspace_dir.clone();
148    let user_dir = config.user_dir.clone();
149    let builtins = config.builtin_skills.clone();
150    let trust = config.trust_workspace;
151
152    // Spawn the event processing loop.
153    tokio::spawn(async move {
154        // Keep the watcher alive for the duration of the task.
155        let _watcher = watcher;
156
157        let mut pending_rebuild = false;
158        let mut debounce_deadline: Option<tokio::time::Instant> = None;
159
160        loop {
161            tokio::select! {
162                // Receive file-system events.
163                event = event_rx.recv() => {
164                    match event {
165                        Some(ev) => {
166                            match ev.kind {
167                                EventKind::Create(_)
168                                | EventKind::Modify(_)
169                                | EventKind::Remove(_) => {
170                                    pending_rebuild = true;
171                                    debounce_deadline = Some(
172                                        tokio::time::Instant::now() + debounce,
173                                    );
174                                }
175                                _ => {}
176                            }
177                        }
178                        None => {
179                            // Channel closed, exit.
180                            break;
181                        }
182                    }
183                }
184                // Debounce timer fires.
185                _ = async {
186                    match debounce_deadline {
187                        Some(deadline) => tokio::time::sleep_until(deadline).await,
188                        None => std::future::pending::<()>().await,
189                    }
190                }, if pending_rebuild => {
191                    pending_rebuild = false;
192                    debounce_deadline = None;
193
194                    let mut reg = registry.write().await;
195                    match reg.rebuild(
196                        ws_dir.as_deref(),
197                        user_dir.as_deref(),
198                        builtins.clone(),
199                        trust,
200                    ).await {
201                        Ok(()) => {
202                            info!(
203                                count = reg.len(),
204                                "skill registry reloaded after file change"
205                            );
206                        }
207                        Err(e) => {
208                            warn!(error = %e, "skill registry rebuild failed");
209                        }
210                    }
211                }
212                // Shutdown signal.
213                _ = &mut shutdown_rx => {
214                    info!("skill watcher shutting down");
215                    break;
216                }
217            }
218        }
219    });
220
221    Ok(SkillWatcherHandle {
222        shutdown_tx: Some(shutdown_tx),
223    })
224}
225
226// ---------------------------------------------------------------------------
227// Tests
228// ---------------------------------------------------------------------------
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::sync::atomic::{AtomicU64, Ordering};
234    use std::sync::Arc;
235
236    use tokio::sync::RwLock;
237
238    use super::super::skills_v2::SkillRegistry;
239    use clawft_types::skill::SkillDefinition;
240
241    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
242
243    fn temp_dir(prefix: &str) -> PathBuf {
244        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
245        let pid = std::process::id();
246        std::env::temp_dir().join(format!("clawft_watcher_{prefix}_{pid}_{id}"))
247    }
248
249    fn create_skill_md(dir: &std::path::Path, name: &str, desc: &str) {
250        let skill_dir = dir.join(name);
251        std::fs::create_dir_all(&skill_dir).unwrap();
252        let content =
253            format!("---\nname: {name}\ndescription: {desc}\n---\n\nInstructions for {name}.");
254        std::fs::write(skill_dir.join("SKILL.md"), content).unwrap();
255    }
256
257    #[tokio::test]
258    async fn test_watcher_config_defaults() {
259        let config = SkillWatcherConfig::default();
260        assert_eq!(config.debounce, Duration::from_millis(500));
261        assert!(config.workspace_dir.is_none());
262        assert!(config.user_dir.is_none());
263        assert!(config.extra_dirs.is_empty());
264        assert!(config.builtin_skills.is_empty());
265        assert!(!config.trust_workspace);
266    }
267
268    #[tokio::test]
269    async fn test_watcher_config_watch_dirs() {
270        let config = SkillWatcherConfig {
271            workspace_dir: Some(PathBuf::from("/ws/skills")),
272            user_dir: Some(PathBuf::from("/user/skills")),
273            extra_dirs: vec![PathBuf::from("/extra/skills")],
274            ..Default::default()
275        };
276        assert_eq!(config.watch_dirs().len(), 3);
277    }
278
279    #[tokio::test]
280    async fn test_watcher_handle_drop_sends_shutdown() {
281        let dir = temp_dir("handle_drop");
282        std::fs::create_dir_all(&dir).unwrap();
283        create_skill_md(&dir, "drop_test", "Drop test skill");
284
285        let registry = Arc::new(RwLock::new(
286            SkillRegistry::discover(Some(&dir), None, vec![])
287                .await
288                .unwrap(),
289        ));
290
291        let config = SkillWatcherConfig {
292            workspace_dir: Some(dir.clone()),
293            trust_workspace: true,
294            debounce: Duration::from_millis(50),
295            ..Default::default()
296        };
297
298        let handle = start_watching(config, registry).unwrap();
299        // Dropping the handle should send shutdown
300        drop(handle);
301
302        // Give the task a moment to receive the signal
303        tokio::time::sleep(Duration::from_millis(100)).await;
304
305        let _ = std::fs::remove_dir_all(&dir);
306    }
307
308    #[tokio::test]
309    async fn test_watcher_detects_new_skill() {
310        let dir = temp_dir("detect_new");
311        std::fs::create_dir_all(&dir).unwrap();
312        create_skill_md(&dir, "original", "Original skill");
313
314        let registry = Arc::new(RwLock::new(
315            SkillRegistry::discover(Some(&dir), None, vec![])
316                .await
317                .unwrap(),
318        ));
319
320        {
321            let reg = registry.read().await;
322            assert_eq!(reg.len(), 1);
323            assert!(reg.get("original").is_some());
324        }
325
326        let config = SkillWatcherConfig {
327            workspace_dir: Some(dir.clone()),
328            trust_workspace: true,
329            debounce: Duration::from_millis(100),
330            ..Default::default()
331        };
332
333        let handle = start_watching(config, registry.clone()).unwrap();
334
335        // Add a new skill
336        create_skill_md(&dir, "added", "Added skill");
337
338        // Wait for debounce + rebuild
339        tokio::time::sleep(Duration::from_millis(500)).await;
340
341        {
342            let reg = registry.read().await;
343            assert_eq!(reg.len(), 2);
344            assert!(reg.get("original").is_some());
345            assert!(reg.get("added").is_some());
346        }
347
348        handle.stop();
349        let _ = std::fs::remove_dir_all(&dir);
350    }
351
352    #[tokio::test]
353    async fn test_watcher_detects_skill_removal() {
354        let dir = temp_dir("detect_remove");
355        std::fs::create_dir_all(&dir).unwrap();
356        create_skill_md(&dir, "alpha", "Alpha");
357        create_skill_md(&dir, "beta", "Beta");
358
359        let registry = Arc::new(RwLock::new(
360            SkillRegistry::discover(Some(&dir), None, vec![])
361                .await
362                .unwrap(),
363        ));
364
365        {
366            let reg = registry.read().await;
367            assert_eq!(reg.len(), 2);
368        }
369
370        let config = SkillWatcherConfig {
371            workspace_dir: Some(dir.clone()),
372            trust_workspace: true,
373            debounce: Duration::from_millis(100),
374            ..Default::default()
375        };
376
377        let handle = start_watching(config, registry.clone()).unwrap();
378
379        // Remove one skill
380        std::fs::remove_dir_all(dir.join("beta")).unwrap();
381
382        // Wait for debounce + rebuild
383        tokio::time::sleep(Duration::from_millis(500)).await;
384
385        {
386            let reg = registry.read().await;
387            assert_eq!(reg.len(), 1);
388            assert!(reg.get("alpha").is_some());
389            assert!(reg.get("beta").is_none());
390        }
391
392        handle.stop();
393        let _ = std::fs::remove_dir_all(&dir);
394    }
395
396    #[tokio::test]
397    async fn test_watcher_detects_skill_modification() {
398        let dir = temp_dir("detect_modify");
399        std::fs::create_dir_all(&dir).unwrap();
400        create_skill_md(&dir, "mutable", "Original description");
401
402        let registry = Arc::new(RwLock::new(
403            SkillRegistry::discover(Some(&dir), None, vec![])
404                .await
405                .unwrap(),
406        ));
407
408        {
409            let reg = registry.read().await;
410            assert_eq!(reg.get("mutable").unwrap().description, "Original description");
411        }
412
413        let config = SkillWatcherConfig {
414            workspace_dir: Some(dir.clone()),
415            trust_workspace: true,
416            debounce: Duration::from_millis(100),
417            ..Default::default()
418        };
419
420        let handle = start_watching(config, registry.clone()).unwrap();
421
422        // Modify the skill
423        let modified =
424            "---\nname: mutable\ndescription: Updated description\n---\n\nUpdated instructions.";
425        std::fs::write(dir.join("mutable").join("SKILL.md"), modified).unwrap();
426
427        // Wait for debounce + rebuild
428        tokio::time::sleep(Duration::from_millis(500)).await;
429
430        {
431            let reg = registry.read().await;
432            assert_eq!(reg.get("mutable").unwrap().description, "Updated description");
433        }
434
435        handle.stop();
436        let _ = std::fs::remove_dir_all(&dir);
437    }
438
439    #[tokio::test]
440    async fn test_watcher_missing_dir_does_not_error() {
441        let registry = Arc::new(RwLock::new(
442            SkillRegistry::discover(None, None, vec![]).await.unwrap(),
443        ));
444
445        let config = SkillWatcherConfig {
446            workspace_dir: Some(PathBuf::from("/definitely/does/not/exist")),
447            debounce: Duration::from_millis(50),
448            ..Default::default()
449        };
450
451        // Should not panic or error
452        let handle = start_watching(config, registry);
453        assert!(handle.is_ok());
454        handle.unwrap().stop();
455    }
456
457    #[tokio::test]
458    async fn test_registry_upsert_and_remove() {
459        let mut registry = SkillRegistry::discover(None, None, vec![]).await.unwrap();
460        assert!(registry.is_empty());
461
462        // Upsert a skill
463        let skill = SkillDefinition::new("dynamic", "A dynamically added skill");
464        let prev = registry.upsert(skill);
465        assert!(prev.is_none());
466        assert_eq!(registry.len(), 1);
467        assert!(registry.get("dynamic").is_some());
468
469        // Upsert again (replace)
470        let updated = SkillDefinition::new("dynamic", "Updated description");
471        let prev = registry.upsert(updated);
472        assert!(prev.is_some());
473        assert_eq!(prev.unwrap().description, "A dynamically added skill");
474        assert_eq!(registry.get("dynamic").unwrap().description, "Updated description");
475
476        // Remove
477        let removed = registry.remove("dynamic");
478        assert!(removed.is_some());
479        assert!(registry.is_empty());
480
481        // Remove nonexistent
482        let removed = registry.remove("nonexistent");
483        assert!(removed.is_none());
484    }
485
486    #[tokio::test]
487    async fn test_registry_rebuild() {
488        let dir = temp_dir("rebuild");
489        std::fs::create_dir_all(&dir).unwrap();
490        create_skill_md(&dir, "first", "First skill");
491
492        let mut registry = SkillRegistry::discover(Some(&dir), None, vec![]).await.unwrap();
493        assert_eq!(registry.len(), 1);
494
495        // Add another skill to disk
496        create_skill_md(&dir, "second", "Second skill");
497
498        // Rebuild
499        registry.rebuild(Some(&dir), None, vec![], true).await.unwrap();
500        assert_eq!(registry.len(), 2);
501        assert!(registry.get("first").is_some());
502        assert!(registry.get("second").is_some());
503
504        let _ = std::fs::remove_dir_all(&dir);
505    }
506
507    #[tokio::test]
508    async fn test_precedence_on_reload() {
509        let ws_dir = temp_dir("prec_ws");
510        let user_dir = temp_dir("prec_user");
511
512        std::fs::create_dir_all(&ws_dir).unwrap();
513        std::fs::create_dir_all(&user_dir).unwrap();
514
515        // Both have a skill with the same name
516        create_skill_md(&ws_dir, "shared", "Workspace version");
517        create_skill_md(&user_dir, "shared", "User version");
518
519        let registry = Arc::new(RwLock::new(
520            SkillRegistry::discover(Some(&ws_dir), Some(&user_dir), vec![])
521                .await
522                .unwrap(),
523        ));
524
525        {
526            let reg = registry.read().await;
527            // Workspace takes priority
528            assert_eq!(reg.get("shared").unwrap().description, "Workspace version");
529        }
530
531        let config = SkillWatcherConfig {
532            workspace_dir: Some(ws_dir.clone()),
533            user_dir: Some(user_dir.clone()),
534            trust_workspace: true,
535            debounce: Duration::from_millis(100),
536            ..Default::default()
537        };
538
539        let handle = start_watching(config, registry.clone()).unwrap();
540
541        // Remove the workspace skill -- user version should be revealed
542        std::fs::remove_dir_all(ws_dir.join("shared")).unwrap();
543
544        tokio::time::sleep(Duration::from_millis(500)).await;
545
546        {
547            let reg = registry.read().await;
548            assert_eq!(reg.get("shared").unwrap().description, "User version");
549        }
550
551        handle.stop();
552        let _ = std::fs::remove_dir_all(&ws_dir);
553        let _ = std::fs::remove_dir_all(&user_dir);
554    }
555}