Skip to main content

winx_code_agent/utils/
scratch_file.rs

1//! Offload the dropped head of an over-long PTY output to a scratch file.
2//!
3//! When a command's output grows past the in-memory cap the PTY keeps only the
4//! tail and drops the oldest bytes (see the truncation paths in `pty.rs`). That
5//! dropped head is the part the agent can no longer see. Instead of losing it,
6//! we append it to a file under `<workspace>/.winx/scratch/` so the agent can
7//! recover it with `ReadFiles` (or grep via `BashCommand`). The file lives inside the workspace on
8//! purpose: those read tools are workspace-confined and pipe through the
9//! redaction layer, so a leaked secret in the log is still scrubbed on the way
10//! back out.
11//!
12//! Everything here is best-effort: any IO error is logged at debug and swallowed
13//! so output offload can never crash the server (release builds are
14//! `panic = "abort"`).
15
16use std::fs::{self, OpenOptions};
17use std::io::Write;
18use std::path::{Path, PathBuf};
19use std::sync::{
20    atomic::{AtomicU64, Ordering},
21    Mutex,
22};
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24
25use tracing::debug;
26
27const SCRATCH_SUBDIR: &str = ".winx/scratch";
28
29/// Monotonic per-process counter folded into scratch filenames so two shells
30/// that ask for a path within the same nanosecond can't collide on the timestamp
31/// and interleave their output into one file.
32static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
33static SCRATCH_IO_LOCK: Mutex<()> = Mutex::new(());
34
35/// Scratch files older than this are pruned when a new one is created.
36const SCRATCH_MAX_AGE: Duration = Duration::from_secs(3600);
37/// Hard workspace-wide quota for Bash output scratch files.
38const SCRATCH_MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
39/// Make room for one command's full per-file allowance before assigning a path.
40const SCRATCH_FILE_RESERVE_BYTES: u64 = 50 * 1024 * 1024;
41
42fn scratch_dir(workspace_root: &Path) -> PathBuf {
43    workspace_root.join(SCRATCH_SUBDIR)
44}
45
46/// A fresh, unique scratch-file path under the workspace, creating the directory
47/// if needed and pruning stale files first so the dir does not grow without
48/// bound. Returns `None` on any IO error (offload is best-effort).
49pub fn new_scratch_path(workspace_root: &Path) -> Option<PathBuf> {
50    let _guard = SCRATCH_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
51    let dir = scratch_dir(workspace_root);
52    if let Err(e) = fs::create_dir_all(&dir) {
53        debug!("scratch: create_dir_all {} failed: {e}", dir.display());
54        return None;
55    }
56    prune_scratch_dir_unlocked(workspace_root, SCRATCH_MAX_AGE);
57    prune_scratch_to_size(&dir, SCRATCH_MAX_TOTAL_BYTES - SCRATCH_FILE_RESERVE_BYTES);
58    let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_nanos();
59    let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed);
60    Some(dir.join(format!("bash-output-{}-{nonce:x}-{seq:x}.txt", std::process::id())))
61}
62
63/// Append `bytes` to the scratch file, creating it if absent.
64pub fn append_scratch(path: &Path, bytes: &[u8]) -> std::io::Result<usize> {
65    let _guard = SCRATCH_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
66    let Some(dir) = path.parent() else {
67        return Ok(0);
68    };
69    let used = scratch_dir_size(dir);
70    let writable = allowed_append_len(used, bytes.len());
71    if writable == 0 {
72        return Ok(0);
73    }
74    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
75    file.write_all(&bytes[..writable])?;
76    Ok(writable)
77}
78
79fn allowed_append_len(used: u64, requested: usize) -> usize {
80    usize::try_from(SCRATCH_MAX_TOTAL_BYTES.saturating_sub(used))
81        .unwrap_or(usize::MAX)
82        .min(requested)
83}
84
85/// Remove scratch files whose mtime is older than `max_age`. Best-effort:
86/// per-entry failures are logged at debug and skipped, and a missing dir is a
87/// no-op.
88pub fn prune_scratch_dir(workspace_root: &Path, max_age: Duration) {
89    let _guard = SCRATCH_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
90    prune_scratch_dir_unlocked(workspace_root, max_age);
91}
92
93fn prune_scratch_dir_unlocked(workspace_root: &Path, max_age: Duration) {
94    let dir = scratch_dir(workspace_root);
95    let Ok(entries) = fs::read_dir(&dir) else { return };
96    let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
97    for entry in entries.flatten() {
98        let path = entry.path();
99        let stale =
100            fs::metadata(&path).and_then(|meta| meta.modified()).is_ok_and(|mtime| mtime < cutoff);
101        if stale {
102            if let Err(e) = fs::remove_file(&path) {
103                debug!("scratch: prune remove {} failed: {e}", path.display());
104            }
105        }
106    }
107}
108
109fn scratch_dir_size(dir: &Path) -> u64 {
110    let Ok(entries) = fs::read_dir(dir) else { return 0 };
111    entries
112        .flatten()
113        .filter_map(|entry| entry.metadata().ok())
114        .filter(std::fs::Metadata::is_file)
115        .fold(0_u64, |total, metadata| total.saturating_add(metadata.len()))
116}
117
118fn prune_scratch_to_size(dir: &Path, target_bytes: u64) {
119    let Ok(entries) = fs::read_dir(dir) else { return };
120    let mut files: Vec<_> = entries
121        .flatten()
122        .filter_map(|entry| {
123            let metadata = entry.metadata().ok()?;
124            metadata
125                .is_file()
126                .then(|| (entry.path(), metadata.modified().unwrap_or(UNIX_EPOCH), metadata.len()))
127        })
128        .collect();
129    let mut total = files.iter().fold(0_u64, |sum, (_, _, len)| sum.saturating_add(*len));
130    files.sort_by_key(|(_, modified, _)| *modified);
131    for (path, _, len) in files {
132        if total <= target_bytes {
133            break;
134        }
135        if fs::remove_file(&path).is_ok() {
136            total = total.saturating_sub(len);
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    #![allow(clippy::unwrap_used, clippy::expect_used)]
144    use super::*;
145    use std::thread::sleep;
146    use tempfile::TempDir;
147
148    #[test]
149    fn new_scratch_path_is_under_winx_scratch() {
150        let ws = TempDir::new().unwrap();
151        let path = new_scratch_path(ws.path()).expect("path");
152        assert!(
153            path.starts_with(ws.path().join(".winx/scratch")),
154            "path outside scratch: {path:?}"
155        );
156        assert!(path.parent().is_some_and(Path::exists), "scratch dir not created");
157    }
158
159    #[test]
160    fn new_scratch_path_is_unique_per_call() {
161        // Two calls (possibly within the same nanosecond) must not collide, or
162        // two shells would interleave output into one file.
163        let ws = TempDir::new().unwrap();
164        let a = new_scratch_path(ws.path()).unwrap();
165        let b = new_scratch_path(ws.path()).unwrap();
166        assert_ne!(a, b, "consecutive scratch paths must be unique");
167    }
168
169    #[test]
170    fn append_scratch_accumulates() {
171        let ws = TempDir::new().unwrap();
172        let path = new_scratch_path(ws.path()).unwrap();
173        append_scratch(&path, b"head-one\n").unwrap();
174        append_scratch(&path, b"head-two\n").unwrap();
175        assert_eq!(fs::read_to_string(&path).unwrap(), "head-one\nhead-two\n");
176    }
177
178    #[test]
179    fn prune_removes_stale_keeps_fresh() {
180        let ws = TempDir::new().unwrap();
181        let old = new_scratch_path(ws.path()).unwrap();
182        append_scratch(&old, b"old").unwrap();
183        sleep(Duration::from_millis(15));
184        // Prune anything older than 1ms: the 15ms-old file goes, a brand-new one stays.
185        prune_scratch_dir(ws.path(), Duration::from_millis(1));
186        assert!(!old.exists(), "stale scratch file should be pruned");
187        let fresh = new_scratch_path(ws.path()).unwrap();
188        append_scratch(&fresh, b"fresh").unwrap();
189        prune_scratch_dir(ws.path(), Duration::from_secs(3600));
190        assert!(fresh.exists(), "fresh scratch file must survive");
191    }
192
193    #[cfg(unix)] // relies on /proc refusing child directories
194    #[test]
195    fn new_scratch_path_returns_none_on_unwritable_root() {
196        // A workspace root that cannot hold a child dir must yield None, not panic.
197        let path = new_scratch_path(Path::new("/proc/winx-nonexistent-xyz"));
198        assert!(path.is_none(), "expected None for unwritable root");
199    }
200
201    #[test]
202    fn workspace_quota_caps_each_append() {
203        assert_eq!(allowed_append_len(SCRATCH_MAX_TOTAL_BYTES, 1024), 0);
204        assert_eq!(allowed_append_len(SCRATCH_MAX_TOTAL_BYTES - 7, 1024), 7);
205        assert_eq!(allowed_append_len(0, 1024), 1024);
206    }
207}