Skip to main content

agent_exec/
gc.rs

1//! Implementation of the `gc` sub-command.
2//!
3//! Traverses all job directories under the resolved root, evaluates each for
4//! eligibility (terminal state + GC timestamp older than the retention window),
5//! and either deletes or reports them.
6
7use anyhow::{Result, anyhow};
8use tracing::debug;
9
10use crate::jobstore::resolve_root;
11use crate::schema::{GcData, GcJobResult, JobStatus, Response};
12
13const DEFAULT_OLDER_THAN: &str = "30d";
14
15/// Options for the `gc` sub-command.
16#[derive(Debug)]
17pub struct GcOpts<'a> {
18    pub root: Option<&'a str>,
19    /// Retention duration string (e.g. "30d", "24h"); None means use default.
20    pub older_than: Option<&'a str>,
21    pub dry_run: bool,
22}
23
24/// Execute `gc`: traverse root, evaluate jobs, delete or report, emit JSON.
25pub fn execute(opts: GcOpts) -> Result<()> {
26    let root = resolve_root(opts.root);
27    let root_str = root.display().to_string();
28
29    let (older_than_str, older_than_source) = match opts.older_than {
30        Some(s) => (s.to_string(), "flag"),
31        None => (DEFAULT_OLDER_THAN.to_string(), "default"),
32    };
33
34    let retention_secs =
35        parse_duration(&older_than_str).ok_or_else(|| anyhow!("invalid duration: {older_than_str}; expected formats: 30d, 24h, 60m, 3600s"))?;
36
37    // Compute the cutoff timestamp as seconds since UNIX epoch.
38    let now_secs = std::time::SystemTime::now()
39        .duration_since(std::time::UNIX_EPOCH)
40        .unwrap_or_default()
41        .as_secs();
42    let cutoff_secs = now_secs.saturating_sub(retention_secs);
43    let cutoff_rfc3339 = format_rfc3339(cutoff_secs);
44
45    debug!(
46        root = %root_str,
47        older_than = %older_than_str,
48        older_than_source,
49        dry_run = opts.dry_run,
50        cutoff = %cutoff_rfc3339,
51        "gc: starting"
52    );
53
54    // If root does not exist, return empty response.
55    if !root.exists() {
56        debug!(root = %root_str, "gc: root does not exist; nothing to collect");
57        Response::new(
58            "gc",
59            GcData {
60                root: root_str,
61                dry_run: opts.dry_run,
62                older_than: older_than_str,
63                older_than_source: older_than_source.to_string(),
64                deleted: 0,
65                skipped: 0,
66                freed_bytes: 0,
67                jobs: vec![],
68            },
69        )
70        .print();
71        return Ok(());
72    }
73
74    let read_dir = std::fs::read_dir(&root)
75        .map_err(|e| anyhow!("failed to read root directory {}: {}", root_str, e))?;
76
77    let mut job_results: Vec<GcJobResult> = Vec::new();
78    let mut deleted_count: u64 = 0;
79    let mut skipped_count: u64 = 0;
80    let mut freed_bytes: u64 = 0;
81
82    for entry in read_dir {
83        let entry = match entry {
84            Ok(e) => e,
85            Err(e) => {
86                debug!(error = %e, "gc: failed to read directory entry; skipping");
87                skipped_count += 1;
88                continue;
89            }
90        };
91
92        let path = entry.path();
93        if !path.is_dir() {
94            continue;
95        }
96
97        let job_id = match path.file_name().and_then(|n| n.to_str()) {
98            Some(n) => n.to_string(),
99            None => {
100                debug!(path = %path.display(), "gc: cannot get dir name; skipping");
101                skipped_count += 1;
102                continue;
103            }
104        };
105
106        // Read state.json — required for eligibility evaluation.
107        let state_path = path.join("state.json");
108        let state = match std::fs::read(&state_path)
109            .ok()
110            .and_then(|b| serde_json::from_slice::<crate::schema::JobState>(&b).ok())
111        {
112            Some(s) => s,
113            None => {
114                debug!(path = %path.display(), "gc: state.json missing or unreadable; skipping");
115                skipped_count += 1;
116                job_results.push(GcJobResult {
117                    job_id,
118                    state: "unknown".to_string(),
119                    action: "skipped".to_string(),
120                    reason: "state_unreadable".to_string(),
121                    bytes: 0,
122                });
123                continue;
124            }
125        };
126
127        let status = state.status();
128
129        // Running jobs are never deleted.
130        if *status == JobStatus::Running {
131            debug!(job_id = %job_id, "gc: running job; skipping");
132            skipped_count += 1;
133            job_results.push(GcJobResult {
134                job_id,
135                state: "running".to_string(),
136                action: "skipped".to_string(),
137                reason: "running".to_string(),
138                bytes: 0,
139            });
140            continue;
141        }
142
143        // Only terminal states are candidates.
144        if !matches!(status, JobStatus::Exited | JobStatus::Killed | JobStatus::Failed) {
145            debug!(job_id = %job_id, status = ?status, "gc: unknown status; skipping");
146            skipped_count += 1;
147            job_results.push(GcJobResult {
148                job_id,
149                state: status.as_str().to_string(),
150                action: "skipped".to_string(),
151                reason: "non_terminal_status".to_string(),
152                bytes: 0,
153            });
154            continue;
155        }
156
157        // Determine the GC timestamp: finished_at preferred, updated_at as fallback.
158        let gc_ts = match state.finished_at.as_deref().or(Some(state.updated_at.as_str())) {
159            Some(ts) if !ts.is_empty() => ts.to_string(),
160            _ => {
161                debug!(job_id = %job_id, "gc: no usable timestamp; skipping");
162                skipped_count += 1;
163                job_results.push(GcJobResult {
164                    job_id,
165                    state: status.as_str().to_string(),
166                    action: "skipped".to_string(),
167                    reason: "no_timestamp".to_string(),
168                    bytes: 0,
169                });
170                continue;
171            }
172        };
173
174        // Compare GC timestamp to cutoff (lexicographic comparison of RFC 3339 UTC strings).
175        if !is_older_than(&gc_ts, &cutoff_rfc3339) {
176            debug!(job_id = %job_id, gc_ts = %gc_ts, cutoff = %cutoff_rfc3339, "gc: too recent; skipping");
177            skipped_count += 1;
178            job_results.push(GcJobResult {
179                job_id,
180                state: status.as_str().to_string(),
181                action: "skipped".to_string(),
182                reason: "too_recent".to_string(),
183                bytes: 0,
184            });
185            continue;
186        }
187
188        // Compute directory size before deletion.
189        let dir_bytes = dir_size_bytes(&path);
190
191        if opts.dry_run {
192            debug!(job_id = %job_id, bytes = dir_bytes, "gc: dry-run would delete");
193            freed_bytes += dir_bytes;
194            job_results.push(GcJobResult {
195                job_id,
196                state: status.as_str().to_string(),
197                action: "would_delete".to_string(),
198                reason: format!("older_than_{older_than_str}"),
199                bytes: dir_bytes,
200            });
201        } else {
202            match std::fs::remove_dir_all(&path) {
203                Ok(()) => {
204                    debug!(job_id = %job_id, bytes = dir_bytes, "gc: deleted");
205                    deleted_count += 1;
206                    freed_bytes += dir_bytes;
207                    job_results.push(GcJobResult {
208                        job_id,
209                        state: status.as_str().to_string(),
210                        action: "deleted".to_string(),
211                        reason: format!("older_than_{older_than_str}"),
212                        bytes: dir_bytes,
213                    });
214                }
215                Err(e) => {
216                    debug!(job_id = %job_id, error = %e, "gc: failed to delete; skipping");
217                    skipped_count += 1;
218                    job_results.push(GcJobResult {
219                        job_id,
220                        state: status.as_str().to_string(),
221                        action: "skipped".to_string(),
222                        reason: format!("delete_failed: {e}"),
223                        bytes: dir_bytes,
224                    });
225                }
226            }
227        }
228    }
229
230    debug!(
231        deleted = deleted_count,
232        skipped = skipped_count,
233        freed_bytes,
234        "gc: complete"
235    );
236
237    Response::new(
238        "gc",
239        GcData {
240            root: root_str,
241            dry_run: opts.dry_run,
242            older_than: older_than_str,
243            older_than_source: older_than_source.to_string(),
244            deleted: deleted_count,
245            skipped: skipped_count,
246            freed_bytes,
247            jobs: job_results,
248        },
249    )
250    .print();
251
252    Ok(())
253}
254
255/// Parse a duration string into seconds.
256///
257/// Supported formats: `30d`, `24h`, `60m`, `3600s`.
258pub fn parse_duration(s: &str) -> Option<u64> {
259    let s = s.trim();
260    if let Some(n) = s.strip_suffix('d') {
261        n.parse::<u64>().ok().map(|v| v * 86_400)
262    } else if let Some(n) = s.strip_suffix('h') {
263        n.parse::<u64>().ok().map(|v| v * 3_600)
264    } else if let Some(n) = s.strip_suffix('m') {
265        n.parse::<u64>().ok().map(|v| v * 60)
266    } else if let Some(n) = s.strip_suffix('s') {
267        n.parse::<u64>().ok()
268    } else {
269        // Plain number treated as seconds.
270        s.parse::<u64>().ok()
271    }
272}
273
274/// Return true when `ts` represents a point in time strictly before `cutoff`.
275///
276/// Both `ts` and `cutoff` must be RFC 3339 UTC strings produced by
277/// `format_rfc3339` (format: `YYYY-MM-DDTHH:MM:SSZ`).  Lexicographic
278/// comparison is correct for zero-padded fixed-width UTC ISO 8601 strings.
279fn is_older_than(ts: &str, cutoff: &str) -> bool {
280    // Normalize: compare the first 19 chars (YYYY-MM-DDTHH:MM:SS) only so
281    // that subsecond suffixes and different timezone markers don't break the
282    // comparison.  Both values are UTC so ignoring the suffix is safe.
283    let ts_prefix = &ts[..ts.len().min(19)];
284    let cutoff_prefix = &cutoff[..cutoff.len().min(19)];
285    ts_prefix < cutoff_prefix
286}
287
288/// Recursively compute the total byte size of a directory.
289///
290/// Counts only regular file sizes (metadata size is excluded). Returns 0
291/// if the directory cannot be read.
292pub fn dir_size_bytes(path: &std::path::Path) -> u64 {
293    let mut total = 0u64;
294    let Ok(entries) = std::fs::read_dir(path) else {
295        return 0;
296    };
297    for entry in entries.flatten() {
298        let entry_path = entry.path();
299        if let Ok(meta) = entry_path.metadata() {
300            if meta.is_file() {
301                total += meta.len();
302            } else if meta.is_dir() {
303                total += dir_size_bytes(&entry_path);
304            }
305        }
306    }
307    total
308}
309
310/// Manual conversion of Unix timestamp (seconds) to RFC 3339 UTC string.
311///
312/// Duplicated from `run.rs` to keep `gc` self-contained.
313fn format_rfc3339(secs: u64) -> String {
314    let mut s = secs;
315    let seconds = s % 60;
316    s /= 60;
317    let minutes = s % 60;
318    s /= 60;
319    let hours = s % 24;
320    s /= 24;
321
322    let mut days = s;
323    let mut year = 1970u64;
324    loop {
325        let days_in_year = if is_leap(year) { 366 } else { 365 };
326        if days < days_in_year {
327            break;
328        }
329        days -= days_in_year;
330        year += 1;
331    }
332
333    let leap = is_leap(year);
334    let month_days: [u64; 12] = [
335        31,
336        if leap { 29 } else { 28 },
337        31,
338        30,
339        31,
340        30,
341        31,
342        31,
343        30,
344        31,
345        30,
346        31,
347    ];
348    let mut month = 0usize;
349    for (i, &d) in month_days.iter().enumerate() {
350        if days < d {
351            month = i;
352            break;
353        }
354        days -= d;
355    }
356    let day = days + 1;
357
358    format!(
359        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
360        year,
361        month + 1,
362        day,
363        hours,
364        minutes,
365        seconds
366    )
367}
368
369fn is_leap(year: u64) -> bool {
370    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn parse_duration_days() {
379        assert_eq!(parse_duration("30d"), Some(30 * 86_400));
380        assert_eq!(parse_duration("7d"), Some(7 * 86_400));
381        assert_eq!(parse_duration("1d"), Some(86_400));
382    }
383
384    #[test]
385    fn parse_duration_hours() {
386        assert_eq!(parse_duration("24h"), Some(24 * 3_600));
387        assert_eq!(parse_duration("1h"), Some(3_600));
388    }
389
390    #[test]
391    fn parse_duration_minutes() {
392        assert_eq!(parse_duration("60m"), Some(3_600));
393    }
394
395    #[test]
396    fn parse_duration_seconds() {
397        assert_eq!(parse_duration("3600s"), Some(3_600));
398        assert_eq!(parse_duration("0s"), Some(0));
399    }
400
401    #[test]
402    fn parse_duration_invalid() {
403        assert!(parse_duration("abc").is_none());
404        assert!(parse_duration("").is_none());
405    }
406
407    #[test]
408    fn is_older_than_true() {
409        assert!(is_older_than("2020-01-01T00:00:00Z", "2024-01-01T00:00:00Z"));
410    }
411
412    #[test]
413    fn is_older_than_false_equal() {
414        assert!(!is_older_than("2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z"));
415    }
416
417    #[test]
418    fn is_older_than_false_newer() {
419        assert!(!is_older_than("2025-01-01T00:00:00Z", "2024-01-01T00:00:00Z"));
420    }
421
422    #[test]
423    fn format_rfc3339_epoch() {
424        // UNIX epoch → 1970-01-01T00:00:00Z
425        assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
426    }
427
428    #[test]
429    fn format_rfc3339_known() {
430        // 2024-01-01T00:00:00Z = 1704067200 secs since epoch
431        assert_eq!(format_rfc3339(1_704_067_200), "2024-01-01T00:00:00Z");
432    }
433
434    #[test]
435    fn dir_size_bytes_empty_dir() {
436        let tmp = tempfile::tempdir().unwrap();
437        assert_eq!(dir_size_bytes(tmp.path()), 0);
438    }
439
440    #[test]
441    fn dir_size_bytes_with_file() {
442        let tmp = tempfile::tempdir().unwrap();
443        let file = tmp.path().join("test.txt");
444        std::fs::write(&file, b"hello world").unwrap();
445        assert_eq!(dir_size_bytes(tmp.path()), 11);
446    }
447}