Skip to main content

vtcode_session_store/
retention.rs

1//! Retention and garbage-collection for the unified session store.
2
3use std::path::Path;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use walkdir::WalkDir;
7
8use crate::error::SessionStoreError;
9use crate::query::SessionSummary;
10use crate::sessions_root;
11
12/// Retention policy applied to the set of per-session stores.
13#[derive(Debug, Clone, Copy)]
14pub struct RetentionPolicy {
15    /// Maximum number of sessions to keep (oldest evicted first).
16    pub max_sessions: usize,
17    /// Maximum age of a session in days before eviction.
18    pub max_age_days: u64,
19}
20
21impl Default for RetentionPolicy {
22    fn default() -> Self {
23        Self {
24            max_sessions: 50,
25            max_age_days: 30,
26        }
27    }
28}
29
30/// Apply the retention policy, removing the oldest / stale sessions.
31///
32/// Returns the number of sessions removed. This bounds the otherwise
33/// unbounded growth of `.vtcode/sessions/` so overhead does not accumulate
34/// on disk across a long-lived agent.
35pub fn apply_retention(
36    workspace: &Path,
37    policy: RetentionPolicy,
38) -> Result<usize, SessionStoreError> {
39    let root = sessions_root(workspace);
40    if !root.exists() {
41        return Ok(0);
42    }
43    let mut sessions: Vec<SessionSummary> = crate::query::recent_sessions(workspace, usize::MAX);
44    let mut removed = 0usize;
45
46    // Phase 1: evict oldest sessions beyond the count cap.
47    if sessions.len() > policy.max_sessions {
48        sessions.sort_by(|a, b| a.updated_at.cmp(&b.updated_at));
49        let to_remove = sessions.len() - policy.max_sessions;
50        for s in sessions.iter().take(to_remove) {
51            remove_session(&root.join(&s.session_id))?;
52            removed += 1;
53        }
54        // Drop evicted entries so phase 2 doesn't double-remove.
55        sessions.drain(..to_remove);
56    }
57
58    // Phase 2: evict sessions older than max_age_days (regardless of count).
59    let cutoff = age_cutoff(policy.max_age_days);
60    for s in &sessions {
61        if older_than(s.updated_at.as_str(), cutoff) {
62            remove_session(&root.join(&s.session_id))?;
63            removed += 1;
64        }
65    }
66
67    Ok(removed)
68}
69
70/// Remove the legacy `history/` and `logs/` directories after they have been
71/// imported into the unified store by [`crate::migrate_legacy`].
72///
73/// Returns the number of bytes freed. The legacy `checkpoints/` directory is
74/// intentionally left in place until `/revert` is rewired to the unified
75/// store; callers should confirm revert behavior before deleting it manually.
76pub fn gc_legacy(workspace: &Path) -> Result<u64, SessionStoreError> {
77    let vt = workspace.join(".vtcode");
78    let mut freed = 0u64;
79    for name in ["history", "logs"] {
80        let dir = vt.join(name);
81        if dir.exists() {
82            freed += dir_size(&dir);
83            std::fs::remove_dir_all(&dir).map_err(|e| SessionStoreError::io(dir.clone(), e))?;
84        }
85    }
86    Ok(freed)
87}
88
89fn remove_session(dir: &Path) -> Result<(), SessionStoreError> {
90    if dir.exists() {
91        std::fs::remove_dir_all(dir).map_err(|e| SessionStoreError::io(dir.to_path_buf(), e))?;
92    }
93    Ok(())
94}
95
96fn dir_size(dir: &Path) -> u64 {
97    WalkDir::new(dir)
98        .into_iter()
99        .filter_map(Result::ok)
100        .filter(|e| e.file_type().is_file())
101        .filter_map(|e| e.metadata().ok())
102        .map(|m| m.len())
103        .sum()
104}
105
106fn age_cutoff(max_age_days: u64) -> SystemTime {
107    SystemTime::now() - Duration::from_secs(max_age_days * 24 * 3600)
108}
109
110fn older_than(rfc3339: &str, cutoff: SystemTime) -> bool {
111    let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) else {
112        return false;
113    };
114    let cutoff_secs = cutoff
115        .duration_since(UNIX_EPOCH)
116        .map(|d| d.as_secs() as i64)
117        .unwrap_or(i64::MAX);
118    dt.timestamp() < cutoff_secs
119}