merge: 00001KVWPVHFJ storage cleanup cli
# Conflicts: # .yoi/tickets/00001KVWPVHFJ/item.md # .yoi/tickets/00001KVWPVHFJ/thread.md
This commit is contained in:
commit
4fb75ec324
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -6010,6 +6010,7 @@ dependencies = [
|
||||||
"manifest",
|
"manifest",
|
||||||
"memory",
|
"memory",
|
||||||
"pod",
|
"pod",
|
||||||
|
"pod-store",
|
||||||
"project-record",
|
"project-record",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ use crate::{SegmentId, SessionId};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
/// Filesystem-backed JSONL store.
|
/// Filesystem-backed JSONL store.
|
||||||
///
|
///
|
||||||
|
|
@ -41,6 +42,50 @@ impl FsStore {
|
||||||
Ok(Self { root })
|
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<Option<SystemTime>, 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<bool, StoreError> {
|
||||||
|
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 {
|
fn session_dir(&self, session_id: SessionId) -> PathBuf {
|
||||||
self.root.join(session_id.to_string())
|
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)
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ client = { workspace = true }
|
||||||
memory = { workspace = true }
|
memory = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
pod = { workspace = true }
|
pod = { workspace = true }
|
||||||
|
pod-store = { workspace = true }
|
||||||
session-store = { workspace = true }
|
session-store = { workspace = true }
|
||||||
session-analytics = { workspace = true }
|
session-analytics = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ mod mcp_cli;
|
||||||
mod memory_lint;
|
mod memory_lint;
|
||||||
mod objective_cli;
|
mod objective_cli;
|
||||||
mod plugin_cli;
|
mod plugin_cli;
|
||||||
|
mod pod_cleanup_cli;
|
||||||
mod session_cli;
|
mod session_cli;
|
||||||
mod ticket_cli;
|
mod ticket_cli;
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ enum Mode {
|
||||||
Plugin(plugin_cli::PluginCliCommand),
|
Plugin(plugin_cli::PluginCliCommand),
|
||||||
Objective(objective_cli::ObjectiveCli),
|
Objective(objective_cli::ObjectiveCli),
|
||||||
Session(session_cli::SessionCli),
|
Session(session_cli::SessionCli),
|
||||||
|
PodCleanup(pod_cleanup_cli::PodCleanupCli),
|
||||||
Ticket(ticket_cli::TicketCli),
|
Ticket(ticket_cli::TicketCli),
|
||||||
WorkspaceHelp,
|
WorkspaceHelp,
|
||||||
WorkspaceServe(Vec<String>),
|
WorkspaceServe(Vec<String>),
|
||||||
|
|
@ -117,6 +119,7 @@ async fn main() -> ExitCode {
|
||||||
print!("{}", output.stdout);
|
print!("{}", output.stdout);
|
||||||
match output.status {
|
match output.status {
|
||||||
session_cli::SessionCliStatus::Success => ExitCode::SUCCESS,
|
session_cli::SessionCliStatus::Success => ExitCode::SUCCESS,
|
||||||
|
session_cli::SessionCliStatus::Failure => ExitCode::FAILURE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -124,6 +127,19 @@ async fn main() -> ExitCode {
|
||||||
ExitCode::FAILURE
|
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) {
|
Mode::Ticket(cli) => match ticket_cli::run(cli) {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
print!("{}", output.stdout);
|
print!("{}", output.stdout);
|
||||||
|
|
@ -188,7 +204,14 @@ fn parse_args_slice(args: &[String]) -> Result<Mode, ParseError> {
|
||||||
match args[0].as_str() {
|
match args[0].as_str() {
|
||||||
"--help" | "-h" => return Ok(Mode::Help),
|
"--help" | "-h" => return Ok(Mode::Help),
|
||||||
"resume" => return parse_resume_args(&args[1..]),
|
"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" => {
|
"objective" => {
|
||||||
let objective_cli = objective_cli::parse_objective_args(&args[1..])
|
let objective_cli = objective_cli::parse_objective_args(&args[1..])
|
||||||
.map_err(|e| ParseError(e.to_string()))?;
|
.map_err(|e| ParseError(e.to_string()))?;
|
||||||
|
|
@ -878,7 +901,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--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 <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete <NAME> [--force] [--dry-run]\n yoi pod prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--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 <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> 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]
|
#[test]
|
||||||
fn parse_ticket_subcommand_uses_ticket_mode() {
|
fn parse_ticket_subcommand_uses_ticket_mode() {
|
||||||
match parse_args_from(["ticket", "doctor"]).unwrap() {
|
match parse_args_from(["ticket", "doctor"]).unwrap() {
|
||||||
|
|
|
||||||
624
crates/yoi/src/pod_cleanup_cli.rs
Normal file
624
crates/yoi/src/pod_cleanup_cli.rs
Normal file
|
|
@ -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<Option<PodCleanupCli>, 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<PodDeleteOptions, PodCleanupCliError> {
|
||||||
|
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<String>, 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<PodPruneOptions, PodCleanupCliError> {
|
||||||
|
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 <DURATION>".into())
|
||||||
|
})?;
|
||||||
|
Ok(PodPruneOptions {
|
||||||
|
older_than,
|
||||||
|
force,
|
||||||
|
dry_run,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_duration(value: &str) -> Result<Duration, PodCleanupCliError> {
|
||||||
|
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::<u64>()
|
||||||
|
.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<PodCleanupCliOutput, PodCleanupCliError> {
|
||||||
|
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<PodCleanupCliOutput, PodCleanupCliError> {
|
||||||
|
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<PodCleanupCliOutput, PodCleanupCliError> {
|
||||||
|
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<PodCleanupCliOutput, PodCleanupCliError> {
|
||||||
|
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(|| "<pending>".to_string())
|
||||||
|
)),
|
||||||
|
None => stdout.push_str("active_session: <none>\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_modified_at(
|
||||||
|
root: Option<&Path>,
|
||||||
|
pod_name: &str,
|
||||||
|
) -> Result<Option<SystemTime>, 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<String> {
|
||||||
|
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<E: fmt::Display>(error: E) -> PodCleanupCliError {
|
||||||
|
PodCleanupCliError(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn help_text() -> &'static str {
|
||||||
|
"yoi pod\n\nUsage:\n yoi pod delete <NAME> [--force] [--dry-run]\n yoi pod prune --older-than <DURATION> [--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 <NAME> [--force] [--dry-run]"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_help_text() -> &'static str {
|
||||||
|
"usage: yoi pod prune --older-than <DURATION> [--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<String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::path::PathBuf;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum SessionCli {
|
pub enum SessionCli {
|
||||||
Help,
|
Help,
|
||||||
Analyze(SessionAnalyzeOptions),
|
Analyze(SessionAnalyzeOptions),
|
||||||
|
Prune(SessionPruneOptions),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -13,6 +24,14 @@ pub struct SessionAnalyzeOptions {
|
||||||
pub json: bool,
|
pub json: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SessionPruneOptions {
|
||||||
|
pub unreferenced: bool,
|
||||||
|
pub older_than: Option<Duration>,
|
||||||
|
pub force: bool,
|
||||||
|
pub dry_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SessionCliOutput {
|
pub struct SessionCliOutput {
|
||||||
pub stdout: String,
|
pub stdout: String,
|
||||||
|
|
@ -22,6 +41,7 @@ pub struct SessionCliOutput {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SessionCliStatus {
|
pub enum SessionCliStatus {
|
||||||
Success,
|
Success,
|
||||||
|
Failure,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -41,6 +61,7 @@ pub fn parse_session_args(args: &[String]) -> Result<SessionCli, SessionCliError
|
||||||
}
|
}
|
||||||
match args[0].as_str() {
|
match args[0].as_str() {
|
||||||
"analyze" => parse_analyze_args(&args[1..]).map(SessionCli::Analyze),
|
"analyze" => parse_analyze_args(&args[1..]).map(SessionCli::Analyze),
|
||||||
|
"prune" => parse_prune_args(&args[1..]).map(SessionCli::Prune),
|
||||||
other => Err(SessionCliError(format!(
|
other => Err(SessionCliError(format!(
|
||||||
"unknown yoi session command `{other}`"
|
"unknown yoi session command `{other}`"
|
||||||
))),
|
))),
|
||||||
|
|
@ -79,6 +100,65 @@ fn parse_analyze_args(args: &[String]) -> Result<SessionAnalyzeOptions, SessionC
|
||||||
Ok(SessionAnalyzeOptions { path, json })
|
Ok(SessionAnalyzeOptions { path, json })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_prune_args(args: &[String]) -> Result<SessionPruneOptions, SessionCliError> {
|
||||||
|
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<PathBuf>, value: &str) -> Result<(), SessionCliError> {
|
fn set_path(path: &mut Option<PathBuf>, value: &str) -> Result<(), SessionCliError> {
|
||||||
if path.is_some() {
|
if path.is_some() {
|
||||||
return Err(SessionCliError(
|
return Err(SessionCliError(
|
||||||
|
|
@ -105,16 +185,181 @@ pub fn run(cli: SessionCli) -> Result<SessionCliOutput, SessionCliError> {
|
||||||
status: SessionCliStatus::Success,
|
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<SessionCliOutput, SessionCliError> {
|
||||||
|
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<BTreeSet<SessionId>, 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<E: fmt::Display>(error: E) -> SessionCliError {
|
||||||
|
SessionCliError(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn help_text() -> &'static str {
|
pub fn help_text() -> &'static str {
|
||||||
"yoi session\n\nUsage:\n yoi session analyze <SESSION_JSONL_PATH> --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 <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use pod_store::{PodActiveSegmentRef, PodMetadata};
|
||||||
|
use session_store::{Store, new_segment_id, new_session_id};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn run_session_analyze_outputs_json() {
|
fn run_session_analyze_outputs_json() {
|
||||||
let mut fixture = tempfile::NamedTempFile::new().unwrap();
|
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]
|
#[test]
|
||||||
fn analyze_requires_json_for_initial_cli() {
|
fn analyze_requires_json_for_initial_cli() {
|
||||||
let err = parse_session_args(&["analyze".to_string(), "/tmp/session.jsonl".to_string()])
|
let err = parse_session_args(&["analyze".to_string(), "/tmp/session.jsonl".to_string()])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user