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