Skip to main content

vtcode_memory/
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#[derive(Debug)]
13struct RetentionCandidate {
14    path: std::path::PathBuf,
15    summary: SessionSummary,
16}
17
18/// Retention policy applied to the set of per-session stores.
19#[derive(Debug, Clone, Copy)]
20pub struct RetentionPolicy {
21    /// Maximum number of sessions to keep (oldest evicted first).
22    pub max_sessions: usize,
23    /// Maximum age of a session in days before eviction.
24    pub max_age_days: u64,
25}
26
27impl Default for RetentionPolicy {
28    fn default() -> Self {
29        Self { max_sessions: 50, max_age_days: 30 }
30    }
31}
32
33/// Apply the retention policy, removing the oldest / stale sessions.
34///
35/// Returns the number of sessions removed. This bounds the otherwise
36/// unbounded growth of `.vtcode/sessions/` so overhead does not accumulate
37/// on disk across a long-lived agent.
38pub fn apply_retention(workspace: &Path, policy: RetentionPolicy) -> Result<usize, SessionStoreError> {
39    apply_retention_preserving(workspace, policy, None)
40}
41
42/// Apply retention while preserving one session directory, even when its
43/// existing manifest still says `completed` (for example, a resumed session).
44///
45/// The preserved path is resolved with the same session-id sanitization as the
46/// canonical store and is compared against the direct child discovered on
47/// disk. It is never taken from a manifest.
48pub fn apply_retention_preserving(
49    workspace: &Path,
50    policy: RetentionPolicy,
51    preserve_session_id: Option<&str>,
52) -> Result<usize, SessionStoreError> {
53    let root = sessions_root(workspace);
54    let root_metadata = match std::fs::symlink_metadata(&root) {
55        Ok(metadata) => metadata,
56        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
57        Err(error) => return Err(SessionStoreError::io(root.clone(), error)),
58    };
59    if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
60        return Ok(0);
61    }
62    let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
63    let mut sessions = retention_candidates(&root, preserve_path.as_deref())?;
64    let mut removed = 0usize;
65
66    // Phase 1: evict oldest sessions beyond the count cap.
67    if sessions.len() > policy.max_sessions {
68        sessions.sort_by(|a, b| a.summary.updated_at.cmp(&b.summary.updated_at));
69        let to_remove = sessions.len() - policy.max_sessions;
70        for s in sessions.iter().take(to_remove) {
71            remove_session(&root, &s.path)?;
72            removed += 1;
73        }
74        // Drop evicted entries so phase 2 doesn't double-remove.
75        sessions.drain(..to_remove);
76    }
77
78    // Phase 2: evict sessions older than max_age_days (regardless of count).
79    let cutoff = age_cutoff(policy.max_age_days);
80    for s in &sessions {
81        if older_than(s.summary.updated_at.as_str(), cutoff) {
82            remove_session(&root, &s.path)?;
83            removed += 1;
84        }
85    }
86
87    Ok(removed)
88}
89
90/// Enumerate session stores from their filesystem entries, never from the
91/// session ID contained in a manifest. This keeps retention confined to
92/// validated direct children of the sessions root.
93fn retention_candidates(
94    root: &Path,
95    preserve_path: Option<&Path>,
96) -> Result<Vec<RetentionCandidate>, SessionStoreError> {
97    let entries = std::fs::read_dir(root).map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
98    let mut candidates = Vec::new();
99    for entry in entries {
100        let entry = entry.map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
101        let file_type = entry.file_type().map_err(|e| SessionStoreError::io(entry.path(), e))?;
102        if !file_type.is_dir() || file_type.is_symlink() {
103            continue;
104        }
105        let path = entry.path();
106        if preserve_path.is_some_and(|preserve_path| preserve_path == path) {
107            continue;
108        }
109        let manifest_path = path.join("manifest.json");
110        let Ok(bytes) = std::fs::read(&manifest_path) else {
111            continue;
112        };
113        let Ok(summary) = serde_json::from_slice::<SessionSummary>(&bytes) else {
114            continue;
115        };
116        if summary.status == "active" {
117            continue;
118        }
119        candidates.push(RetentionCandidate { path, summary });
120    }
121    Ok(candidates)
122}
123
124/// Remove the legacy `history/` and `logs/` directories after they have been
125/// imported into the unified store by [`crate::migrate_legacy`].
126///
127/// Returns the number of bytes freed. The legacy `checkpoints/` directory is
128/// intentionally left in place until `/revert` is rewired to the unified
129/// store; callers should confirm revert behavior before deleting it manually.
130pub fn gc_legacy(workspace: &Path) -> Result<u64, SessionStoreError> {
131    let vt = workspace.join(".vtcode");
132    let mut freed = 0u64;
133    for name in ["history", "logs"] {
134        let dir = vt.join(name);
135        if dir.exists() {
136            freed += dir_size(&dir);
137            std::fs::remove_dir_all(&dir).map_err(|e| SessionStoreError::io(dir.clone(), e))?;
138        }
139    }
140    Ok(freed)
141}
142
143fn remove_session(root: &Path, dir: &Path) -> Result<(), SessionStoreError> {
144    // Retention is allowed to remove only one validated child of the sessions
145    // root. Never trust a manifest-controlled identifier or follow a symlink.
146    if dir.parent() != Some(root) {
147        return Ok(());
148    }
149    let metadata = match std::fs::symlink_metadata(dir) {
150        Ok(metadata) => metadata,
151        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
152        Err(error) => return Err(SessionStoreError::io(dir.to_path_buf(), error)),
153    };
154    if metadata.file_type().is_symlink() || !metadata.is_dir() {
155        return Ok(());
156    }
157    std::fs::remove_dir_all(dir).map_err(|e| SessionStoreError::io(dir.to_path_buf(), e))?;
158    Ok(())
159}
160
161fn dir_size(dir: &Path) -> u64 {
162    WalkDir::new(dir)
163        .into_iter()
164        .filter_map(Result::ok)
165        .filter(|e| e.file_type().is_file())
166        .filter_map(|e| e.metadata().ok())
167        .map(|m| m.len())
168        .sum()
169}
170
171fn age_cutoff(max_age_days: u64) -> SystemTime {
172    let seconds = max_age_days.saturating_mul(24 * 3600);
173    SystemTime::now() - Duration::from_secs(seconds)
174}
175
176fn older_than(rfc3339: &str, cutoff: SystemTime) -> bool {
177    let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) else {
178        return false;
179    };
180    let cutoff_secs = cutoff
181        .duration_since(UNIX_EPOCH)
182        .map(|d| d.as_secs() as i64)
183        .unwrap_or(i64::MAX);
184    dt.timestamp() < cutoff_secs
185}