Skip to main content

workspace/
gc.rs

1use crate::cow::parse_base_project_name;
2use crate::state::{read_state, write_state};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime};
6use tracing::{debug, info};
7
8#[derive(Debug, Clone, Default)]
9pub struct GcReport {
10    pub total_workspaces_scanned: usize,
11    pub caches_trimmed_bytes: u64,
12    pub workspaces_deleted: usize,
13    pub workspaces_deleted_bytes: u64,
14    pub remaining_disk_bytes: u64,
15}
16
17/// Result of a CAS garbage-collection pass.
18#[derive(Debug, Clone, Default)]
19pub struct CasGcReport {
20    pub objects_deleted: usize,
21    pub bytes_freed: u64,
22    pub remaining_bytes: u64,
23}
24
25#[derive(Debug, Clone)]
26pub struct WorkspaceMetadata {
27    pub path: PathBuf,
28    pub name: String,
29    pub last_used_at: SystemTime,
30    pub size_bytes: u64,
31    pub is_canonical: bool,
32}
33
34/// Calculate total size of a directory recursively in bytes.
35pub fn calculate_dir_size(path: &Path) -> u64 {
36    let mut total = 0u64;
37    if let Ok(entries) = fs::read_dir(path) {
38        for entry in entries.flatten() {
39            if let Ok(meta) = entry.metadata() {
40                if meta.is_dir() {
41                    total += calculate_dir_size(&entry.path());
42                } else {
43                    total += meta.len();
44                }
45            }
46        }
47    }
48    total
49}
50
51/// Update workspace `last_used_at` timestamp.
52pub fn touch_workspace(workspace_dir: &Path, _project_name: &str) {
53    if let Some(mut state) = read_state(workspace_dir) {
54        state.last_installed_at = SystemTime::now();
55        let _ = write_state(workspace_dir, &state);
56    }
57
58    // Write / update .farhand-last-used marker file
59    let stamp = workspace_dir.join(".farhand-last-used");
60    let _ = fs::write(stamp, format!("{:?}", SystemTime::now()));
61}
62
63/// Retrieve last used timestamp for a workspace.
64pub fn get_workspace_last_used(workspace_dir: &Path) -> SystemTime {
65    if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-last-used")) {
66        if let Ok(mtime) = meta.modified() {
67            return mtime;
68        }
69    }
70
71    if let Some(state) = read_state(workspace_dir) {
72        return state.last_installed_at;
73    }
74
75    if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-state.json")) {
76        if let Ok(mtime) = meta.modified() {
77            return mtime;
78        }
79    }
80
81    if let Ok(meta) = fs::metadata(workspace_dir) {
82        if let Ok(mtime) = meta.modified() {
83            return mtime;
84        }
85    }
86
87    SystemTime::UNIX_EPOCH
88}
89
90/// Soft pruning: trims volatile compiler caches (e.g. incremental caches, node_modules/.cache).
91/// Keeps installed dependencies and build artifacts intact.
92pub fn trim_workspace_caches(workspace_dir: &Path) -> u64 {
93    let cache_dirs = [
94        "target/debug/incremental",
95        "target/release/incremental",
96        "node_modules/.cache",
97        ".next/cache",
98        "DerivedData",
99        ".gradle/caches",
100    ];
101
102    let mut freed_bytes = 0u64;
103    for rel_dir in cache_dirs {
104        let dir = workspace_dir.join(rel_dir);
105        if dir.is_dir() {
106            let size = calculate_dir_size(&dir);
107            if fs::remove_dir_all(&dir).is_ok() {
108                freed_bytes += size;
109                info!("Trimmed cache {} (freed {} bytes)", dir.display(), size);
110            }
111        }
112    }
113
114    freed_bytes
115}
116
117/// Scan all workspace directories under `workspaces_root`.
118pub fn scan_workspaces(workspaces_root: &Path) -> Vec<WorkspaceMetadata> {
119    let mut workspaces = Vec::new();
120
121    let entries = match fs::read_dir(workspaces_root) {
122        Ok(e) => e,
123        Err(_) => return workspaces,
124    };
125
126    for entry in entries.flatten() {
127        let path = entry.path();
128        if path.is_dir() {
129            let file_name = path
130                .file_name()
131                .and_then(|s| s.to_str())
132                .unwrap_or("")
133                .to_string();
134
135            // Skip non-workspace infrastructure dirs (CAS storage, state).
136            if file_name == "cas" || file_name.starts_with('.') {
137                continue;
138            }
139
140            let last_used = get_workspace_last_used(&path);
141            let size = calculate_dir_size(&path);
142
143            // A workspace is canonical if it does not have branch separators in its name
144            // or explicitly matches main/master
145            let is_canonical = parse_base_project_name(&file_name).is_none()
146                || file_name.contains("__main")
147                || file_name.contains("__master")
148                || file_name.contains(":main")
149                || file_name.contains(":master");
150
151            workspaces.push(WorkspaceMetadata {
152                path,
153                name: file_name,
154                last_used_at: last_used,
155                size_bytes: size,
156                is_canonical,
157            });
158        }
159    }
160
161    workspaces
162}
163
164/// Execute automated Garbage Collection across `workspaces_root`.
165///
166/// `skip_locked` decides whether a workspace (by project name) may be
167/// deleted or cache-trimmed — the daemon passes its workspace-lock check so
168/// active runs are never destroyed underneath themselves.
169pub fn run_garbage_collection(
170    workspaces_root: &Path,
171    max_disk_bytes: Option<u64>,
172    ttl: Option<Duration>,
173    skip_locked: &dyn Fn(&str) -> bool,
174) -> GcReport {
175    let mut report = GcReport::default();
176    let mut workspaces = scan_workspaces(workspaces_root);
177    report.total_workspaces_scanned = workspaces.len();
178
179    let now = SystemTime::now();
180
181    // 1. Evict any non-canonical workspace exceeding TTL
182    if let Some(ttl_dur) = ttl {
183        workspaces.retain(|ws| {
184            if !ws.is_canonical {
185                if skip_locked(&ws.name) {
186                    debug!("GC: skipping locked workspace {} (active run)", ws.name);
187                    return true;
188                }
189                if let Ok(age) = now.duration_since(ws.last_used_at) {
190                    if age > ttl_dur {
191                        info!(
192                            "TTL expired for workspace {} (age {:?} > {:?}). Purging...",
193                            ws.path.display(),
194                            age,
195                            ttl_dur
196                        );
197                        let _ = fs::remove_dir_all(&ws.path);
198                        report.workspaces_deleted += 1;
199                        report.workspaces_deleted_bytes += ws.size_bytes;
200                        return false;
201                    }
202                }
203            }
204            true
205        });
206    }
207
208    // 2. Check total storage quota
209    let current_total: u64 = workspaces.iter().map(|w| w.size_bytes).sum();
210    let mut current_usage = current_total;
211
212    if let Some(max_bytes) = max_disk_bytes {
213        if current_usage > max_bytes {
214            // Sort workspaces oldest first (LRU)
215            workspaces.sort_by_key(|a| a.last_used_at);
216
217            // Tier 1: Soft trim caches of non-canonical workspaces
218            for ws in &mut workspaces {
219                if current_usage <= max_bytes {
220                    break;
221                }
222                if !ws.is_canonical && !skip_locked(&ws.name) {
223                    let trimmed = trim_workspace_caches(&ws.path);
224                    report.caches_trimmed_bytes += trimmed;
225                    current_usage = current_usage.saturating_sub(trimmed);
226                    ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
227                }
228            }
229
230            // Tier 2: Hard eviction of non-canonical workspaces (oldest first)
231            for ws in &workspaces {
232                if current_usage <= max_bytes {
233                    break;
234                }
235                if !ws.is_canonical && !skip_locked(&ws.name) {
236                    info!(
237                        "Quota exceeded. Purging LRU workspace {} (size {} bytes)...",
238                        ws.path.display(),
239                        ws.size_bytes
240                    );
241                    let _ = fs::remove_dir_all(&ws.path);
242                    report.workspaces_deleted += 1;
243                    report.workspaces_deleted_bytes += ws.size_bytes;
244                    current_usage = current_usage.saturating_sub(ws.size_bytes);
245                }
246            }
247        }
248    }
249
250    report.remaining_disk_bytes = current_usage;
251    report
252}
253
254/// Emergency disk cleanup pass: trims caches in non-canonical workspaces,
255/// and if needed, evicts oldest non-canonical workspaces until target_bytes_to_free is reached.
256///
257/// `skip_locked` protects workspaces with active runs (same contract as
258/// [`run_garbage_collection`]).
259pub fn run_emergency_disk_gc(
260    workspaces_root: &Path,
261    target_bytes_to_free: u64,
262    skip_locked: &dyn Fn(&str) -> bool,
263) -> GcReport {
264    let mut report = GcReport::default();
265    let mut workspaces = scan_workspaces(workspaces_root);
266    report.total_workspaces_scanned = workspaces.len();
267
268    // Sort LRU: oldest last_used_at first
269    workspaces.sort_by_key(|w| w.last_used_at);
270
271    let mut freed_bytes = 0u64;
272
273    // Phase 1: Trim incremental / compiler caches in non-canonical workspaces
274    for ws in &mut workspaces {
275        if freed_bytes >= target_bytes_to_free {
276            break;
277        }
278        if !ws.is_canonical && !skip_locked(&ws.name) {
279            let trimmed = trim_workspace_caches(&ws.path);
280            report.caches_trimmed_bytes += trimmed;
281            freed_bytes += trimmed;
282            ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
283        }
284    }
285
286    // Phase 2: Purge oldest non-canonical workspaces if still below target
287    for ws in &workspaces {
288        if freed_bytes >= target_bytes_to_free {
289            break;
290        }
291        if !ws.is_canonical && !skip_locked(&ws.name) {
292            info!(
293                "Emergency GC: Purging LRU workspace {} (size {} bytes)...",
294                ws.path.display(),
295                ws.size_bytes
296            );
297            let _ = fs::remove_dir_all(&ws.path);
298            report.workspaces_deleted += 1;
299            report.workspaces_deleted_bytes += ws.size_bytes;
300            freed_bytes += ws.size_bytes;
301        }
302    }
303
304    report.remaining_disk_bytes = freed_bytes;
305    report
306}
307
308/// Garbage-collect the content-addressable store.
309///
310/// CAS is a **cache**: an evicted object is simply re-uploaded on the next
311/// manifest mismatch, so retention is a throughput trade, never a correctness
312/// one. Policy:
313/// 1. TTL — objects whose mtime is older than `ttl` are removed. Hydration
314///    touches object mtimes (see `CasStore::materialize_to`), so this means
315///    "unused since", not "ingested at".
316/// 2. Quota — if the store exceeds `max_bytes`, oldest-mtime objects are
317///    evicted first (LRU).
318/// 3. Stale `.tmp` files (aborted `put_file` uploads) are removed after an
319///    hour regardless of quota.
320pub fn gc_cas(
321    cas_objects_dir: &Path,
322    max_bytes: Option<u64>,
323    ttl: Option<Duration>,
324) -> CasGcReport {
325    let mut report = CasGcReport::default();
326
327    let mut objects: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
328    for entry in walkdir::WalkDir::new(cas_objects_dir)
329        .follow_links(false)
330        .into_iter()
331        .flatten()
332    {
333        let path = entry.path();
334        if !path.is_file() {
335            continue;
336        }
337        let meta = match entry.metadata() {
338            Ok(m) => m,
339            Err(_) => continue,
340        };
341        objects.push((
342            path.to_path_buf(),
343            meta.len(),
344            meta.modified().unwrap_or(SystemTime::now()),
345        ));
346    }
347
348    let now = SystemTime::now();
349    let mut remaining: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
350    let is_tmp = |p: &Path| {
351        p.file_name()
352            .and_then(|s| s.to_str())
353            .map(|s| s.contains(".tmp."))
354            .unwrap_or(false)
355    };
356
357    for (path, size, mtime) in objects {
358        let age = now.duration_since(mtime).unwrap_or_default();
359        let stale_tmp = is_tmp(&path) && age > Duration::from_secs(3600);
360        let expired = ttl.map(|t| age > t).unwrap_or(false);
361
362        if stale_tmp || expired {
363            if fs::remove_file(&path).is_ok() {
364                report.objects_deleted += 1;
365                report.bytes_freed += size;
366            } else {
367                remaining.push((path, size, mtime));
368            }
369        } else {
370            remaining.push((path, size, mtime));
371        }
372    }
373
374    // Quota: evict oldest-touched first.
375    if let Some(max_bytes) = max_bytes {
376        let mut total: u64 = remaining.iter().map(|(_, s, _)| *s).sum();
377        if total > max_bytes {
378            remaining.sort_by_key(|(_, _, mtime)| *mtime);
379            let mut survivors = Vec::new();
380            for (path, size, mtime) in remaining {
381                if total > max_bytes && fs::remove_file(&path).is_ok() {
382                    report.objects_deleted += 1;
383                    report.bytes_freed += size;
384                    total = total.saturating_sub(size);
385                } else {
386                    survivors.push((path, size, mtime));
387                }
388            }
389            remaining = survivors;
390        }
391    }
392
393    report.remaining_bytes = remaining.iter().map(|(_, s, _)| *s).sum();
394    report
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use tempfile::tempdir;
401
402    #[test]
403    fn test_trim_workspace_caches() {
404        let temp = tempdir().unwrap();
405        let ws = temp.path().join("my-ws");
406
407        let cache_dir = ws.join("target/debug/incremental");
408        fs::create_dir_all(&cache_dir).unwrap();
409        fs::write(cache_dir.join("cache.dat"), "1234567890").unwrap();
410
411        let src_file = ws.join("src/main.rs");
412        fs::create_dir_all(ws.join("src")).unwrap();
413        fs::write(&src_file, "fn main() {}").unwrap();
414
415        let freed = trim_workspace_caches(&ws);
416        assert!(freed >= 10);
417        assert!(!cache_dir.exists());
418        assert!(src_file.exists());
419    }
420
421    #[test]
422    fn test_run_garbage_collection_quota_eviction() {
423        let temp = tempdir().unwrap();
424        let root = temp.path();
425
426        // 1. Canonical workspace (should NOT be deleted)
427        let main_ws = root.join("my-repo__main-12345678");
428        fs::create_dir_all(&main_ws).unwrap();
429        fs::write(main_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
430
431        // 2. Old feature branch workspace
432        let feat_ws = root.join("my-repo__feat1-87654321");
433        fs::create_dir_all(&feat_ws).unwrap();
434        fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
435
436        // Quota is 1200 bytes, total is ~2000 bytes
437        let report = run_garbage_collection(root, Some(1200), None, &|_| false);
438
439        assert_eq!(report.workspaces_deleted, 1);
440        assert!(!feat_ws.exists());
441        assert!(main_ws.exists(), "Canonical workspace must be preserved");
442    }
443
444    #[test]
445    fn test_run_garbage_collection_skips_locked_workspaces() {
446        let temp = tempdir().unwrap();
447        let root = temp.path();
448
449        let feat_ws = root.join("my-repo__feat1-87654321");
450        fs::create_dir_all(&feat_ws).unwrap();
451        fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
452
453        // Quota forces eviction, but the workspace's project is locked
454        // (an active run holds it) — GC must leave it alone.
455        let report = run_garbage_collection(root, Some(500), None, &|name| {
456            name.starts_with("my-repo__feat1")
457        });
458
459        assert_eq!(report.workspaces_deleted, 0);
460        assert!(feat_ws.exists(), "locked workspace must survive GC");
461    }
462
463    #[test]
464    fn test_run_emergency_disk_gc() {
465        let temp = tempdir().unwrap();
466        let root = temp.path();
467
468        let feat_ws = root.join("my-repo__feat1-87654321");
469        fs::create_dir_all(&feat_ws).unwrap();
470        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
471
472        let report = run_emergency_disk_gc(root, 1000, &|_| false);
473        assert_eq!(report.workspaces_deleted, 1);
474        assert!(!feat_ws.exists());
475    }
476
477    #[test]
478    fn test_run_emergency_disk_gc_skips_locked() {
479        let temp = tempdir().unwrap();
480        let root = temp.path();
481
482        let feat_ws = root.join("my-repo__feat1-87654321");
483        fs::create_dir_all(&feat_ws).unwrap();
484        fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
485
486        let report = run_emergency_disk_gc(root, 1000, &|_| true);
487        assert_eq!(report.workspaces_deleted, 0);
488        assert!(feat_ws.exists());
489    }
490}
491
492#[cfg(test)]
493mod cas_gc_tests {
494    use super::*;
495    use std::time::Duration;
496
497    /// Build a fake CAS object layout: cas_objects_dir/ab/cd/<hash>.
498    fn make_object(dir: &Path, hash: &str, size: usize, age_secs: u64) -> PathBuf {
499        let path = dir.join(&hash[..2]).join(&hash[2..4]).join(hash);
500        fs::create_dir_all(path.parent().unwrap()).unwrap();
501        fs::write(&path, vec![0u8; size]).unwrap();
502        let old = SystemTime::now() - Duration::from_secs(age_secs);
503        let f = fs::File::options().write(true).open(&path).unwrap();
504        f.set_modified(old).unwrap();
505        path
506    }
507
508    #[test]
509    fn test_gc_cas_ttl_evicts_unused_objects() {
510        let dir = tempfile::tempdir().unwrap();
511        let fresh = make_object(dir.path(), "aa11fresh_object_1", 100, 0);
512        let stale = make_object(dir.path(), "bb22stale_object_2", 100, 40 * 86400);
513
514        let report = gc_cas(dir.path(), None, Some(Duration::from_secs(30 * 86400)));
515
516        assert_eq!(report.objects_deleted, 1);
517        assert_eq!(report.bytes_freed, 100);
518        assert!(fresh.is_file(), "fresh object must survive");
519        assert!(!stale.exists(), "stale object must be evicted");
520    }
521
522    #[test]
523    fn test_gc_cas_quota_evicts_lru_first() {
524        let dir = tempfile::tempdir().unwrap();
525        let old = make_object(dir.path(), "cc33old_object_1111", 400, 86400);
526        let new = make_object(dir.path(), "dd44new_object_1111", 400, 1);
527
528        // Quota 500 bytes, store has 800 → oldest (old) must go first.
529        let report = gc_cas(dir.path(), Some(500), None);
530
531        assert_eq!(report.objects_deleted, 1);
532        assert!(!old.exists(), "oldest object evicted under quota");
533        assert!(new.is_file(), "newest object survives under quota");
534    }
535
536    #[test]
537    fn test_gc_cas_removes_stale_tmp_files() {
538        let dir = tempfile::tempdir().unwrap();
539        let tmp = dir.path().join("ab").join("cd");
540        fs::create_dir_all(&tmp).unwrap();
541        let tmp_file = tmp.join("abcddeadbeef.tmp.12345.7");
542        fs::write(&tmp_file, vec![0u8; 50]).unwrap();
543        let old = SystemTime::now() - Duration::from_secs(7200);
544        let f = fs::File::options().write(true).open(&tmp_file).unwrap();
545        f.set_modified(old).unwrap();
546
547        let report = gc_cas(dir.path(), None, None);
548
549        assert!(!tmp_file.exists(), "stale tmp file must be cleaned");
550        assert_eq!(report.objects_deleted, 1);
551    }
552
553    #[test]
554    fn test_scan_workspaces_skips_cas_dir() {
555        let root = tempfile::tempdir().unwrap();
556        fs::create_dir_all(root.path().join("myrepo")).unwrap();
557        fs::create_dir_all(root.path().join("cas").join("objects")).unwrap();
558
559        let scanned = scan_workspaces(root.path());
560        let names: Vec<&str> = scanned.iter().map(|w| w.name.as_str()).collect();
561        assert_eq!(
562            names,
563            vec!["myrepo"],
564            "cas dir must not count as a workspace"
565        );
566    }
567}