Skip to main content

vtcode_core/trace/
store.rs

1//! Trace storage implementation for persisting Agent Trace records.
2
3use crate::utils::file_utils::{
4    ensure_dir_exists, ensure_dir_exists_sync, read_file_with_context, read_file_with_context_sync,
5    write_file_with_context, write_file_with_context_sync,
6};
7use anyhow::{Context, Result};
8use std::fs;
9use std::path::{Path, PathBuf};
10use vtcode_exec_events::trace::{AGENT_TRACE_VERSION, TraceRecord};
11
12/// Default directory name for trace storage.
13pub const TRACES_DIR: &str = "traces";
14
15/// Trace storage for reading and writing Agent Trace records.
16///
17/// Provides both sync and async APIs for flexibility.
18#[derive(Debug, Clone)]
19pub struct TraceStore {
20    /// Base directory for trace storage (usually `.vtcode/traces/`).
21    base_dir: PathBuf,
22}
23
24impl TraceStore {
25    /// Create a new trace store at the specified base directory.
26    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
27        Self { base_dir: base_dir.into() }
28    }
29
30    /// Create a trace store under the `.vtcode` directory in the workspace.
31    pub fn for_workspace(workspace_path: impl AsRef<Path>) -> Self {
32        let base_dir = workspace_path.as_ref().join(".vtcode").join(TRACES_DIR);
33        Self::new(base_dir)
34    }
35
36    /// Get the base directory for trace storage.
37    pub fn base_dir(&self) -> &Path {
38        &self.base_dir
39    }
40
41    /// Ensure the trace storage directory exists.
42    #[must_use = "trace directory creation failure is lost"]
43    pub fn ensure_dir(&self) -> Result<()> {
44        ensure_dir_exists_sync(&self.base_dir)
45            .with_context(|| format!("Failed to create trace directory: {:?}", self.base_dir))?;
46        Ok(())
47    }
48
49    /// Write a trace record to storage.
50    ///
51    /// The filename is based on the trace ID or git revision if available.
52    #[must_use = "trace writing failure goes undetected"]
53    pub fn write_trace(&self, trace: &TraceRecord) -> Result<PathBuf> {
54        self.ensure_dir()?;
55
56        let filename = self.trace_filename(trace);
57        let path = self.base_dir.join(&filename);
58
59        let json = serde_json::to_string_pretty(trace).with_context(|| "Failed to serialize trace record")?;
60
61        write_file_with_context_sync(&path, &json, "trace record")
62            .with_context(|| format!("Failed to write trace to {path:?}"))?;
63
64        Ok(path)
65    }
66
67    /// Read a trace record by filename.
68    pub fn read_trace(&self, filename: &str) -> Result<TraceRecord> {
69        let path = self.base_dir.join(filename);
70        self.read_trace_from_path(&path)
71    }
72
73    /// Read a trace record from a specific path.
74    pub fn read_trace_from_path(&self, path: &Path) -> Result<TraceRecord> {
75        let content = read_file_with_context_sync(path, "trace record")
76            .with_context(|| format!("Failed to read trace: {path:?}"))?;
77
78        let trace: TraceRecord =
79            serde_json::from_str(&content).with_context(|| format!("Failed to parse trace: {path:?}"))?;
80
81        Ok(trace)
82    }
83
84    /// Read a trace by git revision.
85    pub fn read_by_revision(&self, revision: &str) -> Result<Option<TraceRecord>> {
86        let short_rev = &revision[..revision.len().min(12)];
87        let filename = format!("{short_rev}.json");
88        let path = self.base_dir.join(&filename);
89
90        if path.exists() {
91            Ok(Some(self.read_trace_from_path(&path)?))
92        } else {
93            // Try full revision
94            let filename = format!("{revision}.json");
95            let path = self.base_dir.join(&filename);
96            if path.exists() {
97                Ok(Some(self.read_trace_from_path(&path)?))
98            } else {
99                Ok(None)
100            }
101        }
102    }
103
104    /// List all trace files in storage.
105    pub fn list_traces(&self) -> Result<Vec<PathBuf>> {
106        if !self.base_dir.exists() {
107            return Ok(Vec::new());
108        }
109
110        let mut traces = Vec::new();
111        for entry in fs::read_dir(&self.base_dir)
112            .with_context(|| format!("Failed to read trace directory: {:?}", self.base_dir))?
113        {
114            let entry = entry?;
115            let path = entry.path();
116            if path.extension().is_some_and(|ext| ext == "json") {
117                traces.push(path);
118            }
119        }
120
121        // Sort by modification time (newest first)
122        traces.sort_by(|a, b| {
123            let a_time = fs::metadata(a).and_then(|m| m.modified()).ok();
124            let b_time = fs::metadata(b).and_then(|m| m.modified()).ok();
125            b_time.cmp(&a_time)
126        });
127
128        Ok(traces)
129    }
130
131    /// Delete a trace by filename.
132    pub fn delete_trace(&self, filename: &str) -> Result<()> {
133        let path = self.base_dir.join(filename);
134        if path.exists() {
135            fs::remove_file(&path).with_context(|| format!("Failed to delete trace: {path:?}"))?;
136        }
137        Ok(())
138    }
139
140    /// Clean up old traces, keeping only the most recent N.
141    pub fn cleanup(&self, keep_count: usize) -> Result<usize> {
142        let traces = self.list_traces()?;
143        let to_delete = traces.into_iter().skip(keep_count);
144        let mut deleted = 0;
145
146        for path in to_delete {
147            if let Err(e) = fs::remove_file(&path) {
148                tracing::warn!("Failed to delete old trace {:?}: {}", path, e);
149            } else {
150                deleted += 1;
151            }
152        }
153
154        Ok(deleted)
155    }
156
157    /// Generate filename for a trace record.
158    fn trace_filename(&self, trace: &TraceRecord) -> String {
159        // Prefer git revision for filename (first 12 chars)
160        if let Some(vcs) = &trace.vcs {
161            let short_rev = &vcs.revision[..vcs.revision.len().min(12)];
162            format!("{short_rev}.json")
163        } else {
164            // Fall back to trace ID
165            format!("{}.json", trace.id)
166        }
167    }
168
169    // ========================================================================
170    // Async API
171    // ========================================================================
172
173    /// Ensure the trace storage directory exists (async).
174    pub async fn ensure_dir_async(&self) -> Result<()> {
175        ensure_dir_exists(&self.base_dir)
176            .await
177            .with_context(|| format!("Failed to create trace directory: {:?}", self.base_dir))?;
178        Ok(())
179    }
180
181    /// Write a trace record to storage (async).
182    pub async fn write_trace_async(&self, trace: &TraceRecord) -> Result<PathBuf> {
183        self.ensure_dir_async().await?;
184
185        let filename = self.trace_filename(trace);
186        let path = self.base_dir.join(&filename);
187
188        let json = serde_json::to_string_pretty(trace).with_context(|| "Failed to serialize trace record")?;
189
190        write_file_with_context(&path, &json, "trace record")
191            .await
192            .with_context(|| format!("Failed to write trace to {path:?}"))?;
193
194        Ok(path)
195    }
196
197    /// Read a trace record from a specific path (async).
198    pub async fn read_trace_from_path_async(&self, path: &Path) -> Result<TraceRecord> {
199        let content = read_file_with_context(path, "trace record")
200            .await
201            .with_context(|| format!("Failed to read trace: {path:?}"))?;
202
203        let trace: TraceRecord =
204            serde_json::from_str(&content).with_context(|| format!("Failed to parse trace: {path:?}"))?;
205
206        Ok(trace)
207    }
208
209    /// Read a trace by git revision (async).
210    pub async fn read_by_revision_async(&self, revision: &str) -> Result<Option<TraceRecord>> {
211        let short_rev = &revision[..revision.len().min(12)];
212        let filename = format!("{short_rev}.json");
213        let path = self.base_dir.join(&filename);
214
215        if tokio::fs::try_exists(&path).await.unwrap_or(false) {
216            Ok(Some(self.read_trace_from_path_async(&path).await?))
217        } else {
218            // Try full revision
219            let filename = format!("{revision}.json");
220            let path = self.base_dir.join(&filename);
221            if tokio::fs::try_exists(&path).await.unwrap_or(false) {
222                Ok(Some(self.read_trace_from_path_async(&path).await?))
223            } else {
224                Ok(None)
225            }
226        }
227    }
228}
229
230/// Index file for quick lookup of traces by file path.
231#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
232pub struct TraceIndex {
233    /// Version of the index format.
234    pub version: String,
235    /// Mapping from file path to trace filenames containing that path.
236    pub files: hashbrown::HashMap<String, Vec<String>>,
237}
238
239impl TraceIndex {
240    /// Create a new empty index.
241    pub fn new() -> Self {
242        Self {
243            version: AGENT_TRACE_VERSION.to_string(),
244            files: hashbrown::HashMap::new(),
245        }
246    }
247
248    /// Add a trace to the index.
249    pub fn add_trace(&mut self, trace: &TraceRecord, filename: &str) {
250        for file in &trace.files {
251            self.files.entry(file.path.clone()).or_default().push(filename.to_string());
252        }
253    }
254
255    /// Get trace filenames for a file path.
256    pub fn get_traces_for_file(&self, path: &str) -> Option<&[String]> {
257        self.files.get(path).map(|v| v.as_slice())
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use tempfile::TempDir;
265    use vtcode_exec_events::trace::{TraceFile, TraceRange, TraceRecordBuilder};
266
267    fn create_test_trace() -> TraceRecord {
268        TraceRecordBuilder::new()
269            .git_revision("abc123def456789012345678901234567890abcd")
270            .file(TraceFile::with_ai_ranges("src/main.rs", "anthropic/claude-opus-4", vec![TraceRange::new(1, 50)]))
271            .build()
272    }
273
274    #[test]
275    fn test_trace_store_write_read() -> Result<()> {
276        let temp_dir = TempDir::new()?;
277        let store = TraceStore::new(temp_dir.path().join("traces"));
278
279        let trace = create_test_trace();
280        let path = store.write_trace(&trace)?;
281
282        assert!(path.exists());
283
284        let loaded = store.read_trace_from_path(&path)?;
285        assert_eq!(loaded.id, trace.id);
286        assert_eq!(loaded.files.len(), 1);
287
288        Ok(())
289    }
290
291    #[test]
292    fn test_trace_store_read_by_revision() -> Result<()> {
293        let temp_dir = TempDir::new()?;
294        let store = TraceStore::new(temp_dir.path().join("traces"));
295
296        let trace = create_test_trace();
297        store.write_trace(&trace)?;
298
299        let loaded = store.read_by_revision("abc123def456789012345678901234567890abcd")?;
300        assert!(loaded.is_some());
301        assert_eq!(loaded.unwrap().id, trace.id);
302
303        Ok(())
304    }
305
306    #[test]
307    fn test_trace_store_list() -> Result<()> {
308        let temp_dir = TempDir::new()?;
309        let store = TraceStore::new(temp_dir.path().join("traces"));
310
311        // Write multiple traces with unique revisions (using large distinct values)
312        let revisions = [
313            "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
314            "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0a1",
315            "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0a1b2",
316        ];
317        for rev in &revisions {
318            let trace = TraceRecordBuilder::new().git_revision(*rev).build();
319            store.write_trace(&trace)?;
320        }
321
322        let traces = store.list_traces()?;
323        assert_eq!(traces.len(), 3);
324
325        Ok(())
326    }
327
328    #[test]
329    fn test_trace_store_cleanup() -> Result<()> {
330        let temp_dir = TempDir::new()?;
331        let store = TraceStore::new(temp_dir.path().join("traces"));
332
333        // Write multiple traces with unique revisions
334        let revisions = [
335            "1111111111111111111111111111111111111111",
336            "2222222222222222222222222222222222222222",
337            "3333333333333333333333333333333333333333",
338            "4444444444444444444444444444444444444444",
339            "5555555555555555555555555555555555555555",
340        ];
341        for rev in &revisions {
342            let trace = TraceRecordBuilder::new().git_revision(*rev).build();
343            store.write_trace(&trace)?;
344            // Small delay to ensure different modification times
345            std::thread::sleep(std::time::Duration::from_millis(10));
346        }
347
348        let deleted = store.cleanup(2)?;
349        assert_eq!(deleted, 3);
350
351        let remaining = store.list_traces()?;
352        assert_eq!(remaining.len(), 2);
353
354        Ok(())
355    }
356
357    #[test]
358    fn test_trace_index() {
359        let mut index = TraceIndex::new();
360        let trace = create_test_trace();
361
362        index.add_trace(&trace, "abc123def456.json");
363
364        let traces = index.get_traces_for_file("src/main.rs");
365        assert!(traces.is_some());
366        assert_eq!(traces.unwrap().len(), 1);
367    }
368
369    #[tokio::test]
370    async fn test_trace_store_async() -> Result<()> {
371        let temp_dir = TempDir::new()?;
372        let store = TraceStore::new(temp_dir.path().join("traces"));
373
374        let trace = create_test_trace();
375        let path = store.write_trace_async(&trace).await?;
376
377        assert!(path.exists());
378
379        let loaded = store.read_trace_from_path_async(&path).await?;
380        assert_eq!(loaded.id, trace.id);
381        assert_eq!(loaded.files.len(), 1);
382
383        // Test read by revision async
384        let loaded_by_rev = store.read_by_revision_async("abc123def456789012345678901234567890abcd").await?;
385        assert!(loaded_by_rev.is_some());
386
387        Ok(())
388    }
389}