recursive/tools/
watch_file.rs1use 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
25pub 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 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 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); assert!(m.poll_watch().is_none());
173 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 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); 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 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 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 std::fs::write(&a, "a-content-more").unwrap();
243 let got = m.poll_watch().expect("b has content from offset 0");
245 assert!(got.contains("b-content"));
246 }
247}