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