vtcode_memory/
retention.rs1use 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#[derive(Debug, Clone, Copy)]
20pub struct RetentionPolicy {
21 pub max_sessions: usize,
23 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
33pub fn apply_retention(workspace: &Path, policy: RetentionPolicy) -> Result<usize, SessionStoreError> {
39 apply_retention_preserving(workspace, policy, None)
40}
41
42pub 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 mark_abandoned_active_sessions(workspace, policy.max_age_days, preserve_session_id)?;
66 let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
67 let mut sessions = retention_candidates(&root, preserve_path.as_deref())?;
68 let mut removed = 0usize;
69
70 if sessions.len() > policy.max_sessions {
72 sessions.sort_by(|a, b| a.summary.updated_at.cmp(&b.summary.updated_at));
73 let to_remove = sessions.len() - policy.max_sessions;
74 for s in sessions.iter().take(to_remove) {
75 remove_session(&root, &s.path)?;
76 removed += 1;
77 }
78 sessions.drain(..to_remove);
80 }
81
82 let cutoff = age_cutoff(policy.max_age_days);
84 for s in &sessions {
85 if older_than(s.summary.updated_at.as_str(), cutoff) {
86 remove_session(&root, &s.path)?;
87 removed += 1;
88 }
89 }
90
91 Ok(removed)
92}
93
94pub const RETENTION_PIN_FILE: &str = "retention-pin.json";
97
98#[must_use]
100pub fn session_retention_pinned(session_dir: &Path) -> bool {
101 session_dir.join(RETENTION_PIN_FILE).is_file()
102}
103
104pub fn pin_session_retention(session_dir: &Path, reason: &str) -> Result<(), SessionStoreError> {
106 let path = session_dir.join(RETENTION_PIN_FILE);
107 let body = serde_json::json!({
108 "reason": reason,
109 "pinned_at": chrono::Utc::now().to_rfc3339(),
110 });
111 std::fs::write(&path, body.to_string()).map_err(|e| SessionStoreError::io(path, e))
112}
113
114pub fn unpin_session_retention(session_dir: &Path) -> Result<bool, SessionStoreError> {
116 let path = session_dir.join(RETENTION_PIN_FILE);
117 if !path.is_file() {
118 return Ok(false);
119 }
120 std::fs::remove_file(&path).map_err(|e| SessionStoreError::io(path, e))?;
121 Ok(true)
122}
123
124fn retention_candidates(
128 root: &Path,
129 preserve_path: Option<&Path>,
130) -> Result<Vec<RetentionCandidate>, SessionStoreError> {
131 let entries = std::fs::read_dir(root).map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
132 let mut candidates = Vec::new();
133 for entry in entries {
134 let entry = entry.map_err(|e| SessionStoreError::io(root.to_path_buf(), e))?;
135 let file_type = entry.file_type().map_err(|e| SessionStoreError::io(entry.path(), e))?;
136 if !file_type.is_dir() || file_type.is_symlink() {
137 continue;
138 }
139 let path = entry.path();
140 if preserve_path.is_some_and(|preserve_path| preserve_path == path) {
141 continue;
142 }
143 if session_retention_pinned(&path) {
145 continue;
146 }
147 let manifest_path = path.join("manifest.json");
148 let Ok(bytes) = std::fs::read(&manifest_path) else {
149 continue;
150 };
151 let Ok(summary) = serde_json::from_slice::<SessionSummary>(&bytes) else {
152 continue;
153 };
154 if summary.status == "active" {
155 continue;
156 }
157 candidates.push(RetentionCandidate { path, summary });
158 }
159 Ok(candidates)
160}
161
162pub fn mark_abandoned_active_sessions(
171 workspace: &Path,
172 max_age_days: u64,
173 preserve_session_id: Option<&str>,
174) -> Result<usize, SessionStoreError> {
175 if max_age_days == 0 {
176 return Ok(0);
177 }
178 let root = sessions_root(workspace);
179 let preserve_path = preserve_session_id.map(|session_id| crate::session_dir(workspace, session_id));
180 let Ok(entries) = std::fs::read_dir(&root) else {
181 return Ok(0);
182 };
183 let cutoff = age_cutoff(max_age_days);
184 let mut marked = 0usize;
185 for entry in entries.flatten() {
186 let path = entry.path();
187 let file_type = match entry.file_type() {
188 Ok(file_type) => file_type,
189 Err(_) => continue,
190 };
191 if !file_type.is_dir() || file_type.is_symlink() {
194 continue;
195 }
196 if preserve_path.as_deref() == Some(path.as_path()) {
197 continue;
198 }
199 if session_retention_pinned(&path) {
200 continue;
201 }
202 let manifest_path = path.join("manifest.json");
203 let Ok(bytes) = std::fs::read(&manifest_path) else {
204 continue;
205 };
206 let Ok(mut summary) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
207 continue;
208 };
209 let is_active = summary.get("status").and_then(serde_json::Value::as_str) == Some("active");
210 if !is_active {
211 continue;
212 }
213 let updated_at = summary
214 .get("updated_at")
215 .and_then(serde_json::Value::as_str)
216 .unwrap_or_default();
217 if !older_than(updated_at, cutoff) {
218 continue;
219 }
220 if let Some(object) = summary.as_object_mut() {
221 object.insert("status".to_string(), serde_json::Value::String("completed".to_string()));
222 } else {
223 continue;
224 }
225 let body = serde_json::to_vec_pretty(&summary)
226 .map_err(|error| SessionStoreError::io(manifest_path.clone(), std::io::Error::other(error)))?;
227 let temp = manifest_path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4()));
230 std::fs::write(&temp, &body).map_err(|e| SessionStoreError::io(temp.clone(), e))?;
231 if let Err(error) = std::fs::rename(&temp, &manifest_path) {
232 let _ = std::fs::remove_file(&temp);
233 return Err(SessionStoreError::io(manifest_path.clone(), error));
234 }
235 marked += 1;
236 }
237 Ok(marked)
238}
239
240pub fn gc_legacy(workspace: &Path) -> Result<u64, SessionStoreError> {
247 let vt = workspace.join(".vtcode");
248 let mut freed = 0u64;
249 for name in ["history", "logs"] {
250 let dir = vt.join(name);
251 if dir.exists() {
252 freed += dir_size(&dir);
253 std::fs::remove_dir_all(&dir).map_err(|e| SessionStoreError::io(dir.clone(), e))?;
254 }
255 }
256 Ok(freed)
257}
258
259fn remove_session(root: &Path, dir: &Path) -> Result<(), SessionStoreError> {
260 if dir.parent() != Some(root) {
263 return Ok(());
264 }
265 let metadata = match std::fs::symlink_metadata(dir) {
266 Ok(metadata) => metadata,
267 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
268 Err(error) => return Err(SessionStoreError::io(dir.to_path_buf(), error)),
269 };
270 if metadata.file_type().is_symlink() || !metadata.is_dir() {
271 return Ok(());
272 }
273 std::fs::remove_dir_all(dir).map_err(|e| SessionStoreError::io(dir.to_path_buf(), e))?;
274 Ok(())
275}
276
277fn dir_size(dir: &Path) -> u64 {
278 WalkDir::new(dir)
279 .into_iter()
280 .filter_map(Result::ok)
281 .filter(|e| e.file_type().is_file())
282 .filter_map(|e| e.metadata().ok())
283 .map(|m| m.len())
284 .sum()
285}
286
287fn age_cutoff(max_age_days: u64) -> SystemTime {
288 let seconds = max_age_days.saturating_mul(24 * 3600);
289 SystemTime::now() - Duration::from_secs(seconds)
290}
291
292fn older_than(rfc3339: &str, cutoff: SystemTime) -> bool {
293 let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) else {
294 return false;
295 };
296 let cutoff_secs = cutoff
297 .duration_since(UNIX_EPOCH)
298 .map(|d| d.as_secs() as i64)
299 .unwrap_or(i64::MAX);
300 dt.timestamp() < cutoff_secs
301}