vtcode_core/trace/
store.rs1use 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
12pub const TRACES_DIR: &str = "traces";
14
15#[derive(Debug, Clone)]
19pub struct TraceStore {
20 base_dir: PathBuf,
22}
23
24impl TraceStore {
25 pub fn new(base_dir: impl Into<PathBuf>) -> Self {
27 Self { base_dir: base_dir.into() }
28 }
29
30 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 pub fn base_dir(&self) -> &Path {
38 &self.base_dir
39 }
40
41 #[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 #[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 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 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 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 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 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 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 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 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 fn trace_filename(&self, trace: &TraceRecord) -> String {
159 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 format!("{}.json", trace.id)
166 }
167 }
168
169 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 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 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 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 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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
232pub struct TraceIndex {
233 pub version: String,
235 pub files: hashbrown::HashMap<String, Vec<String>>,
237}
238
239impl TraceIndex {
240 pub fn new() -> Self {
242 Self {
243 version: AGENT_TRACE_VERSION.to_string(),
244 files: hashbrown::HashMap::new(),
245 }
246 }
247
248 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 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 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 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 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 let loaded_by_rev = store.read_by_revision_async("abc123def456789012345678901234567890abcd").await?;
385 assert!(loaded_by_rev.is_some());
386
387 Ok(())
388 }
389}