Skip to main content

agent_engine/events/
ingest.rs

1//! Inbox watcher — uses inotify (via the `notify` crate) to instantly react
2//! to dropped event JSON files in the inbox directory. Falls back to polling.
3
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use super::queue::EventQueue;
8use super::types::Event;
9use notify::Watcher;
10
11async fn process_file(path: &Path, queue: &EventQueue) {
12    #[cfg(unix)]
13    {
14        if let Ok(meta) = tokio::fs::symlink_metadata(path).await {
15            if meta.file_type().is_symlink() {
16                tracing::warn!("refusing symlink in inbox: {}", path.display());
17                let _ = tokio::fs::remove_file(path).await;
18                return;
19            }
20        }
21    }
22    if path.extension().is_some_and(|e| e == "json") {
23        if let Ok(meta) = tokio::fs::metadata(path).await {
24            if meta.len() > 256 * 1024 {
25                tracing::warn!(
26                    "inbox file too large ({}B), skipping: {}",
27                    meta.len(),
28                    path.display()
29                );
30                let _ = tokio::fs::rename(path, path.with_extension("json.oversized")).await;
31                return;
32            }
33        }
34        match tokio::fs::read_to_string(path).await {
35            Ok(content) => match serde_json::from_str::<Event>(&content) {
36                Ok(event) => {
37                    tracing::info!(
38                        "Inbox event: {} from {}",
39                        event.id,
40                        event.source.source_type
41                    );
42                    match queue.push(event) {
43                        Ok(()) => {
44                            let _ = tokio::fs::remove_file(path).await;
45                        }
46                        Err(e) => {
47                            tracing::warn!("Queue full, retry later: {}", e);
48                        }
49                    }
50                }
51                Err(e) => {
52                    tracing::warn!("Invalid event {}: {}", path.display(), e);
53                    let _ = tokio::fs::rename(path, path.with_extension("json.error")).await;
54                }
55            },
56            Err(e) => tracing::warn!("Read failed {}: {}", path.display(), e),
57        }
58    }
59}
60
61async fn scan_inbox(dir: &Path, queue: &EventQueue) {
62    if let Ok(mut entries) = tokio::fs::read_dir(dir).await {
63        while let Ok(Some(entry)) = entries.next_entry().await {
64            process_file(&entry.path(), queue).await;
65        }
66    }
67}
68
69pub async fn watch_inbox(
70    inbox_dir: PathBuf,
71    queue: Arc<EventQueue>,
72    shutdown: Arc<std::sync::atomic::AtomicBool>,
73) {
74    let _ = tokio::fs::create_dir_all(&inbox_dir).await;
75    #[cfg(unix)]
76    {
77        use std::os::unix::fs::PermissionsExt;
78        if let Ok(meta) = tokio::fs::metadata(&inbox_dir).await {
79            let mut perms = meta.permissions();
80            perms.set_mode(0o700);
81            let _ = tokio::fs::set_permissions(&inbox_dir, perms).await;
82        }
83    }
84    scan_inbox(&inbox_dir, &queue).await;
85
86    // Use a tokio channel so the async runtime isn't blocked
87    let (async_tx, mut async_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<PathBuf>>();
88
89    // Shutdown signal passed in from caller
90    let shutdown_flag = shutdown.clone();
91
92    // Spawn the blocking notify watcher on a dedicated thread
93    let inbox_clone = inbox_dir.clone();
94    let _watcher_handle = tokio::task::spawn_blocking(move || {
95        let (tx, rx) = std::sync::mpsc::channel();
96        let mut _watcher: notify::RecommendedWatcher =
97            match notify::RecommendedWatcher::new(tx, notify::Config::default()) {
98                Ok(w) => w,
99                Err(e) => {
100                    tracing::warn!("inotify unavailable: {}", e);
101                    return;
102                }
103            };
104
105        if let Err(e) = _watcher.watch(&inbox_clone, notify::RecursiveMode::NonRecursive) {
106            tracing::warn!("watch failed: {}", e);
107            return;
108        }
109        tracing::info!("Inbox watcher (inotify) on {}", inbox_clone.display());
110
111        // Blocking loop — checks shutdown flag every 500ms
112        loop {
113            if shutdown_flag.load(std::sync::atomic::Ordering::Relaxed) {
114                break;
115            }
116            match rx.recv_timeout(std::time::Duration::from_millis(500)) {
117                Ok(Ok(event)) => {
118                    if !event.paths.is_empty() {
119                        let _ = async_tx.send(event.paths);
120                    }
121                }
122                Ok(Err(e)) => tracing::warn!("notify error: {}", e),
123                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
124                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
125                    break;
126                }
127            }
128        }
129    });
130
131    // Async loop — receives paths from the blocking watcher thread
132    let queue_ref = &queue;
133    let dir_ref = &inbox_dir;
134    loop {
135        if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
136            break;
137        }
138        tokio::select! {
139            result = async_rx.recv() => {
140                match result {
141                    Some(paths) => {
142                        for path in &paths {
143                            process_file(path, queue_ref).await;
144                        }
145                        // Sweep for any files inotify missed in the batch
146                        scan_inbox(dir_ref, queue_ref).await;
147                    }
148                    None => break, // Watcher thread exited, channel closed
149                }
150            }
151            // Safety scan every 2s in case inotify misses something
152            _ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {
153                if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
154                    break;
155                }
156                scan_inbox(dir_ref, queue_ref).await;
157            }
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::events::{Event, Severity};
166
167    #[tokio::test]
168    async fn picks_up_dropped_event() {
169        let dir = tempfile::tempdir().unwrap();
170        let inbox = dir.path().to_path_buf();
171        let queue = Arc::new(EventQueue::new(10));
172        let q = queue.clone();
173        let ibx = inbox.clone();
174        let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
175        let shutdown_for_task = shutdown.clone();
176        let handle = tokio::spawn(async move { watch_inbox(ibx, q, shutdown_for_task).await });
177
178        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
179        let event = Event::simple("test", "hello inbox", Some(Severity::High));
180        let path = inbox.join("1.json");
181        tokio::fs::write(&path, serde_json::to_string(&event).unwrap())
182            .await
183            .unwrap();
184
185        for _ in 0..30 {
186            if !queue.is_empty() {
187                break;
188            }
189            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
190        }
191        shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
192        let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
193        let popped = queue.pop().expect("event should have been ingested");
194        assert_eq!(popped.content.text, "hello inbox");
195    }
196
197    #[tokio::test]
198    async fn invalid_json_moved_to_error() {
199        let dir = tempfile::tempdir().unwrap();
200        let inbox = dir.path().to_path_buf();
201        let queue = Arc::new(EventQueue::new(10));
202        let q = queue.clone();
203        let ibx = inbox.clone();
204        let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
205        let shutdown_for_task = shutdown.clone();
206        let handle = tokio::spawn(async move { watch_inbox(ibx, q, shutdown_for_task).await });
207
208        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
209        let path = inbox.join("bad.json");
210        tokio::fs::write(&path, "not json").await.unwrap();
211        let err_path = inbox.join("bad.json.error");
212        for _ in 0..30 {
213            if err_path.exists() {
214                break;
215            }
216            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
217        }
218        shutdown.store(true, std::sync::atomic::Ordering::Relaxed);
219        let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
220        assert!(err_path.exists());
221        assert_eq!(queue.len(), 0);
222    }
223}