use anyhow::{Result, bail};
use chrono::Utc;
use zag_agent::config::Config;
use zag_agent::process_store::ProcessStore;
pub struct GcParams {
pub force: bool,
pub older_than: String,
pub keep_logs: bool,
pub json: bool,
pub root: Option<String>,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct GcReport {
pub process_entries_removed: usize,
pub lifecycle_markers_removed: usize,
pub spawn_logs_removed: usize,
pub session_logs_removed: usize,
pub dry_run: bool,
}
fn parse_duration_secs(s: &str) -> Result<i64> {
let s = s.trim();
if let Some(days) = s.strip_suffix('d') {
let n: i64 = days
.parse()
.map_err(|_| anyhow::anyhow!("Invalid duration: {s}"))?;
Ok(n * 86400)
} else if let Some(hours) = s.strip_suffix('h') {
let n: i64 = hours
.parse()
.map_err(|_| anyhow::anyhow!("Invalid duration: {s}"))?;
Ok(n * 3600)
} else {
bail!("Invalid duration '{s}'. Use e.g. 7d or 24h.");
}
}
fn is_file_old(path: &std::path::Path, cutoff: std::time::SystemTime) -> bool {
path.metadata()
.and_then(|m| m.modified())
.map(|t| t < cutoff)
.unwrap_or(false)
}
fn live_session_ids(root: Option<&str>) -> std::collections::HashSet<String> {
let mut live = std::collections::HashSet::new();
if let Ok(proc_store) = ProcessStore::load() {
for entry in &proc_store.processes {
if entry.status == "running" {
if let Some(ref sid) = entry.session_id {
live.insert(sid.clone());
}
}
}
}
let _ = root; live
}
pub fn gc_collect(params: &GcParams) -> Result<GcReport> {
let threshold_secs = parse_duration_secs(¶ms.older_than)?;
let cutoff =
std::time::SystemTime::now() - std::time::Duration::from_secs(threshold_secs as u64);
let cutoff_chrono = Utc::now() - chrono::Duration::seconds(threshold_secs);
let dry_run = !params.force;
let mut report = GcReport {
dry_run,
..Default::default()
};
let live = live_session_ids(params.root.as_deref());
if let Ok(mut proc_store) = ProcessStore::load() {
let to_remove: Vec<String> = proc_store
.processes
.iter()
.filter(|e| {
e.status != "running"
&& !live.contains(e.session_id.as_deref().unwrap_or(""))
&& chrono::DateTime::parse_from_rfc3339(&e.started_at)
.map(|dt| dt < cutoff_chrono)
.unwrap_or(false)
})
.map(|e| e.id.clone())
.collect();
report.process_entries_removed = to_remove.len();
if !dry_run && !to_remove.is_empty() {
proc_store.processes.retain(|e| !to_remove.contains(&e.id));
let _ = proc_store.save();
}
}
let events_dir = Config::global_base_dir().join("events");
if events_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&events_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_file_old(&path, cutoff) {
let fname = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let session_id = fname.to_string();
if !live.contains(&session_id) {
report.lifecycle_markers_removed += 1;
if !dry_run {
let _ = std::fs::remove_file(&path);
}
}
}
}
}
}
let spawn_dir = Config::global_base_dir().join("logs").join("spawn");
if spawn_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&spawn_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_file_old(&path, cutoff) {
report.spawn_logs_removed += 1;
if !dry_run {
let _ = std::fs::remove_file(&path);
}
}
}
}
}
if !params.keep_logs {
let projects_dir = Config::global_base_dir().join("projects");
if projects_dir.exists() {
if let Ok(projects) = std::fs::read_dir(&projects_dir) {
for project in projects.flatten() {
let sessions_dir = project.path().join("logs").join("sessions");
if sessions_dir.exists() {
report.session_logs_removed +=
clean_session_logs(&sessions_dir, cutoff, &live, dry_run);
}
}
}
}
}
Ok(report)
}
pub fn run_gc(params: GcParams) -> Result<()> {
let report = gc_collect(¶ms)?;
if params.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
let action = if report.dry_run {
"Would remove"
} else {
"Removed"
};
if report.process_entries_removed > 0 {
println!(
"{} {} process entries",
action, report.process_entries_removed
);
}
if report.lifecycle_markers_removed > 0 {
println!(
"{} {} lifecycle markers",
action, report.lifecycle_markers_removed
);
}
if report.spawn_logs_removed > 0 {
println!("{} {} spawn logs", action, report.spawn_logs_removed);
}
if report.session_logs_removed > 0 {
println!("{} {} session logs", action, report.session_logs_removed);
}
let total = report.process_entries_removed
+ report.lifecycle_markers_removed
+ report.spawn_logs_removed
+ report.session_logs_removed;
if total == 0 {
println!("Nothing to clean up.");
} else if report.dry_run {
println!("\nRun with --force to actually delete.");
}
}
Ok(())
}
fn clean_session_logs(
sessions_dir: &std::path::Path,
cutoff: std::time::SystemTime,
live: &std::collections::HashSet<String>,
dry_run: bool,
) -> usize {
let mut count = 0;
let entries = match std::fs::read_dir(sessions_dir) {
Ok(e) => e,
Err(_) => return 0,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if !is_file_old(&path, cutoff) {
continue;
}
let session_id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if live.contains(&session_id) {
continue;
}
if !has_session_ended(&path) {
continue;
}
count += 1;
if !dry_run {
let _ = std::fs::remove_file(&path);
}
}
count
}
fn has_session_ended(path: &std::path::Path) -> bool {
use std::io::{BufRead, BufReader};
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let reader = BufReader::new(file);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.contains("\"SessionEnded\"") || line.contains("\"session_ended\"") {
return true;
}
}
false
}
#[cfg(test)]
#[path = "gc_tests.rs"]
mod tests;