Skip to main content

vtcode_commons/
workspace_snapshot.rs

1//! Cheap workspace environment-delta observability.
2//!
3//! Long-horizon agents operate in a *changing environment*: files are written,
4//! processes mutate state, git trees move. The harness needs a fast way to
5//! detect that the environment drifted between turns so it can re-ground
6//! assumptions instead of trusting a stale picture.
7//!
8//! [`WorkspaceSnapshot`] captures a lightweight fingerprint of every tracked
9//! file (size + mtime + a short content hash of the head), and [`diff`]
10//! computes added/changed/removed paths. Snapshots are cheap enough to take at
11//! each turn boundary and to persist as a derived view.
12
13use std::collections::BTreeMap;
14use std::io;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19/// Directories never traversed when capturing a snapshot.
20const SKIP_DIRS: &[&str] = &[
21    ".git",
22    ".svn",
23    "target",
24    "node_modules",
25    "dist",
26    "build",
27    ".vtcode",
28    ".next",
29    "vendor",
30];
31
32/// A compact fingerprint of a single file.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub struct FileStat {
35    /// Size in bytes.
36    pub size: u64,
37    /// Modification time in nanoseconds since the Unix epoch.
38    pub mtime_ns: i64,
39    /// FNV-1a hash of the file's first 4096 bytes.
40    pub head_hash: u64,
41}
42
43/// Number of leading bytes sampled for the content fingerprint.
44const HASH_SAMPLE_BYTES: usize = 4096;
45
46/// A point-in-time fingerprint of a workspace's tracked files.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct WorkspaceSnapshot {
49    /// `relative_path -> stat`, sorted for stable diffs.
50    pub files: BTreeMap<String, FileStat>,
51    /// RFC3339 capture timestamp.
52    pub captured_at: String,
53}
54
55/// The difference between two workspace snapshots.
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct SnapshotDelta {
58    /// Paths present in `new` but not `old`.
59    pub added: Vec<String>,
60    /// Paths present in both but with a different fingerprint.
61    pub changed: Vec<String>,
62    /// Paths present in `old` but not `new`.
63    pub removed: Vec<String>,
64}
65
66/// Capture a snapshot of `workspace`, skipping VCS/ build/ cache directories.
67///
68/// Files larger than `max_file_bytes` are fingerprinted by size + mtime only
69/// (the head hash is set to 0) to keep capture O(files) rather than O(bytes).
70pub fn capture(workspace: &Path, max_file_bytes: u64) -> io::Result<WorkspaceSnapshot> {
71    let mut files = BTreeMap::new();
72    collect(workspace, workspace, max_file_bytes, &mut files)?;
73    Ok(WorkspaceSnapshot { files, captured_at: now_rfc3339() })
74}
75
76fn collect(root: &Path, dir: &Path, max_file_bytes: u64, out: &mut BTreeMap<String, FileStat>) -> io::Result<()> {
77    for entry in std::fs::read_dir(dir)? {
78        let entry = entry?;
79        let path = entry.path();
80        let ft = entry.file_type()?;
81        if ft.is_dir() {
82            if let Some(name) = path.file_name().and_then(|n| n.to_str())
83                && SKIP_DIRS.contains(&name)
84            {
85                continue;
86            }
87            collect(root, &path, max_file_bytes, out)?;
88        } else if ft.is_file() {
89            let rel = match path.strip_prefix(root).ok().and_then(|p| p.to_str()) {
90                Some(r) => r.to_string(),
91                None => continue,
92            };
93            match entry.metadata() {
94                Ok(meta) => {
95                    let size = meta.len();
96                    let mtime_ns = meta
97                        .modified()
98                        .ok()
99                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
100                        .map(|d| d.as_nanos() as i64)
101                        .unwrap_or(0);
102                    let head_hash = if size <= max_file_bytes { hash_head(&path) } else { 0 };
103                    out.insert(rel, FileStat { size, mtime_ns, head_hash });
104                }
105                Err(_) => continue,
106            }
107        }
108    }
109    Ok(())
110}
111
112/// FNV-1a 64-bit hash of the first [`HASH_SAMPLE_BYTES`] bytes of `path`.
113fn hash_head(path: &Path) -> u64 {
114    const SEED: u64 = 0xcbf29ce484222325;
115    const PRIME: u64 = 0x100000001b3;
116    let mut hash = SEED;
117    let mut buf = [0u8; HASH_SAMPLE_BYTES];
118    if let Ok(mut f) = std::fs::File::open(path) {
119        use std::io::Read;
120        if let Ok(n) = f.read(&mut buf) {
121            for &b in &buf[..n] {
122                hash ^= u64::from(b);
123                hash = hash.wrapping_mul(PRIME);
124            }
125        }
126    }
127    hash
128}
129
130/// Compute the delta from `old` to `new`.
131#[must_use]
132pub fn diff(old: &WorkspaceSnapshot, new: &WorkspaceSnapshot) -> SnapshotDelta {
133    let mut delta = SnapshotDelta::default();
134    for (path, new_stat) in &new.files {
135        match old.files.get(path) {
136            None => delta.added.push(path.clone()),
137            Some(old_stat) if old_stat != new_stat => delta.changed.push(path.clone()),
138            Some(_) => {}
139        }
140    }
141    for path in old.files.keys() {
142        if !new.files.contains_key(path) {
143            delta.removed.push(path.clone());
144        }
145    }
146    delta
147}
148
149/// Whether the delta indicates meaningful environment drift.
150#[must_use]
151pub fn is_drift(delta: &SnapshotDelta) -> bool {
152    !delta.added.is_empty() || !delta.changed.is_empty() || !delta.removed.is_empty()
153}
154
155/// Persist a snapshot as JSON (e.g. as a session derived view).
156pub fn save_json(snapshot: &WorkspaceSnapshot, path: &Path) -> io::Result<()> {
157    if let Some(parent) = path.parent() {
158        std::fs::create_dir_all(parent)?;
159    }
160    let bytes = serde_json::to_vec_pretty(snapshot).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
161    std::fs::write(path, bytes)
162}
163
164/// Load a previously persisted snapshot.
165pub fn load_json(path: &Path) -> io::Result<WorkspaceSnapshot> {
166    let bytes = std::fs::read(path)?;
167    serde_json::from_slice(&bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
168}
169
170fn now_rfc3339() -> String {
171    chrono::Utc::now().to_rfc3339()
172}
173
174/// Resolve the on-disk path for a session's environment snapshot.
175#[must_use]
176pub fn snapshot_path(session_dir: &Path) -> PathBuf {
177    session_dir.join("derived").join("workspace_snapshot.json")
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn capture_and_diff_detects_changes() {
186        let tmp = std::env::temp_dir().join(format!("vtcode-snap-{}", std::process::id()));
187        let ws = tmp.join("ws");
188        std::fs::create_dir_all(&ws).unwrap();
189        std::fs::write(ws.join("a.txt"), b"hello").unwrap();
190        std::fs::write(ws.join("b.txt"), b"world").unwrap();
191
192        let snap1 = capture(&ws, 1_000_000).unwrap();
193        assert_eq!(snap1.files.len(), 2);
194        assert!(snap1.files.contains_key("a.txt"));
195        assert!(snap1.files.contains_key("b.txt"));
196
197        // Mutate + add + remove.
198        std::fs::write(ws.join("a.txt"), b"changed").unwrap();
199        std::fs::write(ws.join("c.txt"), b"new").unwrap();
200        std::fs::remove_file(ws.join("b.txt")).unwrap();
201
202        let snap2 = capture(&ws, 1_000_000).unwrap();
203        let delta = diff(&snap1, &snap2);
204        assert_eq!(delta.added, vec!["c.txt".to_string()]);
205        assert_eq!(delta.changed, vec!["a.txt".to_string()]);
206        assert_eq!(delta.removed, vec!["b.txt".to_string()]);
207        assert!(is_drift(&delta));
208        let _ = std::fs::remove_dir_all(&tmp);
209    }
210
211    #[test]
212    fn json_round_trips() {
213        let tmp = std::env::temp_dir().join(format!("vtcode-snap-json-{}", std::process::id()));
214        let path = tmp.join("snap.json");
215        let snap = WorkspaceSnapshot {
216            files: {
217                let mut m = BTreeMap::new();
218                m.insert("x.rs".to_string(), FileStat { size: 3, mtime_ns: 42, head_hash: 7 });
219                m
220            },
221            captured_at: "2026-01-01T00:00:00Z".to_string(),
222        };
223        save_json(&snap, &path).unwrap();
224        let loaded = load_json(&path).unwrap();
225        assert_eq!(loaded, snap);
226        let _ = std::fs::remove_dir_all(&tmp);
227    }
228}