diff --git a/crates/forkd-cli/src/hub.rs b/crates/forkd-cli/src/hub.rs index 3b220a5..04226b4 100644 --- a/crates/forkd-cli/src/hub.rs +++ b/crates/forkd-cli/src/hub.rs @@ -431,11 +431,15 @@ fn hex(bytes: &[u8]) -> String { } /// Render a list-of-local-snapshots line for `forkd images list`. Walks -/// `snapshots/` under the data dir and reports tag + total size. +/// `snapshots/` under the data dir and reports tag + total size + +/// memory.bin size + dir mtime. pub struct LocalSnapshotInfo { pub tag: String, pub total_bytes: u64, + pub memory_bytes: u64, pub has_rootfs: bool, + /// Unix seconds. Best-effort: directory mtime; 0 if unreadable. + pub created_at_unix: u64, } pub fn list_local(snapshots_root: &Path) -> Result> { @@ -453,6 +457,7 @@ pub fn list_local(snapshots_root: &Path) -> Result> { let tag = entry.file_name().to_string_lossy().into_owned(); let dir = entry.path(); let mut total: u64 = 0; + let mut memory: u64 = 0; let mut has_rootfs = false; for name in SNAPSHOT_FILES { let p = dir.join(name); @@ -460,19 +465,58 @@ pub fn list_local(snapshots_root: &Path) -> Result> { total += m.len(); if *name == "rootfs.ext4" { has_rootfs = true; + } else if *name == "memory.bin" { + memory = m.len(); } } } + let created_at_unix = std::fs::metadata(&dir) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); out.push(LocalSnapshotInfo { tag, total_bytes: total, + memory_bytes: memory, has_rootfs, + created_at_unix, }); } - out.sort_by(|a, b| a.tag.cmp(&b.tag)); + // Most recent first; ties broken by tag. + out.sort_by(|a, b| { + b.created_at_unix + .cmp(&a.created_at_unix) + .then_with(|| a.tag.cmp(&b.tag)) + }); Ok(out) } +/// Format a unix timestamp as a human-readable "age" relative to now. +/// Examples: "3m ago", "12h ago", "2d ago", "—" if unknown. +pub fn human_age(created_at_unix: u64) -> String { + if created_at_unix == 0 { + return "—".to_string(); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let dt = now.saturating_sub(created_at_unix); + if dt < 60 { + format!("{dt}s ago") + } else if dt < 3600 { + format!("{}m ago", dt / 60) + } else if dt < 86400 { + format!("{}h ago", dt / 3600) + } else if dt < 86400 * 30 { + format!("{}d ago", dt / 86400) + } else { + format!("{}mo ago", dt / 86400 / 30) + } +} + /// Pretty MiB / GiB formatter for `forkd images list`. pub fn human_bytes(n: u64) -> String { let n = n as f64; diff --git a/crates/forkd-cli/src/main.rs b/crates/forkd-cli/src/main.rs index 453c953..5548073 100644 --- a/crates/forkd-cli/src/main.rs +++ b/crates/forkd-cli/src/main.rs @@ -243,6 +243,24 @@ enum Cmd { #[arg(long)] mem_size_mib: Option, }, + /// Remove one or more snapshot tags. Tries the daemon's DELETE + /// /v1/snapshots/:tag first (clean: removes registry entry + on-disk + /// files atomically); falls back to direct disk removal if the + /// daemon isn't running. + /// + /// Examples: + /// forkd rmi pyagent + /// forkd rmi pyagent langgraph python-numpy + Rmi { + /// Snapshot tags to remove. + tags: Vec, + /// Controller daemon base URL. + #[arg(long, env = "FORKD_URL", default_value = "http://127.0.0.1:8889")] + daemon_url: String, + /// Bearer token (matches the daemon's --token-file). + #[arg(long, env = "FORKD_TOKEN")] + daemon_token: Option, + }, /// List live sandboxes (GET /v1/sandboxes). Table output. Ls { /// Controller daemon base URL. @@ -643,6 +661,11 @@ fn main() -> Result<()> { boot_wait_secs, mem_size_mib, ), + Cmd::Rmi { + tags, + daemon_url, + daemon_token, + } => rmi_cmd(&daemon_url, daemon_token, tags), Cmd::Ls { daemon_url, daemon_token, @@ -1118,6 +1141,79 @@ fn push_cmd( Ok(()) } +/// `forkd rmi ...` — delete snapshots. Daemon-first; falls back +/// to direct disk removal when the daemon is unreachable. +fn rmi_cmd(daemon_url: &str, token: Option, tags: Vec) -> Result<()> { + if tags.is_empty() { + bail!("no tags provided. Usage: forkd rmi ..."); + } + let agent = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(10)) + .build(); + let snapshots_root = data_dir().join("snapshots"); + let mut errs = 0usize; + + for tag in &tags { + let result = (|| -> Result<&'static str> { + // 1. Try daemon DELETE first. + let url = format!("{}/v1/snapshots/{}", daemon_url.trim_end_matches('/'), tag); + let mut req = agent.delete(&url); + if let Some(t) = token.as_deref() { + req = req.set("Authorization", &format!("Bearer {t}")); + } + match req.call() { + Ok(_) => Ok("daemon"), + Err(ureq::Error::Status(404, _)) => { + // Daemon doesn't know it; try disk fallback. + fallback_remove(&snapshots_root, tag)?; + Ok("disk") + } + Err(ureq::Error::Status(code, r)) => { + let body = r.into_string().unwrap_or_default(); + bail!("daemon HTTP {code}: {body}"); + } + Err(_transport) => { + // Daemon down → disk fallback. + fallback_remove(&snapshots_root, tag)?; + Ok("disk (daemon unreachable)") + } + } + })(); + match result { + Ok(src) => println!(" ✓ {tag} ({src})"), + Err(e) => { + println!(" ✗ {tag} ({e})"); + errs += 1; + } + } + } + if errs > 0 { + bail!("{errs} of {} removals failed", tags.len()); + } + Ok(()) +} + +fn fallback_remove(snapshots_root: &std::path::Path, tag: &str) -> Result<()> { + // Validate tag against the same rules the daemon enforces. + if tag.is_empty() + || tag.len() > 64 + || !tag + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + bail!("invalid tag (must be 1-64 chars, alnum / dash / underscore)"); + } + let dir = snapshots_root.join(tag); + if !dir.exists() { + bail!( + "snapshot {tag} not found (no daemon entry, no disk dir at {})", + dir.display() + ); + } + std::fs::remove_dir_all(&dir).with_context(|| format!("rm -rf {}", dir.display()))?; + Ok(()) +} + fn images_cmd() -> Result<()> { let root = data_dir().join("snapshots"); let infos = hub::list_local(&root)?; @@ -1128,15 +1224,34 @@ fn images_cmd() -> Result<()> { ); return Ok(()); } - println!("{:<32} {:>12} ROOTFS?", "TAG", "SIZE"); - for info in infos { + let tag_w = infos.iter().map(|i| i.tag.len()).max().unwrap_or(8).max(3); + println!( + " {:10} {:>10} {:>10} ROOTFS", + "TAG", + "SIZE", + "MEMORY", + "CREATED", + tag_w = tag_w, + ); + let mut total_bytes: u64 = 0; + for info in &infos { println!( - "{:<32} {:>12} {}", + " {:10} {:>10} {:>10} {}", info.tag, hub::human_bytes(info.total_bytes), - if info.has_rootfs { "yes" } else { "—" } + hub::human_bytes(info.memory_bytes), + hub::human_age(info.created_at_unix), + if info.has_rootfs { "yes" } else { "—" }, + tag_w = tag_w, ); + total_bytes += info.total_bytes; } + println!( + "\n {} snapshot{} · {} total", + infos.len(), + if infos.len() == 1 { "" } else { "s" }, + hub::human_bytes(total_bytes), + ); Ok(()) }