use async_trait::async_trait;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use super::run_background::BackgroundJobManager;
use super::{resolve_within, Tool};
use crate::error::{Error, Result};
use crate::llm::ToolSpec;
pub struct WatchFile {
root: PathBuf,
manager: Arc<Mutex<BackgroundJobManager>>,
}
impl WatchFile {
pub fn new(root: impl Into<PathBuf>, manager: Arc<Mutex<BackgroundJobManager>>) -> Self {
Self {
root: root.into(),
manager,
}
}
}
#[async_trait]
impl Tool for WatchFile {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "watch_file".into(),
description: "Register a file (e.g. a command's events log) for \
mid-run event wakes. The loop arbiter polls it and re-invokes \
you only when new bytes are appended. Pair with /loop supervise: \
after run_background, call watch_file on the command's events \
file so you wake on each event instead of polling on a timer."
.into(),
parameters: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File to watch (relative to workspace root; must stay inside the workspace)"
},
"from_end": {
"type": "boolean",
"description": "If true (default), only wake on bytes appended AFTER registration. If false, wake immediately with the whole file content.",
"default": true
}
},
"required": ["path"]
}),
}
}
fn is_deferred(&self) -> bool {
true
}
async fn execute(&self, args: Value) -> Result<String> {
let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
name: "watch_file".into(),
message: "missing `path`".into(),
})?;
let resolved = resolve_within(&self.root, path).map_err(|e| Error::BadToolArgs {
name: "watch_file".into(),
message: format!("path: {e}"),
})?;
let from_end = args
.get("from_end")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let size = std::fs::metadata(&resolved).map(|m| m.len()).unwrap_or(0);
let offset = if from_end { size } else { 0 };
let mut manager = self.manager.lock().await;
manager.set_watch(resolved, offset);
Ok(json!({
"watching": path,
"from_end": from_end,
"initial_offset": offset,
"message": format!(
"Watching `{path}`. The loop arbiter will wake you when new bytes are appended (offset {offset})."
)
})
.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::run_background::WATCH_CHUNK_BYTES;
use std::io::Write;
use tempfile::TempDir;
fn mgr() -> Arc<Mutex<BackgroundJobManager>> {
Arc::new(Mutex::new(BackgroundJobManager::new()))
}
#[tokio::test]
async fn watch_file_registers_watch_from_end() {
let tmp = TempDir::new().unwrap();
let log = tmp.path().join("events.log");
std::fs::write(&log, "existing\n").unwrap();
let tool = WatchFile::new(tmp.path(), mgr());
let out = tool.execute(json!({"path": "events.log"})).await.unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["initial_offset"], 9);
assert_eq!(v["from_end"], true);
}
#[tokio::test]
async fn watch_file_from_end_false_starts_at_zero() {
let tmp = TempDir::new().unwrap();
let log = tmp.path().join("events.log");
std::fs::write(&log, "existing\n").unwrap();
let tool = WatchFile::new(tmp.path(), mgr());
let out = tool
.execute(json!({"path": "events.log", "from_end": false}))
.await
.unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["initial_offset"], 0);
}
#[tokio::test]
async fn watch_file_rejects_path_outside_workspace() {
let tmp = TempDir::new().unwrap();
let tool = WatchFile::new(tmp.path(), mgr());
let err = tool
.execute(json!({"path": "../../etc/hosts"}))
.await
.unwrap_err();
assert!(matches!(err, Error::BadToolArgs { .. }));
}
#[tokio::test]
async fn watch_file_missing_path_errors() {
let tmp = TempDir::new().unwrap();
let tool = WatchFile::new(tmp.path(), mgr());
let err = tool.execute(json!({})).await.unwrap_err();
assert!(matches!(err, Error::BadToolArgs { .. }));
}
#[tokio::test]
async fn poll_watch_returns_new_bytes_and_advances_offset() {
let mut m = BackgroundJobManager::new();
let tmp = TempDir::new().unwrap();
let log = tmp.path().join("events.log");
std::fs::write(&log, "old\n").unwrap();
m.set_watch(log.clone(), 4); assert!(m.poll_watch().is_none());
let mut f = std::fs::OpenOptions::new().append(true).open(&log).unwrap();
writeln!(f, "new1").unwrap();
writeln!(f, "new2").unwrap();
let first = m.poll_watch().expect("new bytes wake");
assert!(first.contains("new1"));
assert!(first.contains("new2"));
assert!(m.poll_watch().is_none());
}
#[tokio::test]
async fn poll_watch_none_when_no_watch_set() {
let mut m = BackgroundJobManager::new();
assert!(m.poll_watch().is_none());
}
#[tokio::test]
async fn poll_watch_handles_truncation_by_resetting() {
let mut m = BackgroundJobManager::new();
let tmp = TempDir::new().unwrap();
let log = tmp.path().join("events.log");
std::fs::write(&log, "0123456789").unwrap();
m.set_watch(log.clone(), 10); std::fs::write(&log, "abc").unwrap();
let got = m.poll_watch().expect("truncation should reset and read");
assert!(got.contains("abc"));
}
#[tokio::test]
async fn poll_watch_caps_chunk_size() {
let mut m = BackgroundJobManager::new();
let tmp = TempDir::new().unwrap();
let log = tmp.path().join("events.log");
let big = "x".repeat(WATCH_CHUNK_BYTES + 1000);
std::fs::write(&log, &big).unwrap();
m.set_watch(log.clone(), 0);
let first = m.poll_watch().expect("first chunk");
assert_eq!(first.len(), WATCH_CHUNK_BYTES);
let second = m.poll_watch().expect("second chunk");
assert_eq!(second.len(), 1000);
assert!(m.poll_watch().is_none());
}
#[tokio::test]
async fn clear_watch_removes_watch() {
let mut m = BackgroundJobManager::new();
let tmp = TempDir::new().unwrap();
let log = tmp.path().join("events.log");
std::fs::write(&log, "x").unwrap();
m.set_watch(log, 0);
m.clear_watch();
assert!(m.poll_watch().is_none());
}
#[tokio::test]
async fn set_watch_replaces_prior_watch() {
let mut m = BackgroundJobManager::new();
let tmp = TempDir::new().unwrap();
let a = tmp.path().join("a.log");
let b = tmp.path().join("b.log");
std::fs::write(&a, "a-content").unwrap();
std::fs::write(&b, "b-content").unwrap();
m.set_watch(a.clone(), 0);
m.set_watch(b.clone(), 0);
std::fs::write(&a, "a-content-more").unwrap();
let got = m.poll_watch().expect("b has content from offset 0");
assert!(got.contains("b-content"));
}
}