Skip to main content

recursive/tools/
watch_file.rs

1//! `watch_file` tool — register a file for mid-run event wakes.
2//!
3//! The TUI loop arbiter polls the registered file and re-invokes the agent
4//! when new bytes are appended. This lets a supervising agent react to a
5//! long-running command's structured events (one JSON object per line)
6//! without burning a turn every tick — the agent only wakes when there is
7//! something new to read.
8//!
9//! The watched file must live inside the workspace sandbox (resolved via
10//! [`super::resolve_within`]). The watch state lives on the shared
11//! [`BackgroundJobManager`] so the arbiter — which already holds that
12//! manager — can poll it without a new shared slot.
13
14use async_trait::async_trait;
15use serde_json::{json, Value};
16use std::path::PathBuf;
17use std::sync::Arc;
18use tokio::sync::Mutex;
19
20use super::run_background::BackgroundJobManager;
21use super::{resolve_within, Tool};
22use crate::error::{Error, Result};
23use crate::llm::ToolSpec;
24
25/// The `watch_file` tool: register a file for event-driven wakes.
26pub struct WatchFile {
27    root: PathBuf,
28    manager: Arc<Mutex<BackgroundJobManager>>,
29}
30
31impl WatchFile {
32    pub fn new(root: impl Into<PathBuf>, manager: Arc<Mutex<BackgroundJobManager>>) -> Self {
33        Self {
34            root: root.into(),
35            manager,
36        }
37    }
38}
39
40#[async_trait]
41impl Tool for WatchFile {
42    fn spec(&self) -> ToolSpec {
43        ToolSpec {
44            name: "watch_file".into(),
45            description: "Register a file (e.g. a command's events log) for \
46                mid-run event wakes. The loop arbiter polls it and re-invokes \
47                you only when new bytes are appended. Pair with /loop supervise: \
48                after run_background, call watch_file on the command's events \
49                file so you wake on each event instead of polling on a timer."
50                .into(),
51            parameters: json!({
52                "type": "object",
53                "properties": {
54                    "path": {
55                        "type": "string",
56                        "description": "File to watch (relative to workspace root; must stay inside the workspace)"
57                    },
58                    "from_end": {
59                        "type": "boolean",
60                        "description": "If true (default), only wake on bytes appended AFTER registration. If false, wake immediately with the whole file content.",
61                        "default": true
62                    }
63                },
64                "required": ["path"]
65            }),
66        }
67    }
68
69    fn is_deferred(&self) -> bool {
70        true
71    }
72
73    async fn execute(&self, args: Value) -> Result<String> {
74        let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
75            name: "watch_file".into(),
76            message: "missing `path`".into(),
77        })?;
78        let resolved = resolve_within(&self.root, path).map_err(|e| Error::BadToolArgs {
79            name: "watch_file".into(),
80            message: format!("path: {e}"),
81        })?;
82        let from_end = args
83            .get("from_end")
84            .and_then(|v| v.as_bool())
85            .unwrap_or(true);
86
87        // Snapshot the current file size (0 if not yet created) so from_end
88        // starts past existing content.
89        let size = std::fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0);
90        let offset = if from_end { size } else { 0 };
91
92        let mut manager = self.manager.lock().await;
93        manager.set_watch(resolved, offset);
94        Ok(json!({
95            "watching": path,
96            "from_end": from_end,
97            "initial_offset": offset,
98            "message": format!(
99                "Watching `{path}`. The loop arbiter will wake you when new bytes are appended (offset {offset})."
100            )
101        })
102        .to_string())
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::tools::run_background::WATCH_CHUNK_BYTES;
110    use std::io::Write;
111    use tempfile::TempDir;
112
113    fn mgr() -> Arc<Mutex<BackgroundJobManager>> {
114        Arc::new(Mutex::new(BackgroundJobManager::new()))
115    }
116
117    #[tokio::test]
118    async fn watch_file_registers_watch_from_end() {
119        let tmp = TempDir::new().unwrap();
120        let log = tmp.path().join("events.log");
121        std::fs::write(&log, "existing\n").unwrap();
122        let tool = WatchFile::new(tmp.path(), mgr());
123        let out = tool.execute(json!({"path": "events.log"})).await.unwrap();
124        let v: Value = serde_json::from_str(&out).unwrap();
125        // from_end=true → initial offset = existing file size, so existing
126        // content does NOT wake the agent. "existing\n" is 9 bytes.
127        assert_eq!(v["initial_offset"], 9);
128        assert_eq!(v["from_end"], true);
129    }
130
131    #[tokio::test]
132    async fn watch_file_from_end_false_starts_at_zero() {
133        let tmp = TempDir::new().unwrap();
134        let log = tmp.path().join("events.log");
135        std::fs::write(&log, "existing\n").unwrap();
136        let tool = WatchFile::new(tmp.path(), mgr());
137        let out = tool
138            .execute(json!({"path": "events.log", "from_end": false}))
139            .await
140            .unwrap();
141        let v: Value = serde_json::from_str(&out).unwrap();
142        assert_eq!(v["initial_offset"], 0);
143    }
144
145    #[tokio::test]
146    async fn watch_file_rejects_path_outside_workspace() {
147        let tmp = TempDir::new().unwrap();
148        let tool = WatchFile::new(tmp.path(), mgr());
149        let err = tool
150            .execute(json!({"path": "../../etc/hosts"}))
151            .await
152            .unwrap_err();
153        assert!(matches!(err, Error::BadToolArgs { .. }));
154    }
155
156    #[tokio::test]
157    async fn watch_file_missing_path_errors() {
158        let tmp = TempDir::new().unwrap();
159        let tool = WatchFile::new(tmp.path(), mgr());
160        let err = tool.execute(json!({})).await.unwrap_err();
161        assert!(matches!(err, Error::BadToolArgs { .. }));
162    }
163
164    #[tokio::test]
165    async fn poll_watch_returns_new_bytes_and_advances_offset() {
166        let mut m = BackgroundJobManager::new();
167        let tmp = TempDir::new().unwrap();
168        let log = tmp.path().join("events.log");
169        std::fs::write(&log, "old\n").unwrap();
170        m.set_watch(log.clone(), 4); // past "old\n"
171                                     // No new bytes yet.
172        assert!(m.poll_watch().is_none());
173        // Append new bytes.
174        let mut f = std::fs::OpenOptions::new().append(true).open(&log).unwrap();
175        writeln!(f, "new1").unwrap();
176        writeln!(f, "new2").unwrap();
177        let first = m.poll_watch().expect("new bytes wake");
178        assert!(first.contains("new1"));
179        assert!(first.contains("new2"));
180        // Offset advanced → no more new bytes.
181        assert!(m.poll_watch().is_none());
182    }
183
184    #[tokio::test]
185    async fn poll_watch_none_when_no_watch_set() {
186        let mut m = BackgroundJobManager::new();
187        assert!(m.poll_watch().is_none());
188    }
189
190    #[tokio::test]
191    async fn poll_watch_handles_truncation_by_resetting() {
192        let mut m = BackgroundJobManager::new();
193        let tmp = TempDir::new().unwrap();
194        let log = tmp.path().join("events.log");
195        std::fs::write(&log, "0123456789").unwrap();
196        m.set_watch(log.clone(), 10); // at EOF
197                                      // Truncate + rewrite smaller (log rotation).
198        std::fs::write(&log, "abc").unwrap();
199        let got = m.poll_watch().expect("truncation should reset and read");
200        assert!(got.contains("abc"));
201    }
202
203    #[tokio::test]
204    async fn poll_watch_caps_chunk_size() {
205        let mut m = BackgroundJobManager::new();
206        let tmp = TempDir::new().unwrap();
207        let log = tmp.path().join("events.log");
208        // Write more than WATCH_CHUNK_BYTES.
209        let big = "x".repeat(WATCH_CHUNK_BYTES + 1000);
210        std::fs::write(&log, &big).unwrap();
211        m.set_watch(log.clone(), 0);
212        let first = m.poll_watch().expect("first chunk");
213        assert_eq!(first.len(), WATCH_CHUNK_BYTES);
214        // Second poll returns the remainder.
215        let second = m.poll_watch().expect("second chunk");
216        assert_eq!(second.len(), 1000);
217        assert!(m.poll_watch().is_none());
218    }
219
220    #[tokio::test]
221    async fn clear_watch_removes_watch() {
222        let mut m = BackgroundJobManager::new();
223        let tmp = TempDir::new().unwrap();
224        let log = tmp.path().join("events.log");
225        std::fs::write(&log, "x").unwrap();
226        m.set_watch(log, 0);
227        m.clear_watch();
228        assert!(m.poll_watch().is_none());
229    }
230
231    #[tokio::test]
232    async fn set_watch_replaces_prior_watch() {
233        let mut m = BackgroundJobManager::new();
234        let tmp = TempDir::new().unwrap();
235        let a = tmp.path().join("a.log");
236        let b = tmp.path().join("b.log");
237        std::fs::write(&a, "a-content").unwrap();
238        std::fs::write(&b, "b-content").unwrap();
239        m.set_watch(a.clone(), 0);
240        m.set_watch(b.clone(), 0);
241        // Only b is watched now.
242        std::fs::write(&a, "a-content-more").unwrap();
243        // a should not be polled (replaced). poll_watch reads b.
244        let got = m.poll_watch().expect("b has content from offset 0");
245        assert!(got.contains("b-content"));
246    }
247}