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::atomic::{AtomicU64, Ordering};
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22use tracing::debug;
23
24const SCRATCH_SUBDIR: &str = ".winx/scratch";
25
26/// Monotonic per-process counter folded into scratch filenames so two shells
27/// that ask for a path within the same nanosecond can't collide on the timestamp
28/// and interleave their output into one file.
29static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
30
31/// Scratch files older than this are pruned when a new one is created.
32const SCRATCH_MAX_AGE: Duration = Duration::from_secs(3600);
33
34fn scratch_dir(workspace_root: &Path) -> PathBuf {
35    workspace_root.join(SCRATCH_SUBDIR)
36}
37
38/// A fresh, unique scratch-file path under the workspace, creating the directory
39/// if needed and pruning stale files first so the dir does not grow without
40/// bound. Returns `None` on any IO error (offload is best-effort).
41pub fn new_scratch_path(workspace_root: &Path) -> Option<PathBuf> {
42    let dir = scratch_dir(workspace_root);
43    if let Err(e) = fs::create_dir_all(&dir) {
44        debug!("scratch: create_dir_all {} failed: {e}", dir.display());
45        return None;
46    }
47    prune_scratch_dir(workspace_root, SCRATCH_MAX_AGE);
48    let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_nanos();
49    let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed);
50    Some(dir.join(format!("bash-output-{}-{nonce:x}-{seq:x}.txt", std::process::id())))
51}
52
53/// Append `bytes` to the scratch file, creating it if absent.
54pub fn append_scratch(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
55    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
56    file.write_all(bytes)
57}
58
59/// Remove scratch files whose mtime is older than `max_age`. Best-effort:
60/// per-entry failures are logged at debug and skipped, and a missing dir is a
61/// no-op.
62pub fn prune_scratch_dir(workspace_root: &Path, max_age: Duration) {
63    let dir = scratch_dir(workspace_root);
64    let Ok(entries) = fs::read_dir(&dir) else { return };
65    let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
66    for entry in entries.flatten() {
67        let path = entry.path();
68        let stale =
69            fs::metadata(&path).and_then(|meta| meta.modified()).is_ok_and(|mtime| mtime < cutoff);
70        if stale {
71            if let Err(e) = fs::remove_file(&path) {
72                debug!("scratch: prune remove {} failed: {e}", path.display());
73            }
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    #![allow(clippy::unwrap_used, clippy::expect_used)]
81    use super::*;
82    use std::thread::sleep;
83    use tempfile::TempDir;
84
85    #[test]
86    fn new_scratch_path_is_under_winx_scratch() {
87        let ws = TempDir::new().unwrap();
88        let path = new_scratch_path(ws.path()).expect("path");
89        assert!(
90            path.starts_with(ws.path().join(".winx/scratch")),
91            "path outside scratch: {path:?}"
92        );
93        assert!(path.parent().is_some_and(Path::exists), "scratch dir not created");
94    }
95
96    #[test]
97    fn new_scratch_path_is_unique_per_call() {
98        // Two calls (possibly within the same nanosecond) must not collide, or
99        // two shells would interleave output into one file.
100        let ws = TempDir::new().unwrap();
101        let a = new_scratch_path(ws.path()).unwrap();
102        let b = new_scratch_path(ws.path()).unwrap();
103        assert_ne!(a, b, "consecutive scratch paths must be unique");
104    }
105
106    #[test]
107    fn append_scratch_accumulates() {
108        let ws = TempDir::new().unwrap();
109        let path = new_scratch_path(ws.path()).unwrap();
110        append_scratch(&path, b"head-one\n").unwrap();
111        append_scratch(&path, b"head-two\n").unwrap();
112        assert_eq!(fs::read_to_string(&path).unwrap(), "head-one\nhead-two\n");
113    }
114
115    #[test]
116    fn prune_removes_stale_keeps_fresh() {
117        let ws = TempDir::new().unwrap();
118        let old = new_scratch_path(ws.path()).unwrap();
119        append_scratch(&old, b"old").unwrap();
120        sleep(Duration::from_millis(15));
121        // Prune anything older than 1ms: the 15ms-old file goes, a brand-new one stays.
122        prune_scratch_dir(ws.path(), Duration::from_millis(1));
123        assert!(!old.exists(), "stale scratch file should be pruned");
124        let fresh = new_scratch_path(ws.path()).unwrap();
125        append_scratch(&fresh, b"fresh").unwrap();
126        prune_scratch_dir(ws.path(), Duration::from_secs(3600));
127        assert!(fresh.exists(), "fresh scratch file must survive");
128    }
129
130    #[test]
131    fn new_scratch_path_returns_none_on_unwritable_root() {
132        // A workspace root that cannot hold a child dir must yield None, not panic.
133        let path = new_scratch_path(Path::new("/proc/winx-nonexistent-xyz"));
134        assert!(path.is_none(), "expected None for unwritable root");
135    }
136}