Skip to main content

vtcode_core/core/
trajectory.rs

1use serde::Serialize;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use crate::telemetry::perf::PerfSpan;
8use crate::utils::async_line_writer::AsyncLineWriter;
9
10const TRAJECTORY_PREFIX: &str = "trajectory-";
11const TRAJECTORY_EXTENSION: &str = "jsonl";
12use super::SECONDS_PER_DAY;
13const BYTES_PER_MB: u64 = 1024 * 1024;
14
15#[derive(Clone)]
16pub struct TrajectoryLogger {
17    enabled: bool,
18    writer: Option<Arc<AsyncLineWriter>>,
19}
20
21#[derive(Debug, Clone, Copy)]
22pub struct TrajectoryRetention {
23    pub max_files: usize,
24    pub max_age_days: u64,
25    pub max_total_size_bytes: u64,
26}
27
28impl Default for TrajectoryRetention {
29    fn default() -> Self {
30        use vtcode_config::constants::defaults;
31        Self {
32            max_files: defaults::DEFAULT_TRAJECTORY_MAX_FILES,
33            max_age_days: defaults::DEFAULT_TRAJECTORY_MAX_AGE_DAYS,
34            max_total_size_bytes: defaults::DEFAULT_TRAJECTORY_MAX_SIZE_MB
35                .saturating_mul(BYTES_PER_MB),
36        }
37    }
38}
39
40impl TrajectoryLogger {
41    pub fn new(workspace: &Path) -> Self {
42        Self::with_retention(workspace, TrajectoryRetention::default())
43    }
44
45    pub fn with_retention(workspace: &Path, retention: TrajectoryRetention) -> Self {
46        let dir = workspace.join(".vtcode").join("logs");
47        rotate_current_trajectory(&dir);
48        prune_trajectory_logs_best_effort(&dir, retention);
49        let path = dir.join("trajectory.jsonl");
50        let writer = AsyncLineWriter::new(path).ok().map(Arc::new);
51        let enabled = writer.is_some();
52        Self { enabled, writer }
53    }
54
55    pub fn disabled() -> Self {
56        Self {
57            enabled: false,
58            writer: None,
59        }
60    }
61
62    pub fn log<T: Serialize>(&self, record: &T) {
63        if !self.enabled {
64            return;
65        }
66        let mut perf = PerfSpan::new("vtcode.perf.trajectory_log_ms");
67        perf.tag("mode", "async");
68        if let Ok(line) = serde_json::to_string(record)
69            && let Some(writer) = self.writer.as_ref()
70        {
71            writer.write_line(line);
72        }
73    }
74
75    #[cfg(test)]
76    pub fn flush(&self) {
77        if let Some(writer) = self.writer.as_ref() {
78            writer.flush();
79        }
80    }
81
82    pub fn log_route(&self, turn: usize, selected_model: &str, class: &str, input_preview: &str) {
83        #[derive(Serialize)]
84        struct RouteRec<'a> {
85            kind: &'static str,
86            turn: usize,
87            selected_model: &'a str,
88            class: &'a str,
89            input_preview: &'a str,
90            ts: i64,
91        }
92        let rec = RouteRec {
93            kind: "route",
94            turn,
95            selected_model,
96            class,
97            input_preview,
98            ts: chrono::Utc::now().timestamp(),
99        };
100        self.log(&rec);
101    }
102
103    pub fn log_tool_call(&self, turn: usize, name: &str, args: &serde_json::Value, ok: bool) {
104        #[derive(Serialize)]
105        struct ToolRec<'a> {
106            kind: &'static str,
107            turn: usize,
108            name: &'a str,
109            args: serde_json::Value,
110            ok: bool,
111            ts: i64,
112        }
113        let rec = ToolRec {
114            kind: "tool",
115            turn,
116            name,
117            args: args.clone(),
118            ok,
119            ts: chrono::Utc::now().timestamp(),
120        };
121        self.log(&rec);
122    }
123}
124
125fn rotate_current_trajectory(dir: &Path) {
126    let current = dir.join("trajectory.jsonl");
127    if !current.exists() {
128        return;
129    }
130    let metadata = match fs::metadata(&current) {
131        Ok(m) => m,
132        Err(_) => return,
133    };
134    if metadata.len() == 0 {
135        return;
136    }
137    let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
138    let rotated_name = format!("{TRAJECTORY_PREFIX}{timestamp}.{TRAJECTORY_EXTENSION}");
139    let rotated_path = dir.join(rotated_name);
140    let _ = fs::rename(&current, &rotated_path);
141}
142
143fn is_trajectory_file(path: &Path) -> bool {
144    let name = match path.file_name().and_then(|n| n.to_str()) {
145        Some(n) => n,
146        None => return false,
147    };
148    name.starts_with(TRAJECTORY_PREFIX) && name.ends_with(&format!(".{TRAJECTORY_EXTENSION}"))
149}
150
151struct FileEntry {
152    path: PathBuf,
153    modified: SystemTime,
154    size: u64,
155}
156
157fn prune_trajectory_logs_best_effort(dir: &Path, limits: TrajectoryRetention) {
158    if let Err(err) = prune_trajectory_logs(dir, limits) {
159        tracing::debug!(
160            "Failed to prune trajectory logs in {}: {}",
161            dir.display(),
162            err
163        );
164    }
165}
166
167fn prune_trajectory_logs(dir: &Path, limits: TrajectoryRetention) -> anyhow::Result<()> {
168    if !dir.exists() {
169        return Ok(());
170    }
171
172    let mut entries = Vec::new();
173    for entry in fs::read_dir(dir)? {
174        let entry = match entry {
175            Ok(e) => e,
176            Err(_) => continue,
177        };
178        let path = entry.path();
179        if !is_trajectory_file(&path) {
180            continue;
181        }
182        let metadata = match entry.metadata() {
183            Ok(m) if m.is_file() => m,
184            _ => continue,
185        };
186        entries.push(FileEntry {
187            path,
188            modified: metadata.modified().unwrap_or(UNIX_EPOCH),
189            size: metadata.len(),
190        });
191    }
192
193    if entries.is_empty() {
194        return Ok(());
195    }
196
197    let now = SystemTime::now();
198    let age_cutoff = if limits.max_age_days == 0 {
199        now
200    } else {
201        now.checked_sub(Duration::from_secs(
202            limits.max_age_days.saturating_mul(SECONDS_PER_DAY),
203        ))
204        .unwrap_or(UNIX_EPOCH)
205    };
206
207    let (expired, mut retained): (Vec<_>, Vec<_>) = entries
208        .into_iter()
209        .partition(|entry| entry.modified <= age_cutoff);
210    remove_files(expired);
211
212    retained.sort_by(|a, b| b.modified.cmp(&a.modified));
213
214    if limits.max_files > 0 && retained.len() > limits.max_files {
215        let overflow = retained.split_off(limits.max_files);
216        remove_files(overflow);
217    }
218
219    if limits.max_total_size_bytes == 0 || retained.is_empty() {
220        return Ok(());
221    }
222
223    let mut total_size = 0u64;
224    let mut size_overflow = Vec::new();
225    let mut keep = Vec::with_capacity(retained.len());
226    for entry in retained {
227        let projected = total_size.saturating_add(entry.size);
228        if keep.is_empty() || projected <= limits.max_total_size_bytes {
229            total_size = projected;
230            keep.push(entry);
231        } else {
232            size_overflow.push(entry);
233        }
234    }
235    remove_files(size_overflow);
236
237    Ok(())
238}
239
240fn remove_files(entries: Vec<FileEntry>) {
241    for entry in entries {
242        if let Err(err) = fs::remove_file(&entry.path) {
243            tracing::debug!(
244                "Failed to remove trajectory log {}: {}",
245                entry.path.display(),
246                err
247            );
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use tempfile::TempDir;
256
257    #[test]
258    fn test_trajectory_logger_log_route_integration() {
259        let temp_dir = TempDir::new().unwrap();
260        let logger = TrajectoryLogger::new(temp_dir.path());
261
262        logger.log_route(
263            1,
264            "gemini-3-flash-preview",
265            "standard",
266            "test user input for logging",
267        );
268        logger.flush();
269
270        let log_path = temp_dir.path().join(".vtcode/logs/trajectory.jsonl");
271        assert!(log_path.exists());
272
273        let content = fs::read_to_string(log_path).unwrap();
274        let lines: Vec<&str> = content.lines().collect();
275        assert_eq!(lines.len(), 1);
276
277        let record: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
278        assert_eq!(record["kind"], "route");
279        assert_eq!(record["turn"], 1);
280        assert_eq!(record["selected_model"], "gemini-3-flash-preview");
281        assert_eq!(record["class"], "standard");
282        assert_eq!(record["input_preview"], "test user input for logging");
283        assert!(record["ts"].is_number());
284    }
285
286    #[test]
287    fn test_rotation_renames_existing_log() {
288        let temp_dir = TempDir::new().unwrap();
289        let logs_dir = temp_dir.path().join(".vtcode").join("logs");
290        fs::create_dir_all(&logs_dir).unwrap();
291
292        let current = logs_dir.join("trajectory.jsonl");
293        fs::write(&current, r#"{"kind":"route","turn":1}"#).unwrap();
294
295        let _logger = TrajectoryLogger::new(temp_dir.path());
296
297        let rotated: Vec<_> = fs::read_dir(&logs_dir)
298            .unwrap()
299            .filter_map(|e| e.ok())
300            .filter(|e| is_trajectory_file(&e.path()))
301            .collect();
302        assert_eq!(rotated.len(), 1, "Old log should be rotated");
303        assert!(current.exists(), "New current file should be created");
304    }
305
306    #[test]
307    fn test_prune_removes_old_files() {
308        let temp_dir = TempDir::new().unwrap();
309        let logs_dir = temp_dir.path().join(".vtcode").join("logs");
310        fs::create_dir_all(&logs_dir).unwrap();
311
312        for i in 0..5 {
313            let name = format!("trajectory-2024010{}T000000Z.jsonl", i);
314            fs::write(logs_dir.join(name), "data").unwrap();
315        }
316
317        let limits = TrajectoryRetention {
318            max_files: 3,
319            max_age_days: 0,
320            max_total_size_bytes: 100 * BYTES_PER_MB,
321        };
322
323        prune_trajectory_logs(&logs_dir, limits).unwrap();
324
325        let remaining: Vec<_> = fs::read_dir(&logs_dir)
326            .unwrap()
327            .filter_map(|e| e.ok())
328            .filter(|e| is_trajectory_file(&e.path()))
329            .collect();
330        assert!(remaining.len() <= 3, "Should keep at most 3 files");
331    }
332
333    #[test]
334    fn test_empty_trajectory_not_rotated() {
335        let temp_dir = TempDir::new().unwrap();
336        let logs_dir = temp_dir.path().join(".vtcode").join("logs");
337        fs::create_dir_all(&logs_dir).unwrap();
338
339        let current = logs_dir.join("trajectory.jsonl");
340        fs::write(&current, "").unwrap();
341
342        let _logger = TrajectoryLogger::new(temp_dir.path());
343
344        let rotated: Vec<_> = fs::read_dir(&logs_dir)
345            .unwrap()
346            .filter_map(|e| e.ok())
347            .filter(|e| is_trajectory_file(&e.path()))
348            .collect();
349        assert_eq!(rotated.len(), 0, "Empty file should not be rotated");
350    }
351}