From d432d370c0bb650d2b356babd2a307ce57bddb75 Mon Sep 17 00:00:00 2001 From: "arc-1e68f1[bot]" <265161896+arc-1e68f1[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:38:35 -0400 Subject: [PATCH] Improve `arc system prune` (#14) * arc(01KKA1A9ZHFMWM3T9VC6HBWJ19): toolchain (success) Arc-Run: 01KKA1A9ZHFMWM3T9VC6HBWJ19 Arc-Completed: 2 Arc-Checkpoint: a38cd3755e5a5ad6a406e3557665c3ea6ba06cc7 * arc(01KKA1A9ZHFMWM3T9VC6HBWJ19): preflight_compile (success) Arc-Run: 01KKA1A9ZHFMWM3T9VC6HBWJ19 Arc-Completed: 3 Arc-Checkpoint: 62150d0142e04fad46092b85e5942ac1979da51b * arc(01KKA1A9ZHFMWM3T9VC6HBWJ19): preflight_lint (fail) Arc-Run: 01KKA1A9ZHFMWM3T9VC6HBWJ19 Arc-Completed: 4 Arc-Checkpoint: 2a213ceb22392e2dd1e00c4e66912ed57f0972af * arc(01KKA1A9ZHFMWM3T9VC6HBWJ19): fix_lints (success) Arc-Run: 01KKA1A9ZHFMWM3T9VC6HBWJ19 Arc-Completed: 5 Arc-Checkpoint: e9d720a9e2288664ebea475c4a1445532fde3ed9 * arc(01KKA1A9ZHFMWM3T9VC6HBWJ19): preflight_lint (success) Arc-Run: 01KKA1A9ZHFMWM3T9VC6HBWJ19 Arc-Completed: 6 Arc-Checkpoint: 8ef9490d981389c374970e0374ef48198fb19829 * arc(01KKA1A9ZHFMWM3T9VC6HBWJ19): implement (success) Arc-Run: 01KKA1A9ZHFMWM3T9VC6HBWJ19 Arc-Completed: 7 Arc-Checkpoint: 063ba4fe9554c6e0d16aaad40c4ffc7badbd0511 * arc(01KKA1A9ZHFMWM3T9VC6HBWJ19): simplify (success) Arc-Run: 01KKA1A9ZHFMWM3T9VC6HBWJ19 Arc-Completed: 8 Arc-Checkpoint: 4c1319c2a2d7c818faa460413e7993c93d30750f * arc(01KKA1A9ZHFMWM3T9VC6HBWJ19): verify (success) Arc-Run: 01KKA1A9ZHFMWM3T9VC6HBWJ19 Arc-Completed: 9 Arc-Checkpoint: a01dd1e93b231415be85a1925f90b6fedf1844fc --------- Co-authored-by: arc --- lib/crates/arc-workflows/src/cli/runs.rs | 532 +++++++++++++++++++++-- 1 file changed, 498 insertions(+), 34 deletions(-) diff --git a/lib/crates/arc-workflows/src/cli/runs.rs b/lib/crates/arc-workflows/src/cli/runs.rs index 4ff2a6066..1e33d1cfd 100644 --- a/lib/crates/arc-workflows/src/cli/runs.rs +++ b/lib/crates/arc-workflows/src/cli/runs.rs @@ -1,11 +1,40 @@ use std::collections::HashMap; +use std::fmt; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; +use chrono::{DateTime, Utc}; use clap::Args; use serde::Serialize; use tracing::{debug, info, warn}; +use crate::outcome::StageStatus; + +/// Status of a run directory — either concluded with a `StageStatus`, actively running, or unknown. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RunStatus { + Concluded(StageStatus), + Running, + Unknown, +} + +impl RunStatus { + pub fn is_running(&self) -> bool { + matches!(self, RunStatus::Running) + } +} + +impl fmt::Display for RunStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RunStatus::Concluded(s) => write!(f, "{s}"), + RunStatus::Running => write!(f, "running"), + RunStatus::Unknown => write!(f, "unknown"), + } + } +} + #[derive(Args)] pub struct RunFilterArgs { /// Only include runs started before this date (YYYY-MM-DD prefix match) @@ -40,6 +69,10 @@ pub struct RunsPruneArgs { #[command(flatten)] pub filter: RunFilterArgs, + /// Only prune runs older than this duration (e.g. 24h, 7d). Default: 24h when no explicit filters are set. + #[arg(long, value_name = "DURATION", value_parser = parse_duration)] + pub older_than: Option, + /// Actually delete (default is dry-run) #[arg(long)] pub yes: bool, @@ -50,10 +83,14 @@ pub struct RunInfo { pub run_id: String, pub dir_name: String, pub workflow_name: String, - pub status: String, + pub status: RunStatus, pub start_time: String, pub labels: HashMap, #[serde(skip)] + pub start_time_dt: Option>, + #[serde(skip)] + pub end_time: Option>, + #[serde(skip)] pub path: PathBuf, #[serde(skip)] pub is_orphan: bool, @@ -85,10 +122,11 @@ pub fn scan_runs(base: &Path) -> Result> { let run_id = manifest.run_id; let workflow_name = manifest.workflow_name; - let start_time = manifest.start_time.to_rfc3339(); + let start_time_dt = manifest.start_time; + let start_time = start_time_dt.to_rfc3339(); let labels = manifest.labels; - let status = read_status(&path); + let (status, end_time) = read_status(&path); runs.push(RunInfo { run_id, @@ -97,28 +135,31 @@ pub fn scan_runs(base: &Path) -> Result> { status, start_time, labels, + start_time_dt: Some(start_time_dt), + end_time, path, is_orphan: false, }); } else { // Orphan directory — no manifest.json - let mtime = entry + let mtime_dt = entry .metadata() .ok() .and_then(|m| m.modified().ok()) - .map(|t| { - let dt: chrono::DateTime = t.into(); - dt.to_rfc3339() - }) + .map(|t| -> DateTime { t.into() }); + let mtime = mtime_dt + .map(|dt| dt.to_rfc3339()) .unwrap_or_default(); runs.push(RunInfo { run_id: dir_name.clone(), dir_name, workflow_name: "[no manifest]".to_string(), - status: "unknown".to_string(), + status: RunStatus::Unknown, start_time: mtime, labels: HashMap::new(), + start_time_dt: mtime_dt, + end_time: None, path, is_orphan: true, }); @@ -130,14 +171,14 @@ pub fn scan_runs(base: &Path) -> Result> { Ok(runs) } -fn read_status(run_dir: &Path) -> String { +fn read_status(run_dir: &Path) -> (RunStatus, Option>) { if let Ok(conclusion) = crate::conclusion::Conclusion::load(&run_dir.join("conclusion.json")) { - return conclusion.status.to_string(); + return (RunStatus::Concluded(conclusion.status), Some(conclusion.timestamp)); } if run_dir.join("run.pid").exists() { - return "running".to_string(); + return (RunStatus::Running, None); } - "unknown".to_string() + (RunStatus::Unknown, None) } /// Filter runs by criteria. Orphans are excluded unless `include_orphans` is true. @@ -326,8 +367,8 @@ pub fn df_from(args: &DfArgs, data_dir: &Path, logs_base: &Path) -> Result<()> { struct RunSizeInfo { run_id: String, workflow_name: String, - status: String, - start_time: String, + status: RunStatus, + start_time_dt: Option>, size: u64, } @@ -336,7 +377,7 @@ pub fn df_from(args: &DfArgs, data_dir: &Path, logs_base: &Path) -> Result<()> { for run in &runs { let size = dir_size(&run.path); total_run_size += size; - let is_active = run.status == "running"; + let is_active = run.status.is_running(); if is_active { active_count += 1; } else { @@ -347,7 +388,7 @@ pub fn df_from(args: &DfArgs, data_dir: &Path, logs_base: &Path) -> Result<()> { run_id: run.run_id.clone(), workflow_name: run.workflow_name.clone(), status: run.status.clone(), - start_time: run.start_time.clone(), + start_time_dt: run.start_time_dt, size, }); } @@ -450,7 +491,7 @@ pub fn df_from(args: &DfArgs, data_dir: &Path, logs_base: &Path) -> Result<()> { } else { detail.workflow_name.clone() }; - let age = if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&detail.start_time) { + let age = if let Some(dt) = detail.start_time_dt { let dur = now.signed_duration_since(dt); if dur.num_days() > 0 { format!("{}d", dur.num_days()) @@ -462,7 +503,7 @@ pub fn df_from(args: &DfArgs, data_dir: &Path, logs_base: &Path) -> Result<()> { } else { "-".to_string() }; - let reclaimable_marker = if detail.status != "running" { " *" } else { "" }; + let reclaimable_marker = if !detail.status.is_running() { " *" } else { "" }; println!( "{:<30} {:<18} {:<10} {:>5} {:>10}{}", run_id_display, @@ -485,10 +526,27 @@ pub fn prune_command(args: &RunsPruneArgs) -> Result<()> { prune_from(args, &base) } +/// Parse a human duration string like "24h" or "7d" into a `chrono::Duration`. +fn parse_duration(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + bail!("empty duration string"); + } + let (num_str, unit) = s.split_at(s.len() - 1); + let num: i64 = num_str + .parse() + .with_context(|| format!("invalid duration: {s}"))?; + match unit { + "h" => Ok(chrono::Duration::hours(num)), + "d" => Ok(chrono::Duration::days(num)), + _ => bail!("invalid duration unit '{unit}' in '{s}' (expected 'h' or 'd')"), + } +} + pub fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> { let runs = scan_runs(base)?; let label_filters = parse_label_filters(&args.filter.label); - let filtered = filter_runs( + let mut filtered = filter_runs( &runs, args.filter.before.as_deref(), args.filter.workflow.as_deref(), @@ -496,25 +554,66 @@ pub fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> { args.filter.orphans, ); + // Determine if the user passed any explicit filters + let has_explicit_filters = args.filter.before.is_some() + || args.filter.workflow.is_some() + || !args.filter.label.is_empty() + || args.filter.orphans; + + // Apply staleness filter: default 24h when no explicit filters, or use --older-than + let staleness_threshold = if let Some(dur) = args.older_than { + Some(dur) + } else if !has_explicit_filters { + Some(chrono::Duration::hours(24)) + } else { + None + }; + + if let Some(threshold) = staleness_threshold { + let now = Utc::now(); + let cutoff = now - threshold; + filtered.retain(|run| { + // Exclude running runs + if run.status.is_running() { + return false; + } + // Use end_time if available, fall back to start_time + let effective_time = run.end_time.or(run.start_time_dt); + match effective_time { + Some(t) => t < cutoff, + None => false, + } + }); + } + if filtered.is_empty() { eprintln!("No matching runs to prune."); return Ok(()); } + // Calculate total disk space + let total_bytes: u64 = filtered.iter().map(|r| dir_size(&r.path)).sum(); + info!(count = filtered.len(), bytes = total_bytes, "pruning runs"); + if args.yes { for run in &filtered { info!(run_id = %run.run_id, path = %run.path.display(), "deleting run"); std::fs::remove_dir_all(&run.path)?; } - eprintln!("{} run(s) deleted.", filtered.len()); + eprintln!( + "{} run(s) deleted ({} freed).", + filtered.len(), + format_size(total_bytes) + ); } else { for run in &filtered { debug!(run_id = %run.run_id, "would delete run (dry-run)"); println!("would delete: {} ({})", run.dir_name, run.workflow_name); } eprintln!( - "\n{} run(s) would be deleted. Pass --yes to confirm.", - filtered.len() + "\n{} run(s) would be deleted ({} freed). Pass --yes to confirm.", + filtered.len(), + format_size(total_bytes) ); } Ok(()) @@ -584,13 +683,13 @@ mod tests { let completed = runs.iter().find(|r| r.run_id == "abc123").unwrap(); assert_eq!(completed.workflow_name, "my-pipeline"); - assert_eq!(completed.status, "success"); + assert_eq!(completed.status, RunStatus::Concluded(crate::outcome::StageStatus::Success)); assert_eq!(completed.labels.get("env").unwrap(), "prod"); assert!(!completed.is_orphan); let orphan = runs.iter().find(|r| r.is_orphan).unwrap(); assert_eq!(orphan.workflow_name, "[no manifest]"); - assert_eq!(orphan.status, "unknown"); + assert_eq!(orphan.status, RunStatus::Unknown); } #[test] @@ -615,7 +714,7 @@ mod tests { let runs = scan_runs(base).unwrap(); assert_eq!(runs.len(), 1); - assert_eq!(runs[0].status, "running"); + assert_eq!(runs[0].status, RunStatus::Running); } #[test] @@ -638,9 +737,11 @@ mod tests { run_id: "old".into(), dir_name: "d1".into(), workflow_name: "p".into(), - status: "success".into(), + status: RunStatus::Concluded(crate::outcome::StageStatus::Success), start_time: "2025-06-01T00:00:00Z".into(), labels: HashMap::new(), + start_time_dt: None, + end_time: None, path: PathBuf::from("/tmp/d1"), is_orphan: false, }, @@ -648,9 +749,11 @@ mod tests { run_id: "new".into(), dir_name: "d2".into(), workflow_name: "p".into(), - status: "success".into(), + status: RunStatus::Concluded(crate::outcome::StageStatus::Success), start_time: "2026-03-01T00:00:00Z".into(), labels: HashMap::new(), + start_time_dt: None, + end_time: None, path: PathBuf::from("/tmp/d2"), is_orphan: false, }, @@ -667,9 +770,11 @@ mod tests { run_id: "a".into(), dir_name: "d1".into(), workflow_name: "deploy-prod".into(), - status: "success".into(), + status: RunStatus::Concluded(crate::outcome::StageStatus::Success), start_time: "2026-01-01T00:00:00Z".into(), labels: HashMap::new(), + start_time_dt: None, + end_time: None, path: PathBuf::from("/tmp/d1"), is_orphan: false, }, @@ -677,9 +782,11 @@ mod tests { run_id: "b".into(), dir_name: "d2".into(), workflow_name: "test-suite".into(), - status: "success".into(), + status: RunStatus::Concluded(crate::outcome::StageStatus::Success), start_time: "2026-01-01T00:00:00Z".into(), labels: HashMap::new(), + start_time_dt: None, + end_time: None, path: PathBuf::from("/tmp/d2"), is_orphan: false, }, @@ -696,9 +803,11 @@ mod tests { run_id: "a".into(), dir_name: "d1".into(), workflow_name: "p".into(), - status: "success".into(), + status: RunStatus::Concluded(crate::outcome::StageStatus::Success), start_time: "2026-01-01T00:00:00Z".into(), labels: HashMap::from([("env".into(), "prod".into())]), + start_time_dt: None, + end_time: None, path: PathBuf::from("/tmp/d1"), is_orphan: false, }, @@ -706,9 +815,11 @@ mod tests { run_id: "b".into(), dir_name: "d2".into(), workflow_name: "p".into(), - status: "success".into(), + status: RunStatus::Concluded(crate::outcome::StageStatus::Success), start_time: "2026-01-01T00:00:00Z".into(), labels: HashMap::from([("env".into(), "staging".into())]), + start_time_dt: None, + end_time: None, path: PathBuf::from("/tmp/d2"), is_orphan: false, }, @@ -730,9 +841,11 @@ mod tests { run_id: "orphan".into(), dir_name: "d1".into(), workflow_name: "[no manifest]".into(), - status: "unknown".into(), + status: RunStatus::Unknown, start_time: "".into(), labels: HashMap::new(), + start_time_dt: None, + end_time: None, path: PathBuf::from("/tmp/d1"), is_orphan: true, }]; @@ -772,6 +885,7 @@ mod tests { label: Vec::new(), orphans: false, }, + older_than: None, yes: false, }; @@ -826,6 +940,7 @@ mod tests { label: Vec::new(), orphans: false, }, + older_than: None, yes: true, }; @@ -851,6 +966,7 @@ mod tests { label: Vec::new(), orphans: true, }, + older_than: None, yes: true, }; @@ -1056,4 +1172,352 @@ mod tests { "Should mention ambiguity" ); } -} + + // === Step 1: end_time tests === + + #[test] + fn scan_runs_populates_end_time() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + + make_run_dir( + base, + "20260101-DONE", + Some(serde_json::json!({ + "run_id": "done-1", + "workflow_name": "pipeline", + "goal": "", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0 + })), + Some(serde_json::json!({ + "timestamp": "2026-01-01T12:05:00Z", + "status": "success", + "duration_ms": 300000 + })), + false, + ); + + let runs = scan_runs(base).unwrap(); + assert_eq!(runs.len(), 1); + let run = &runs[0]; + assert!(run.end_time.is_some()); + assert_eq!( + run.end_time.unwrap().to_rfc3339(), + "2026-01-01T12:05:00+00:00" + ); + } + + #[test] + fn scan_runs_end_time_none_when_running() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + + make_run_dir( + base, + "20260101-RUNNING", + Some(serde_json::json!({ + "run_id": "running-1", + "workflow_name": "pipeline", + "goal": "", + "start_time": "2026-01-01T12:00:00Z", + "node_count": 1, + "edge_count": 0 + })), + None, + true, + ); + + let runs = scan_runs(base).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].status, RunStatus::Running); + assert!(runs[0].end_time.is_none()); + } + + // === Step 2: staleness heuristic tests === + + #[test] + fn prune_default_skips_recent_runs() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + + // A run completed just now — should NOT be pruned by default + let now = Utc::now(); + let recent_ts = now.to_rfc3339(); + let dir = make_run_dir( + base, + "20260309-RECENT", + Some(serde_json::json!({ + "run_id": "recent-1", + "workflow_name": "pipeline", + "goal": "", + "start_time": &recent_ts, + "node_count": 1, + "edge_count": 0 + })), + Some(serde_json::json!({ + "timestamp": &recent_ts, + "status": "success", + "duration_ms": 1000 + })), + false, + ); + + let args = RunsPruneArgs { + filter: RunFilterArgs { + before: None, + workflow: None, + label: Vec::new(), + orphans: false, + }, + older_than: None, + yes: false, + }; + + prune_from(&args, base).unwrap(); + assert!(dir.exists(), "recent run should not be pruned by default"); + } + + #[test] + fn prune_default_skips_running_runs() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + + let old_ts = (Utc::now() - chrono::Duration::hours(48)).to_rfc3339(); + let dir = make_run_dir( + base, + "20260307-RUNNING", + Some(serde_json::json!({ + "run_id": "running-old", + "workflow_name": "pipeline", + "goal": "", + "start_time": &old_ts, + "node_count": 1, + "edge_count": 0 + })), + None, + true, // running + ); + + let args = RunsPruneArgs { + filter: RunFilterArgs { + before: None, + workflow: None, + label: Vec::new(), + orphans: false, + }, + older_than: None, + yes: false, + }; + + prune_from(&args, base).unwrap(); + assert!(dir.exists(), "running run should not be pruned"); + } + + #[test] + fn prune_default_targets_stale_runs() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + + let old_ts = (Utc::now() - chrono::Duration::hours(48)).to_rfc3339(); + let dir = make_run_dir( + base, + "20260307-STALE", + Some(serde_json::json!({ + "run_id": "stale-1", + "workflow_name": "old-pipeline", + "goal": "", + "start_time": &old_ts, + "node_count": 1, + "edge_count": 0 + })), + Some(serde_json::json!({ + "timestamp": &old_ts, + "status": "success", + "duration_ms": 1000 + })), + false, + ); + + let args = RunsPruneArgs { + filter: RunFilterArgs { + before: None, + workflow: None, + label: Vec::new(), + orphans: false, + }, + older_than: None, + yes: true, + }; + + prune_from(&args, base).unwrap(); + assert!(!dir.exists(), "stale run (48h old) should be pruned by default"); + } + + #[test] + fn prune_explicit_filter_bypasses_staleness() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + + // A run completed just now, but matched by --before + let now = Utc::now(); + let recent_ts = now.to_rfc3339(); + let dir = make_run_dir( + base, + "20260309-RECENT", + Some(serde_json::json!({ + "run_id": "recent-1", + "workflow_name": "pipeline", + "goal": "", + "start_time": "2025-06-01T00:00:00Z", + "node_count": 1, + "edge_count": 0 + })), + Some(serde_json::json!({ + "timestamp": &recent_ts, + "status": "success", + "duration_ms": 1000 + })), + false, + ); + + let args = RunsPruneArgs { + filter: RunFilterArgs { + before: Some("2026-01-01".into()), + workflow: None, + label: Vec::new(), + orphans: false, + }, + older_than: None, + yes: true, + }; + + prune_from(&args, base).unwrap(); + assert!( + !dir.exists(), + "explicit --before should bypass staleness filter" + ); + } + + // === Step 2: parse_duration tests === + + #[test] + fn parse_duration_hours() { + let dur = parse_duration("24h").unwrap(); + assert_eq!(dur, chrono::Duration::hours(24)); + } + + #[test] + fn parse_duration_days() { + let dur = parse_duration("7d").unwrap(); + assert_eq!(dur, chrono::Duration::days(7)); + } + + #[test] + fn parse_duration_invalid() { + assert!(parse_duration("").is_err()); + assert!(parse_duration("abc").is_err()); + } + + // === Step 3: disk space reporting tests === + + #[test] + fn format_size_display() { + assert_eq!(format_size(0), "0 B"); + assert_eq!(format_size(512), "512 B"); + assert_eq!(format_size(1024), "1.0 KB"); + assert_eq!(format_size(1536), "1.5 KB"); + assert_eq!(format_size(1024 * 1024), "1.0 MB"); + assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB"); + assert_eq!(format_size(1024 * 1024 * 1024 + 512 * 1024 * 1024), "1.5 GB"); + } + + // === Step 4: custom threshold test === + + #[test] + fn prune_older_than_custom_threshold() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path(); + + // Run completed 3 days ago — should be pruned with --older-than 2d + let three_days_ago = (Utc::now() - chrono::Duration::days(3)).to_rfc3339(); + let dir_old = make_run_dir( + base, + "20260306-OLD", + Some(serde_json::json!({ + "run_id": "old-1", + "workflow_name": "pipeline", + "goal": "", + "start_time": &three_days_ago, + "node_count": 1, + "edge_count": 0 + })), + Some(serde_json::json!({ + "timestamp": &three_days_ago, + "status": "success", + "duration_ms": 1000 + })), + false, + ); + + // Run completed 1 day ago — should NOT be pruned with --older-than 2d + let one_day_ago = (Utc::now() - chrono::Duration::days(1)).to_rfc3339(); + let dir_recent = make_run_dir( + base, + "20260308-RECENT", + Some(serde_json::json!({ + "run_id": "recent-1", + "workflow_name": "pipeline", + "goal": "", + "start_time": &one_day_ago, + "node_count": 1, + "edge_count": 0 + })), + Some(serde_json::json!({ + "timestamp": &one_day_ago, + "status": "success", + "duration_ms": 1000 + })), + false, + ); + + // Run completed 10 days ago — should be pruned with --older-than 7d + let ten_days_ago = (Utc::now() - chrono::Duration::days(10)).to_rfc3339(); + let dir_very_old = make_run_dir( + base, + "20260227-VERYOLD", + Some(serde_json::json!({ + "run_id": "very-old-1", + "workflow_name": "pipeline", + "goal": "", + "start_time": &ten_days_ago, + "node_count": 1, + "edge_count": 0 + })), + Some(serde_json::json!({ + "timestamp": &ten_days_ago, + "status": "success", + "duration_ms": 1000 + })), + false, + ); + + // With --older-than 7d, only the 10-day-old run should be pruned + let args = RunsPruneArgs { + filter: RunFilterArgs { + before: None, + workflow: None, + label: Vec::new(), + orphans: false, + }, + older_than: Some(chrono::Duration::days(7)), + yes: true, + }; + + prune_from(&args, base).unwrap(); + assert!(dir_old.exists(), "3-day-old run should survive 7d threshold"); + assert!(dir_recent.exists(), "1-day-old run should survive 7d threshold"); + assert!(!dir_very_old.exists(), "10-day-old run should be pruned with 7d threshold"); + } +} \ No newline at end of file