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 {
28 base_dir: base_dir.into(),
29 }
30 }
31
32 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 pub fn base_dir(&self) -> &Path {
40 &self.base_dir
41 }
42
43 #[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 #[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 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 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 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 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 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 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 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 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 fn trace_filename(&self, trace: &TraceRecord) -> String {
163 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 format!("{}.json", trace.id)
170 }
171 }
172
173 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 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 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 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 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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
237pub struct TraceIndex {
238 pub version: String,
240 pub files: hashbrown::HashMap<String, Vec<String>>,
242}
243
244impl TraceIndex {
245 pub fn new() -> Self {
247 Self {
248 version: AGENT_TRACE_VERSION.to_string(),
249 files: hashbrown::HashMap::new(),
250 }
251 }
252
253 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 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 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 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 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 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}