diff --git a/Cargo.lock b/Cargo.lock index 775ed1c0..4205c9b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6010,6 +6010,7 @@ dependencies = [ "manifest", "memory", "pod", + "pod-store", "project-record", "serde", "serde_json", diff --git a/crates/session-store/src/fs_store.rs b/crates/session-store/src/fs_store.rs index f9e04343..1339e7c0 100644 --- a/crates/session-store/src/fs_store.rs +++ b/crates/session-store/src/fs_store.rs @@ -22,6 +22,7 @@ use crate::{SegmentId, SessionId}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use std::time::SystemTime; /// Filesystem-backed JSONL store. /// @@ -41,6 +42,50 @@ impl FsStore { Ok(Self { root }) } + /// Return the filesystem root used by this store. + pub fn root_dir(&self) -> &Path { + &self.root + } + + /// Return the latest filesystem mtime under a Session directory. + /// + /// Missing Sessions return `Ok(None)`. This is intentionally Session-scoped + /// so cleanup callers can apply age thresholds without reaching around the + /// Session store's directory authority. + pub fn session_modified_at( + &self, + session_id: SessionId, + ) -> Result, StoreError> { + let session_dir = self.session_dir(session_id); + let dir_metadata = match fs::metadata(&session_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let mut latest = Some(dir_metadata.modified()?); + for entry in fs::read_dir(&session_dir)? { + let entry = entry?; + let modified = entry.metadata()?.modified()?; + if latest.map(|current| modified > current).unwrap_or(true) { + latest = Some(modified); + } + } + Ok(latest) + } + + /// Delete an entire Session directory owned by this Session store. + /// + /// Returns `Ok(true)` when a Session directory was removed and `Ok(false)` + /// when it was already absent. + pub fn delete_session(&self, session_id: SessionId) -> Result { + let session_dir = self.session_dir(session_id); + match fs::remove_dir_all(&session_dir) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } + } + fn session_dir(&self, session_id: SessionId) -> PathBuf { self.root.join(session_id.to_string()) } @@ -220,3 +265,42 @@ impl Store for FsStore { self.append_line(&self.trace_path(session_id, segment_id), &line) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{new_segment_id, new_session_id}; + + #[test] + fn delete_session_removes_session_directory_only() { + let tmp = tempfile::TempDir::new().unwrap(); + let store = FsStore::new(tmp.path()).unwrap(); + let keep_session = new_session_id(); + let keep_segment = new_segment_id(); + let delete_session = new_session_id(); + let delete_segment = new_segment_id(); + store + .create_segment(keep_session, keep_segment, &[]) + .unwrap(); + store + .create_segment(delete_session, delete_segment, &[]) + .unwrap(); + + assert!(store.delete_session(delete_session).unwrap()); + assert!(!store.exists(delete_session, delete_segment).unwrap()); + assert!(store.exists(keep_session, keep_segment).unwrap()); + assert!(!store.delete_session(delete_session).unwrap()); + } + + #[test] + fn session_modified_at_is_store_scoped() { + let tmp = tempfile::TempDir::new().unwrap(); + let store = FsStore::new(tmp.path()).unwrap(); + let session_id = new_session_id(); + let segment_id = new_segment_id(); + + assert!(store.session_modified_at(session_id).unwrap().is_none()); + store.create_segment(session_id, segment_id, &[]).unwrap(); + assert!(store.session_modified_at(session_id).unwrap().is_some()); + } +} diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index 0121f6a2..e4d23ec5 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -15,6 +15,7 @@ client = { workspace = true } memory = { workspace = true } manifest = { workspace = true } pod = { workspace = true } +pod-store = { workspace = true } session-store = { workspace = true } session-analytics = { workspace = true } ticket = { workspace = true } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 048628b4..3625681a 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -2,6 +2,7 @@ mod mcp_cli; mod memory_lint; mod objective_cli; mod plugin_cli; +mod pod_cleanup_cli; mod session_cli; mod ticket_cli; @@ -25,6 +26,7 @@ enum Mode { Plugin(plugin_cli::PluginCliCommand), Objective(objective_cli::ObjectiveCli), Session(session_cli::SessionCli), + PodCleanup(pod_cleanup_cli::PodCleanupCli), Ticket(ticket_cli::TicketCli), WorkspaceHelp, WorkspaceServe(Vec), @@ -117,6 +119,7 @@ async fn main() -> ExitCode { print!("{}", output.stdout); match output.status { session_cli::SessionCliStatus::Success => ExitCode::SUCCESS, + session_cli::SessionCliStatus::Failure => ExitCode::FAILURE, } } Err(e) => { @@ -124,6 +127,19 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, + Mode::PodCleanup(cli) => match pod_cleanup_cli::run(cli).await { + Ok(output) => { + print!("{}", output.stdout); + match output.status { + pod_cleanup_cli::PodCleanupCliStatus::Success => ExitCode::SUCCESS, + pod_cleanup_cli::PodCleanupCliStatus::Failure => ExitCode::FAILURE, + } + } + Err(e) => { + eprintln!("yoi pod: {e}"); + ExitCode::FAILURE + } + }, Mode::Ticket(cli) => match ticket_cli::run(cli) { Ok(output) => { print!("{}", output.stdout); @@ -188,7 +204,14 @@ fn parse_args_slice(args: &[String]) -> Result { match args[0].as_str() { "--help" | "-h" => return Ok(Mode::Help), "resume" => return parse_resume_args(&args[1..]), - "pod" => return Ok(Mode::PodRuntime(args[1..].to_vec())), + "pod" => { + if let Some(cli) = pod_cleanup_cli::parse_pod_management_args(&args[1..]) + .map_err(|e| ParseError(e.to_string()))? + { + return Ok(Mode::PodCleanup(cli)); + } + return Ok(Mode::PodRuntime(args[1..].to_vec())); + } "objective" => { let objective_cli = objective_cli::parse_objective_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))?; @@ -878,7 +901,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete [--force] [--dry-run]\n yoi pod prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod Open the Pod Console by name (attach/restore/create)\n --socket Attach a Pod Console to a specific socket with --pod\n --session Resume a specific session segment in the Pod Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } @@ -978,6 +1001,31 @@ mod tests { } } + #[test] + fn parse_pod_delete_uses_cleanup_mode() { + match parse_args_from(["pod", "delete", "agent", "--dry-run"]).unwrap() { + Mode::PodCleanup(pod_cleanup_cli::PodCleanupCli::Delete(options)) => { + assert_eq!(options.name, "agent"); + assert!(options.dry_run); + assert!(!options.force); + } + _ => panic!("expected Pod cleanup delete mode"), + } + } + + #[test] + fn parse_pod_prune_uses_cleanup_mode() { + match parse_args_from(["pod", "prune", "--older-than", "30d"]).unwrap() { + Mode::PodCleanup(pod_cleanup_cli::PodCleanupCli::Prune(options)) => { + assert_eq!( + options.older_than, + std::time::Duration::from_secs(30 * 24 * 60 * 60) + ); + } + _ => panic!("expected Pod cleanup prune mode"), + } + } + #[test] fn parse_ticket_subcommand_uses_ticket_mode() { match parse_args_from(["ticket", "doctor"]).unwrap() { diff --git a/crates/yoi/src/pod_cleanup_cli.rs b/crates/yoi/src/pod_cleanup_cli.rs new file mode 100644 index 00000000..61f782fc --- /dev/null +++ b/crates/yoi/src/pod_cleanup_cli.rs @@ -0,0 +1,624 @@ +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use manifest::paths; +use pod_store::{FsPodStore, PodMetadata, PodMetadataStore, validate_pod_name}; + +const MAX_REPORT_ITEMS: usize = 50; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PodCleanupCli { + Help, + Delete(PodDeleteOptions), + Prune(PodPruneOptions), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PodDeleteOptions { + pub name: String, + pub force: bool, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PodPruneOptions { + pub older_than: Duration, + pub force: bool, + pub dry_run: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PodCleanupCliOutput { + pub stdout: String, + pub status: PodCleanupCliStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PodCleanupCliStatus { + Success, + Failure, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PodCleanupCliError(String); + +impl fmt::Display for PodCleanupCliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for PodCleanupCliError {} + +pub fn parse_pod_management_args( + args: &[String], +) -> Result, PodCleanupCliError> { + let Some((subcommand, rest)) = args.split_first() else { + return Ok(None); + }; + match subcommand.as_str() { + "delete" => parse_delete_args(rest).map(PodCleanupCli::Delete).map(Some), + "prune" => parse_prune_args(rest).map(PodCleanupCli::Prune).map(Some), + "help" => Ok(Some(PodCleanupCli::Help)), + "--help" | "-h" => Ok(Some(PodCleanupCli::Help)), + _ => Ok(None), + } +} + +fn parse_delete_args(args: &[String]) -> Result { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + return Err(PodCleanupCliError(delete_help_text().to_string())); + } + let mut name = None; + let mut force = false; + let mut dry_run = false; + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--force" => force = true, + "--dry-run" => dry_run = true, + "--" => { + for positional in iter { + set_name(&mut name, positional)?; + } + break; + } + value if value.starts_with('-') => { + return Err(PodCleanupCliError(format!( + "unknown yoi pod delete option `{value}`" + ))); + } + positional => set_name(&mut name, positional)?, + } + } + let name = name + .ok_or_else(|| PodCleanupCliError("yoi pod delete requires an explicit Pod name".into()))?; + validate_pod_name(&name).map_err(|e| PodCleanupCliError(e.to_string()))?; + Ok(PodDeleteOptions { + name, + force, + dry_run, + }) +} + +fn set_name(name: &mut Option, value: &str) -> Result<(), PodCleanupCliError> { + if name.is_some() { + return Err(PodCleanupCliError( + "yoi pod delete accepts exactly one Pod name".into(), + )); + } + *name = Some(value.to_string()); + Ok(()) +} + +fn parse_prune_args(args: &[String]) -> Result { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + return Err(PodCleanupCliError(prune_help_text().to_string())); + } + let mut older_than = None; + let mut force = false; + let mut dry_run = false; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--force" { + force = true; + index += 1; + } else if arg == "--dry-run" { + dry_run = true; + index += 1; + } else if arg == "--older-than" { + let value = args.get(index + 1).ok_or_else(|| { + PodCleanupCliError("--older-than requires a duration value".into()) + })?; + if value.starts_with('-') { + return Err(PodCleanupCliError( + "--older-than requires a duration value".into(), + )); + } + older_than = Some(parse_duration(value)?); + index += 2; + } else if let Some(value) = arg.strip_prefix("--older-than=") { + if value.is_empty() { + return Err(PodCleanupCliError( + "--older-than requires a duration value".into(), + )); + } + older_than = Some(parse_duration(value)?); + index += 1; + } else if arg.starts_with('-') { + return Err(PodCleanupCliError(format!( + "unknown yoi pod prune option `{arg}`" + ))); + } else { + return Err(PodCleanupCliError(format!( + "yoi pod prune does not accept positional argument `{arg}`" + ))); + } + } + let older_than = older_than.ok_or_else(|| { + PodCleanupCliError("yoi pod prune requires --older-than ".into()) + })?; + Ok(PodPruneOptions { + older_than, + force, + dry_run, + }) +} + +pub fn parse_duration(value: &str) -> Result { + let split = value + .find(|ch: char| !ch.is_ascii_digit()) + .unwrap_or(value.len()); + let (amount, unit) = value.split_at(split); + if amount.is_empty() || unit.is_empty() { + return Err(PodCleanupCliError(format!( + "duration `{value}` must use an explicit unit: s, m, h, d, or w" + ))); + } + let amount = amount + .parse::() + .map_err(|_| PodCleanupCliError(format!("invalid duration amount `{value}`")))?; + if amount == 0 { + return Err(PodCleanupCliError( + "duration must be greater than zero".into(), + )); + } + let seconds = match unit { + "s" | "sec" | "secs" | "second" | "seconds" => amount, + "m" | "min" | "mins" | "minute" | "minutes" => amount.saturating_mul(60), + "h" | "hr" | "hrs" | "hour" | "hours" => amount.saturating_mul(60 * 60), + "d" | "day" | "days" => amount.saturating_mul(60 * 60 * 24), + "w" | "week" | "weeks" => amount.saturating_mul(60 * 60 * 24 * 7), + _ => { + return Err(PodCleanupCliError(format!( + "unknown duration unit `{unit}` in `{value}`" + ))); + } + }; + Ok(Duration::from_secs(seconds)) +} + +pub async fn run(cli: PodCleanupCli) -> Result { + let data_dir = paths::data_dir() + .ok_or_else(|| PodCleanupCliError("failed to resolve Yoi data directory".into()))?; + let runtime_dir = paths::runtime_dir() + .ok_or_else(|| PodCleanupCliError("failed to resolve Yoi runtime directory".into()))?; + run_with_roots(cli, data_dir, runtime_dir).await +} + +pub async fn run_with_roots( + cli: PodCleanupCli, + data_dir: PathBuf, + runtime_dir: PathBuf, +) -> Result { + match cli { + PodCleanupCli::Help => Ok(PodCleanupCliOutput { + stdout: help_text().to_string(), + status: PodCleanupCliStatus::Success, + }), + PodCleanupCli::Delete(options) => run_delete(options, data_dir, runtime_dir).await, + PodCleanupCli::Prune(options) => run_prune(options, data_dir, runtime_dir).await, + } +} + +async fn run_delete( + options: PodDeleteOptions, + data_dir: PathBuf, + runtime_dir: PathBuf, +) -> Result { + let store = FsPodStore::new(data_dir.join("pods")).map_err(to_error)?; + let metadata = store.read_by_name(&options.name).map_err(to_error)?; + let Some(metadata) = metadata else { + return Ok(PodCleanupCliOutput { + stdout: format!( + "yoi pod delete\nstatus: refused\npod: {}\nreason: pod metadata is missing\n", + options.name + ), + status: PodCleanupCliStatus::Failure, + }); + }; + + let probe = probe_pod_liveness(&runtime_dir, &options.name).await; + if let Some(reason) = probe.refusal_reason() { + return Ok(PodCleanupCliOutput { + stdout: format!( + "yoi pod delete\nstatus: refused\npod: {}\nreason: {}\nsocket: {}\n", + options.name, + reason, + probe.socket_path.display() + ), + status: PodCleanupCliStatus::Failure, + }); + } + + let delete = options.force && !options.dry_run; + let mut stdout = String::new(); + stdout.push_str("yoi pod delete\n"); + stdout.push_str(if delete { + "mode: force\n" + } else { + "mode: dry-run\n" + }); + stdout.push_str(&format!("pod: {}\n", options.name)); + describe_metadata(&mut stdout, &metadata); + if delete { + store.delete_by_name(&options.name).map_err(to_error)?; + stdout.push_str("deleted: pod metadata\n"); + stdout.push_str("preserved: session logs/history\n"); + } else { + stdout.push_str("would_delete: pod metadata\n"); + stdout.push_str("would_preserve: session logs/history\n"); + stdout + .push_str("note: pass --force to delete metadata; --dry-run keeps report-only mode\n"); + } + Ok(PodCleanupCliOutput { + stdout, + status: PodCleanupCliStatus::Success, + }) +} + +async fn run_prune( + options: PodPruneOptions, + data_dir: PathBuf, + runtime_dir: PathBuf, +) -> Result { + let store = FsPodStore::new(data_dir.join("pods")).map_err(to_error)?; + let names = store.list_names().map_err(to_error)?; + let cutoff = SystemTime::now() + .checked_sub(options.older_than) + .ok_or_else(|| PodCleanupCliError("--older-than duration is too large".into()))?; + let delete = options.force && !options.dry_run; + let mut stdout = String::new(); + stdout.push_str("yoi pod prune\n"); + stdout.push_str(if delete { + "mode: force\n" + } else { + "mode: dry-run\n" + }); + stdout.push_str(&format!("older_than: {:?}\n", options.older_than)); + + let mut deleted = 0usize; + let mut would_delete = 0usize; + let mut kept = 0usize; + let mut refused = 0usize; + for (index, name) in names.iter().enumerate() { + let metadata = store.read_by_name(name).map_err(to_error)?; + let Some(metadata) = metadata else { + kept += 1; + push_item_line(&mut stdout, index, "kept", name, "metadata disappeared"); + continue; + }; + let modified = metadata_modified_at(store.root_dir().as_deref(), name).map_err(to_error)?; + let Some(modified) = modified else { + refused += 1; + push_item_line( + &mut stdout, + index, + "refused", + name, + "metadata mtime is unavailable", + ); + continue; + }; + if modified > cutoff { + kept += 1; + push_item_line( + &mut stdout, + index, + "kept", + name, + "metadata is newer than threshold", + ); + continue; + } + let probe = probe_pod_liveness(&runtime_dir, name).await; + if let Some(reason) = probe.refusal_reason() { + refused += 1; + push_item_line(&mut stdout, index, "refused", name, &reason); + continue; + } + if delete { + store.delete_by_name(name).map_err(to_error)?; + deleted += 1; + push_item_line( + &mut stdout, + index, + "deleted", + name, + "old pod metadata; session logs/history preserved", + ); + } else { + would_delete += 1; + let reason = metadata + .active + .as_ref() + .map(|active| format!("old metadata; active_session={}", active.session_id)) + .unwrap_or_else(|| "old metadata; no active session".to_string()); + push_item_line(&mut stdout, index, "would_delete", name, &reason); + } + } + stdout.push_str(&format!( + "summary: deleted={deleted} would_delete={would_delete} kept={kept} refused={refused}\n" + )); + if !delete { + stdout + .push_str("note: pass --force to delete metadata; --dry-run keeps report-only mode\n"); + } + Ok(PodCleanupCliOutput { + stdout, + status: if refused > 0 { + PodCleanupCliStatus::Failure + } else { + PodCleanupCliStatus::Success + }, + }) +} + +fn describe_metadata(stdout: &mut String, metadata: &PodMetadata) { + match metadata.active.as_ref() { + Some(active) => stdout.push_str(&format!( + "active_session: {}\nactive_segment: {}\n", + active.session_id, + active + .segment_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "".to_string()) + )), + None => stdout.push_str("active_session: \n"), + } +} + +fn metadata_modified_at( + root: Option<&Path>, + pod_name: &str, +) -> Result, io::Error> { + let Some(root) = root else { + return Ok(None); + }; + let path = root.join(pod_name).join("metadata.json"); + match std::fs::metadata(path) { + Ok(metadata) => metadata.modified().map(Some), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + +fn push_item_line(stdout: &mut String, index: usize, action: &str, name: &str, reason: &str) { + if index < MAX_REPORT_ITEMS { + stdout.push_str(&format!("{action}: {name} ({reason})\n")); + } else if index == MAX_REPORT_ITEMS { + stdout.push_str("... additional items omitted from bounded report ...\n"); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LivenessProbe { + socket_path: PathBuf, + result: LivenessResult, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum LivenessResult { + NotReachable, + Reachable, + Uncertain(String), +} + +impl LivenessProbe { + fn refusal_reason(&self) -> Option { + match &self.result { + LivenessResult::NotReachable => None, + LivenessResult::Reachable => Some("pod is live/reachable".into()), + LivenessResult::Uncertain(reason) => Some(format!( + "pod liveness is uncertain; refusing destructive metadata cleanup ({reason})" + )), + } + } +} + +async fn probe_pod_liveness(runtime_dir: &Path, pod_name: &str) -> LivenessProbe { + let socket_path = runtime_dir.join(pod_name).join("sock"); + let result = probe_socket(&socket_path).await; + LivenessProbe { + socket_path, + result, + } +} + +#[cfg(unix)] +async fn probe_socket(socket_path: &Path) -> LivenessResult { + use std::os::unix::net::UnixStream; + + let path = socket_path.to_path_buf(); + match tokio::task::spawn_blocking(move || UnixStream::connect(path)).await { + Ok(Ok(_stream)) => LivenessResult::Reachable, + Ok(Err(error)) if is_not_live_socket_error(&error) => LivenessResult::NotReachable, + Ok(Err(error)) => LivenessResult::Uncertain(error.to_string()), + Err(error) => LivenessResult::Uncertain(error.to_string()), + } +} + +#[cfg(unix)] +fn is_not_live_socket_error(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused + ) +} + +#[cfg(not(unix))] +async fn probe_socket(_socket_path: &Path) -> LivenessResult { + LivenessResult::Uncertain("Unix socket probing is unavailable on this platform".into()) +} + +fn to_error(error: E) -> PodCleanupCliError { + PodCleanupCliError(error.to_string()) +} + +pub fn help_text() -> &'static str { + "yoi pod\n\nUsage:\n yoi pod delete [--force] [--dry-run]\n yoi pod prune --older-than [--force] [--dry-run]\n yoi pod [POD_OPTIONS]\n\nDescription:\n delete/prune are safe Pod metadata cleanup commands. `pod delete` removes only name-keyed Pod metadata and never removes session logs/history. Live or uncertain Pod liveness is refused. Without --force the command reports only.\n\nDuration units: s, m, h, d, w\n\nOptions:\n --force Perform deletion after safety checks\n --dry-run Report only, even with --force\n --older-than Required explicit age threshold for prune\n -h, --help Print help\n" +} + +fn delete_help_text() -> &'static str { + "usage: yoi pod delete [--force] [--dry-run]" +} + +fn prune_help_text() -> &'static str { + "usage: yoi pod prune --older-than [--force] [--dry-run]" +} + +#[cfg(test)] +mod tests { + use super::*; + use pod_store::PodActiveSegmentRef; + use session_store::{Store, new_segment_id, new_session_id}; + + fn string_args(args: &[&str]) -> Vec { + args.iter().map(|arg| arg.to_string()).collect() + } + + #[test] + fn parse_pod_delete_command() { + let cli = + parse_pod_management_args(&string_args(&["delete", "agent", "--force", "--dry-run"])) + .unwrap() + .unwrap(); + assert_eq!( + cli, + PodCleanupCli::Delete(PodDeleteOptions { + name: "agent".into(), + force: true, + dry_run: true, + }) + ); + } + + #[test] + fn parse_pod_prune_requires_explicit_threshold() { + let err = parse_pod_management_args(&string_args(&["prune"])).unwrap_err(); + assert!(err.to_string().contains("--older-than")); + } + + #[test] + fn parse_duration_requires_units() { + let err = parse_duration("30").unwrap_err(); + assert!(err.to_string().contains("explicit unit")); + assert_eq!(parse_duration("2d").unwrap(), Duration::from_secs(172_800)); + } + + #[tokio::test] + async fn stopped_pod_delete_force_removes_only_metadata() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let runtime_dir = tmp.path().join("run"); + let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap(); + let session_store = session_store::FsStore::new(data_dir.join("sessions")).unwrap(); + let session_id = new_session_id(); + let segment_id = new_segment_id(); + session_store + .create_segment(session_id, segment_id, &[]) + .unwrap(); + pod_store + .write(&PodMetadata::new( + "agent", + Some(PodActiveSegmentRef::active_segment(session_id, segment_id)), + )) + .unwrap(); + + let output = run_with_roots( + PodCleanupCli::Delete(PodDeleteOptions { + name: "agent".into(), + force: true, + dry_run: false, + }), + data_dir.clone(), + runtime_dir, + ) + .await + .unwrap(); + + assert_eq!(output.status, PodCleanupCliStatus::Success); + assert!(output.stdout.contains("deleted: pod metadata")); + assert!(pod_store.read_by_name("agent").unwrap().is_none()); + assert!(session_store.exists(session_id, segment_id).unwrap()); + } + + #[tokio::test] + async fn pod_delete_without_force_reports_dry_run() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let runtime_dir = tmp.path().join("run"); + let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap(); + pod_store.write(&PodMetadata::new("agent", None)).unwrap(); + + let output = run_with_roots( + PodCleanupCli::Delete(PodDeleteOptions { + name: "agent".into(), + force: false, + dry_run: false, + }), + data_dir, + runtime_dir, + ) + .await + .unwrap(); + + assert_eq!(output.status, PodCleanupCliStatus::Success); + assert!(output.stdout.contains("mode: dry-run")); + assert!(pod_store.read_by_name("agent").unwrap().is_some()); + } + + #[cfg(unix)] + #[tokio::test] + async fn live_pod_delete_is_refused() { + use std::os::unix::net::UnixListener; + + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let runtime_dir = tmp.path().join("run"); + let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap(); + pod_store.write(&PodMetadata::new("agent", None)).unwrap(); + std::fs::create_dir_all(runtime_dir.join("agent")).unwrap(); + let listener = UnixListener::bind(runtime_dir.join("agent/sock")).unwrap(); + + let output = run_with_roots( + PodCleanupCli::Delete(PodDeleteOptions { + name: "agent".into(), + force: true, + dry_run: false, + }), + data_dir, + runtime_dir, + ) + .await + .unwrap(); + + drop(listener); + assert_eq!(output.status, PodCleanupCliStatus::Failure); + assert!(output.stdout.contains("status: refused")); + assert!(pod_store.read_by_name("agent").unwrap().is_some()); + } +} diff --git a/crates/yoi/src/session_cli.rs b/crates/yoi/src/session_cli.rs index ab3d8e6c..29641254 100644 --- a/crates/yoi/src/session_cli.rs +++ b/crates/yoi/src/session_cli.rs @@ -1,10 +1,21 @@ +use std::collections::BTreeSet; use std::fmt; use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use manifest::paths; +use pod_store::{FsPodStore, PodMetadataStore}; +use session_store::{FsStore, SessionId, Store}; + +use crate::pod_cleanup_cli::parse_duration; + +const MAX_REPORT_ITEMS: usize = 50; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SessionCli { Help, Analyze(SessionAnalyzeOptions), + Prune(SessionPruneOptions), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -13,6 +24,14 @@ pub struct SessionAnalyzeOptions { pub json: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPruneOptions { + pub unreferenced: bool, + pub older_than: Option, + pub force: bool, + pub dry_run: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionCliOutput { pub stdout: String, @@ -22,6 +41,7 @@ pub struct SessionCliOutput { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionCliStatus { Success, + Failure, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -41,6 +61,7 @@ pub fn parse_session_args(args: &[String]) -> Result parse_analyze_args(&args[1..]).map(SessionCli::Analyze), + "prune" => parse_prune_args(&args[1..]).map(SessionCli::Prune), other => Err(SessionCliError(format!( "unknown yoi session command `{other}`" ))), @@ -79,6 +100,65 @@ fn parse_analyze_args(args: &[String]) -> Result Result { + let mut unreferenced = false; + let mut older_than = None; + let mut force = false; + let mut dry_run = false; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--unreferenced" { + unreferenced = true; + index += 1; + } else if arg == "--force" { + force = true; + index += 1; + } else if arg == "--dry-run" { + dry_run = true; + index += 1; + } else if arg == "--older-than" { + let value = args + .get(index + 1) + .ok_or_else(|| SessionCliError("--older-than requires a duration value".into()))?; + if value.starts_with('-') { + return Err(SessionCliError( + "--older-than requires a duration value".into(), + )); + } + older_than = Some(parse_duration(value).map_err(|e| SessionCliError(e.to_string()))?); + index += 2; + } else if let Some(value) = arg.strip_prefix("--older-than=") { + if value.is_empty() { + return Err(SessionCliError( + "--older-than requires a duration value".into(), + )); + } + older_than = Some(parse_duration(value).map_err(|e| SessionCliError(e.to_string()))?); + index += 1; + } else if arg.starts_with('-') { + return Err(SessionCliError(format!( + "unknown yoi session prune option `{arg}`" + ))); + } else { + return Err(SessionCliError(format!( + "yoi session prune does not accept positional argument `{arg}`" + ))); + } + } + if !unreferenced { + return Err(SessionCliError( + "yoi session prune requires --unreferenced".into(), + )); + } + Ok(SessionPruneOptions { + unreferenced, + older_than, + force, + dry_run, + }) +} + fn set_path(path: &mut Option, value: &str) -> Result<(), SessionCliError> { if path.is_some() { return Err(SessionCliError( @@ -105,16 +185,181 @@ pub fn run(cli: SessionCli) -> Result { status: SessionCliStatus::Success, }) } + SessionCli::Prune(options) => { + let data_dir = paths::data_dir() + .ok_or_else(|| SessionCliError("failed to resolve Yoi data directory".into()))?; + run_prune_with_roots(options, data_dir) + } } } +pub fn run_prune_with_roots( + options: SessionPruneOptions, + data_dir: PathBuf, +) -> Result { + if !options.unreferenced { + return Err(SessionCliError( + "yoi session prune requires --unreferenced".into(), + )); + } + let session_store = FsStore::new(data_dir.join("sessions")).map_err(to_error)?; + let pod_store = FsPodStore::new(data_dir.join("pods")).map_err(to_error)?; + let referenced_sessions = referenced_sessions(&pod_store)?; + let cutoff = options + .older_than + .map(|older_than| { + SystemTime::now() + .checked_sub(older_than) + .ok_or_else(|| SessionCliError("--older-than duration is too large".into())) + }) + .transpose()?; + let delete = options.force && !options.dry_run; + + let mut deleted = 0usize; + let mut would_delete = 0usize; + let mut kept_referenced = 0usize; + let mut kept_newer = 0usize; + let mut refused = 0usize; + let mut stdout = String::new(); + stdout.push_str("yoi session prune\n"); + stdout.push_str(if delete { + "mode: force\n" + } else { + "mode: dry-run\n" + }); + stdout.push_str("scope: unreferenced sessions\n"); + if let Some(older_than) = options.older_than { + stdout.push_str(&format!("older_than: {older_than:?}\n")); + } + + let sessions = session_store.list_sessions().map_err(to_error)?; + for (index, session_id) in sessions.iter().enumerate() { + if referenced_sessions.contains(session_id) { + kept_referenced += 1; + push_item_line( + &mut stdout, + index, + "kept", + *session_id, + "referenced by pod metadata", + ); + continue; + } + if let Some(cutoff) = cutoff { + let modified = session_store + .session_modified_at(*session_id) + .map_err(to_error)?; + match modified { + Some(modified) if modified > cutoff => { + kept_newer += 1; + push_item_line( + &mut stdout, + index, + "kept", + *session_id, + "newer than threshold", + ); + continue; + } + Some(_) => {} + None => { + refused += 1; + push_item_line( + &mut stdout, + index, + "refused", + *session_id, + "session mtime is unavailable", + ); + continue; + } + } + } + if delete { + session_store + .delete_session(*session_id) + .map_err(to_error)?; + deleted += 1; + push_item_line( + &mut stdout, + index, + "deleted", + *session_id, + "unreferenced session", + ); + } else { + would_delete += 1; + push_item_line( + &mut stdout, + index, + "would_delete", + *session_id, + "unreferenced session", + ); + } + } + stdout.push_str(&format!( + "summary: deleted={deleted} would_delete={would_delete} kept_referenced={kept_referenced} kept_newer={kept_newer} refused={refused}\n" + )); + if !delete { + stdout + .push_str("note: pass --force to delete sessions; --dry-run keeps report-only mode\n"); + } + Ok(SessionCliOutput { + stdout, + status: if refused > 0 { + SessionCliStatus::Failure + } else { + SessionCliStatus::Success + }, + }) +} + +fn referenced_sessions(pod_store: &FsPodStore) -> Result, SessionCliError> { + let mut sessions = BTreeSet::new(); + for name in pod_store.list_names().map_err(to_error)? { + let metadata = pod_store + .read_by_name(&name) + .map_err(to_error)? + .ok_or_else(|| { + SessionCliError(format!( + "pod metadata for `{name}` disappeared while checking references" + )) + })?; + if let Some(active) = metadata.active { + sessions.insert(active.session_id); + } + } + Ok(sessions) +} + +fn push_item_line( + stdout: &mut String, + index: usize, + action: &str, + session_id: SessionId, + reason: &str, +) { + if index < MAX_REPORT_ITEMS { + stdout.push_str(&format!("{action}: {session_id} ({reason})\n")); + } else if index == MAX_REPORT_ITEMS { + stdout.push_str("... additional items omitted from bounded report ...\n"); + } +} + +fn to_error(error: E) -> SessionCliError { + SessionCliError(error.to_string()) +} + pub fn help_text() -> &'static str { - "yoi session\n\nUsage:\n yoi session analyze --json\n\nOptions:\n --json Emit a machine-readable JSON analytics report\n -h, --help Print help\n" + "yoi session\n\nUsage:\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n\nOptions:\n --json Emit a machine-readable JSON analytics report\n --unreferenced Prune only Sessions not referenced by Pod metadata\n --older-than Optional explicit age threshold for unreferenced cleanup (units: s, m, h, d, w)\n --force Perform deletion after safety checks\n --dry-run Report only, even with --force\n -h, --help Print help\n" } #[cfg(test)] mod tests { use super::*; + use pod_store::{PodActiveSegmentRef, PodMetadata}; + use session_store::{Store, new_segment_id, new_session_id}; use std::io::Write; #[test] @@ -134,6 +379,32 @@ mod tests { ); } + #[test] + fn parse_session_prune_unreferenced() { + let cli = parse_session_args(&[ + "prune".to_string(), + "--unreferenced".to_string(), + "--older-than=2w".to_string(), + "--dry-run".to_string(), + ]) + .unwrap(); + assert_eq!( + cli, + SessionCli::Prune(SessionPruneOptions { + unreferenced: true, + older_than: Some(Duration::from_secs(14 * 24 * 60 * 60)), + force: false, + dry_run: true, + }) + ); + } + + #[test] + fn session_prune_requires_unreferenced() { + let err = parse_session_args(&["prune".to_string()]).unwrap_err(); + assert!(err.to_string().contains("--unreferenced")); + } + #[test] fn run_session_analyze_outputs_json() { let mut fixture = tempfile::NamedTempFile::new().unwrap(); @@ -165,6 +436,88 @@ mod tests { ); } + #[test] + fn session_prune_unreferenced_preserves_active_pod_reference() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let session_store = FsStore::new(data_dir.join("sessions")).unwrap(); + let pod_store = FsPodStore::new(data_dir.join("pods")).unwrap(); + let referenced_session = new_session_id(); + let referenced_segment = new_segment_id(); + let orphan_session = new_session_id(); + let orphan_segment = new_segment_id(); + session_store + .create_segment(referenced_session, referenced_segment, &[]) + .unwrap(); + session_store + .create_segment(orphan_session, orphan_segment, &[]) + .unwrap(); + pod_store + .write(&PodMetadata::new( + "agent", + Some(PodActiveSegmentRef::active_segment( + referenced_session, + referenced_segment, + )), + )) + .unwrap(); + + let output = run_prune_with_roots( + SessionPruneOptions { + unreferenced: true, + older_than: None, + force: true, + dry_run: false, + }, + data_dir, + ) + .unwrap(); + + assert_eq!(output.status, SessionCliStatus::Success); + assert!(output.stdout.contains("deleted=1")); + assert!( + session_store + .exists(referenced_session, referenced_segment) + .unwrap() + ); + assert!( + !session_store + .exists(orphan_session, orphan_segment) + .unwrap() + ); + } + + #[test] + fn session_prune_without_force_is_dry_run() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let session_store = FsStore::new(data_dir.join("sessions")).unwrap(); + let orphan_session = new_session_id(); + let orphan_segment = new_segment_id(); + session_store + .create_segment(orphan_session, orphan_segment, &[]) + .unwrap(); + + let output = run_prune_with_roots( + SessionPruneOptions { + unreferenced: true, + older_than: None, + force: false, + dry_run: false, + }, + data_dir, + ) + .unwrap(); + + assert_eq!(output.status, SessionCliStatus::Success); + assert!(output.stdout.contains("mode: dry-run")); + assert!( + session_store + .exists(orphan_session, orphan_segment) + .unwrap() + ); + } + #[test] fn analyze_requires_json_for_initial_cli() { let err = parse_session_args(&["analyze".to_string(), "/tmp/session.jsonl".to_string()])