diff --git a/crates/vole-cli/src/agent.rs b/crates/vole-cli/src/agent.rs new file mode 100644 index 0000000..151ecd3 --- /dev/null +++ b/crates/vole-cli/src/agent.rs @@ -0,0 +1,436 @@ +//! `vole agent` plan / apply / TTY interactive 接线。 + +use std::env; +use std::io::{self, BufRead, IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crossbeam_channel::unbounded; +use vole_core::mutex::{try_lock_agent, MutexError}; +use vole_core::ops::{ + apply_agent_plan, build_agent_plan, coverage_with_apply_permission_hint, + report_has_permission_skips, AgentApplyError, AgentApplyOptions, AgentPlanOptions, DuPathSize, + LiveGitProbe, APPLY_PERMISSION_WARN, DEFAULT_AGENT_PER_ROOT_SECS, + DEFAULT_AGENT_SCAN_BUDGET_SECS, DEFAULT_AGENT_TTL_SECS, +}; +use vole_core::protection::AppProtection; +use vole_core::units; +use vole_core::vole_proto::{Plan as ProtoPlan, PlanEntry, Report, StreamEvent, SCHEMA_VERSION}; + +use crate::signals; +use crate::tui::{run_paginated_select, MenuItem, MenuState, SelectOutcome}; + +pub struct AgentOptions { + /// `--plan`(隐藏别名 `--dry-run` / `-n`):强制走自动化 plan 路径。 + pub explicit_plan: bool, + pub json: bool, + pub json_stream: bool, + pub plan_out: Option, + pub apply_plan: Option, + pub permanent: bool, +} + +pub fn run_agent(opts: AgentOptions) -> i32 { + match run_agent_inner(opts) { + Ok(()) => 0, + Err(e) if e.kind() == io::ErrorKind::Interrupted => 130, + Err(e) => { + eprintln!("vole agent: {e}"); + 1 + } + } +} + +fn run_agent_inner(opts: AgentOptions) -> io::Result<()> { + let _lock = try_lock_agent().map_err(map_mutex_error)?; + + if let Some(ref plan_path) = opts.apply_plan { + return run_apply(&opts, plan_path); + } + if gate_interactive(io::stdin().is_terminal(), io::stdout().is_terminal(), &opts) { + return run_interactive(&opts); + } + run_plan(opts) +} + +pub(crate) fn agent_scan_spinner_message() -> &'static str { + "Scanning leftover agent data..." +} + +/// TTY 裸调用进入交互多选的门控(可单测,不依赖真实 TTY)。 +pub(crate) fn gate_interactive(stdin_tty: bool, stdout_tty: bool, opts: &AgentOptions) -> bool { + stdin_tty + && stdout_tty + && !opts.explicit_plan + && !opts.json + && !opts.json_stream + && opts.plan_out.is_none() + && opts.apply_plan.is_none() +} + +fn plan_options<'a>(home: &'a Path, cwd: &'a Path, git: &'a LiveGitProbe) -> AgentPlanOptions<'a> { + let budget_secs = env::var("VOLE_TIMEOUT_AGENT_SCAN_SEC") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_AGENT_SCAN_BUDGET_SECS); + AgentPlanOptions { + home, + cwd, + ttl_secs: DEFAULT_AGENT_TTL_SECS, + now: SystemTime::now(), + search_roots: None, + budget: Duration::from_secs(budget_secs), + per_root: Duration::from_secs(DEFAULT_AGENT_PER_ROOT_SECS), + git, + size_probe: Some(std::sync::Arc::new(DuPathSize)), + } +} + +fn run_interactive(opts: &AgentOptions) -> io::Result<()> { + let spinner = crate::tty_spinner::TtySpinner::start(agent_scan_spinner_message()); + let home = env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| io::Error::other("HOME not set"))?; + let cwd = env::current_dir()?; + let protection = AppProtection::new(); + let git = LiveGitProbe; + let plan_opts = plan_options(&home, &cwd, &git); + let plan = + build_agent_plan(&protection, &plan_opts).map_err(|e| io::Error::other(e.to_string()))?; + + if plan.entries.is_empty() { + spinner.stop(); + eprintln!("No leftover agent data found."); + return Ok(()); + } + spinner.stop(); + + let selected_idxs = loop { + let items: Vec = plan.entries.iter().map(menu_item_from_entry).collect(); + let mut cfg = MenuState::config_from_env(); + cfg.ignore_initial_enter = true; + cfg.preselected = Vec::new(); + if let Ok((_, rows)) = crossterm::terminal::size() { + cfg.term_height = rows; + } + + match run_paginated_select("Select Agent Leftovers to Remove", items, cfg)? { + SelectOutcome::Cancelled => return Ok(()), + SelectOutcome::Back => crate::interactive::exit_to_home(), + SelectOutcome::Confirmed(idxs) if idxs.is_empty() => { + eprintln!("No items selected"); + continue; + } + SelectOutcome::Confirmed(idxs) => break idxs, + } + }; + + eprintln!( + "Selected {} agent leftover(s) for removal:", + selected_idxs.len() + ); + for &i in &selected_idxs { + let entry = &plan.entries[i]; + eprintln!(" - {} ({})", entry.label, entry.path.display()); + } + eprint!("Proceed with agent removal? [y/N] "); + let _ = io::stderr().flush(); + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + if !line.trim().eq_ignore_ascii_case("y") { + eprintln!("Aborted."); + return Ok(()); + } + + let apply_plan = filter_plan_entries(plan, &selected_idxs); + if apply_plan.entries.is_empty() { + eprintln!("Nothing to remove for the selection."); + return Ok(()); + } + + let apply_opts = AgentApplyOptions { + permanent: opts.permanent, + }; + let report = + apply_agent_plan(&apply_plan, &protection, apply_opts, None).map_err(map_apply_error)?; + print_human_report(&report); + Ok(()) +} + +fn run_plan(opts: AgentOptions) -> io::Result<()> { + let home = env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| io::Error::other("HOME not set"))?; + let cwd = env::current_dir()?; + let protection = AppProtection::new(); + let git = LiveGitProbe; + let plan_opts = plan_options(&home, &cwd, &git); + + let cancel = vole_core::cancel::CancelToken::new(); + signals::spawn_signal_cancel(cancel); + + let stream_tx = if opts.json_stream { + let (event_tx, event_rx) = unbounded(); + let writer = spawn_stream_writer(event_rx)?; + let _ = event_tx.send(StreamEvent::Progress { + scanned: 0, + current: "scanning leftover agent data".into(), + }); + Some((event_tx, writer)) + } else { + None + }; + + let plan = + build_agent_plan(&protection, &plan_opts).map_err(|e| io::Error::other(e.to_string()))?; + + if let Some((event_tx, writer)) = stream_tx { + let _ = event_tx.send(StreamEvent::Done { + report: Report { + coverage_note: plan.coverage_note.clone(), + ..Report::default() + }, + }); + drop(event_tx); + writer + .join() + .map_err(|_| io::Error::other("stream writer panicked"))??; + } + + write_plan_output(&opts, &plan)?; + Ok(()) +} + +fn run_apply(opts: &AgentOptions, plan_path: &Path) -> io::Result<()> { + let json = std::fs::read_to_string(plan_path)?; + let plan: ProtoPlan = serde_json::from_str(&json).map_err(io::Error::other)?; + if plan.schema_version != SCHEMA_VERSION { + return Err(io::Error::other(format!( + "unsupported plan schema version {}", + plan.schema_version + ))); + } + + let protection = AppProtection::new(); + let apply_opts = AgentApplyOptions { + permanent: opts.permanent, + }; + + let mut report = if opts.json_stream { + let (event_tx, event_rx) = unbounded(); + let writer = spawn_stream_writer(event_rx)?; + let on_event = |event: StreamEvent| { + let _ = event_tx.send(event); + }; + let report = apply_agent_plan(&plan, &protection, apply_opts, Some(&on_event)) + .map_err(map_apply_error)?; + drop(event_tx); + writer + .join() + .map_err(|_| io::Error::other("stream writer panicked"))??; + report + } else { + apply_agent_plan(&plan, &protection, apply_opts, None).map_err(map_apply_error)? + }; + + if should_use_json(opts.json) { + report.coverage_note = + coverage_with_apply_permission_hint(report.coverage_note.as_deref(), &report); + println!( + "{}", + serde_json::to_string_pretty(&report).map_err(io::Error::other)? + ); + } else { + print_human_report(&report); + } + Ok(()) +} + +fn menu_item_from_entry(entry: &PlanEntry) -> MenuItem { + let path_str = entry.path.display().to_string(); + let home = env::var_os("HOME").map(PathBuf::from); + MenuItem { + label: format_agent_menu_label(entry, home.as_deref()), + filter_name: Some(path_str), + epoch: mtime_epoch(entry.mtime), + size_kb: Some(entry.size / 1024), + } +} + +pub(crate) fn format_agent_menu_label(entry: &PlanEntry, home: Option<&Path>) -> String { + let kind = entry + .rule_id + .strip_prefix("agent:") + .unwrap_or(entry.rule_id.as_str()); + let blockers = if entry.blockers.is_empty() { + "-".to_string() + } else { + entry.blockers.join(",") + }; + format!( + "{kind}\npath {}\nblockers {blockers}", + display_home_path(&entry.path, home) + ) +} + +fn display_home_path(path: &Path, home: Option<&Path>) -> String { + let raw = path.display().to_string(); + let Some(home) = home else { + return raw; + }; + let home = home.to_string_lossy(); + if raw.starts_with(home.as_ref()) { + raw.replacen(home.as_ref(), "~", 1) + } else { + raw + } +} + +fn filter_plan_entries(mut plan: ProtoPlan, idxs: &[usize]) -> ProtoPlan { + let keep: std::collections::HashSet = idxs.iter().copied().collect(); + plan.entries = plan + .entries + .into_iter() + .enumerate() + .filter(|(i, _)| keep.contains(i)) + .map(|(_, e)| e) + .collect(); + plan +} + +fn mtime_epoch(mtime: SystemTime) -> Option { + mtime + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_secs() as i64) +} + +fn write_plan_output(opts: &AgentOptions, plan: &ProtoPlan) -> io::Result<()> { + let json = serde_json::to_string_pretty(plan).map_err(io::Error::other)?; + if let Some(ref path) = opts.plan_out { + std::fs::write(path, &json)?; + } + if should_use_json(opts.json) { + println!("{json}"); + } else if opts.plan_out.is_none() { + print_human_plan(plan); + } + Ok(()) +} + +fn print_human_plan(plan: &ProtoPlan) { + eprintln!( + "agent plan: {} entries (ttl {}s)", + plan.entries.len(), + plan.ttl_secs + ); + for entry in &plan.entries { + if entry.blockers.is_empty() { + eprintln!( + " {} {} {}", + units::bytes_bin(entry.size), + entry.rule_id, + entry.path.display() + ); + } else { + eprintln!( + " {} {} {} blockers={}", + units::bytes_bin(entry.size), + entry.rule_id, + entry.path.display(), + entry.blockers.join(",") + ); + } + } + if let Some(note) = &plan.coverage_note { + eprintln!("\n{note}"); + } +} + +fn print_human_report(report: &Report) { + eprintln!( + "agent apply: succeeded={} skipped={} failed={} trashed={} deleted={}", + report.succeeded, + report.skipped, + report.failed, + units::bytes_bin(report.trashed_bytes), + units::bytes_bin(report.deleted_bytes) + ); + if let Some(note) = &report.coverage_note { + eprintln!("{note}"); + } + if report_has_permission_skips(report) { + eprintln!("{APPLY_PERMISSION_WARN}"); + } +} + +fn should_use_json(force: bool) -> bool { + force || !io::stdout().is_terminal() +} + +fn spawn_stream_writer( + event_rx: crossbeam_channel::Receiver, +) -> io::Result>> { + thread::Builder::new() + .name("vole-agent-stream".into()) + .spawn(move || { + let stdout = io::stdout(); + let mut out = stdout.lock(); + while let Ok(event) = event_rx.recv() { + let value = event.with_schema(SCHEMA_VERSION); + let line = serde_json::to_string(&value).map_err(io::Error::other)?; + out.write_all(line.as_bytes())?; + out.write_all(b"\n")?; + out.flush()?; + } + Ok(()) + }) + .map_err(io::Error::other) +} + +fn map_mutex_error(e: MutexError) -> io::Error { + match e { + MutexError::AlreadyRunning => io::Error::other("another vole agent is running"), + other => io::Error::other(other.to_string()), + } +} + +fn map_apply_error(e: AgentApplyError) -> io::Error { + io::Error::other(e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interactive_gate_requires_bare_tty() { + let bare = AgentOptions { + explicit_plan: false, + json: false, + json_stream: false, + plan_out: None, + apply_plan: None, + permanent: false, + }; + assert!(!gate_interactive(false, false, &bare)); + assert!(gate_interactive(true, true, &bare)); + assert!(!gate_interactive( + true, + true, + &AgentOptions { + explicit_plan: true, + ..bare + } + )); + } + + #[test] + fn agent_scan_spinner_message_is_fixed() { + assert_eq!( + agent_scan_spinner_message(), + "Scanning leftover agent data..." + ); + } +} diff --git a/crates/vole-cli/src/main.rs b/crates/vole-cli/src/main.rs index 2a99cdf..e57aa42 100644 --- a/crates/vole-cli/src/main.rs +++ b/crates/vole-cli/src/main.rs @@ -1,6 +1,7 @@ //! vole 命令行入口。 #![forbid(unsafe_code)] +mod agent; mod clean; mod clean_group; mod history_cmd; @@ -272,6 +273,31 @@ enum Command { #[arg(long, conflicts_with = "apply")] plan_out: Option, }, + /// List leftover agent containers, sessions, and caches; move selected items to Trash. + /// + /// Blockers are shown; you confirm each removal. This is not a git + /// checkout cleaner (`vole worktree` owns checkouts). + /// + /// On a TTY with no flags: scan, paginated select (none preselected), + /// confirm, then trash. With `--plan` / `--json`, or when not a TTY: + /// emit a plan only. + Agent { + /// Emit candidates only; do not delete (default when not a TTY; on a TTY skips interactive UI). + #[arg(long, alias = "dry-run", short = 'n', conflicts_with = "apply")] + plan: bool, + /// Apply entries from a plan file (TTL + TOCTOU revalidation required). + #[arg(long, value_name = "PLAN", conflicts_with_all = ["plan", "plan_out"])] + apply: Option, + /// Permanently delete instead of moving to Trash (`--apply` or after interactive confirm). + #[arg(long)] + permanent: bool, + #[arg(long)] + json: bool, + #[arg(long = "json-stream")] + json_stream: bool, + #[arg(long, conflicts_with = "apply")] + plan_out: Option, + }, /// Remove stale project build artifacts. /// /// On a TTY with no flags: paginated select, confirm, then purge. @@ -516,6 +542,24 @@ fn main() { }); std::process::exit(code); } + Some(Command::Agent { + plan, + apply, + permanent, + json, + json_stream, + plan_out, + }) => { + let code = agent::run_agent(agent::AgentOptions { + explicit_plan: plan, + json, + json_stream, + plan_out, + apply_plan: apply, + permanent, + }); + std::process::exit(code); + } Some(Command::Purge { plan, apply, diff --git a/crates/vole-cli/src/tui/home_menu.rs b/crates/vole-cli/src/tui/home_menu.rs index b32e6f2..17aefc4 100644 --- a/crates/vole-cli/src/tui/home_menu.rs +++ b/crates/vole-cli/src/tui/home_menu.rs @@ -78,7 +78,7 @@ pub fn map_key(key: KeyEvent) -> Option { KeyCode::Esc => Some(HomeKey::Quit), KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(HomeKey::Quit), KeyCode::Char(c) => match c { - '1'..='6' => Some(HomeKey::Digit(c as u8 - b'0')), + '1'..='7' => Some(HomeKey::Digit(c as u8 - b'0')), 'h' | 'H' => Some(HomeKey::Help), 'v' | 'V' => Some(HomeKey::Version), 't' | 'T' => Some(HomeKey::TouchId), @@ -321,6 +321,10 @@ mod tests { map_key(KeyEvent::new(KeyCode::Char('6'), KeyModifiers::NONE)), Some(HomeKey::Digit(6)) )); + assert!(matches!( + map_key(KeyEvent::new(KeyCode::Char('7'), KeyModifiers::NONE)), + Some(HomeKey::Digit(7)) + )); assert!(matches!( map_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)), Some(HomeKey::Digit(1)) diff --git a/crates/vole-cli/src/tui/home_menu_state.rs b/crates/vole-cli/src/tui/home_menu_state.rs index f811d11..71101ec 100644 --- a/crates/vole-cli/src/tui/home_menu_state.rs +++ b/crates/vole-cli/src/tui/home_menu_state.rs @@ -18,7 +18,7 @@ pub fn format_home_item_line(index: usize, selected: bool, item: &HomeItem) -> S ) } -pub const HOME_ITEMS: [HomeItem; 6] = [ +pub const HOME_ITEMS: [HomeItem; 7] = [ HomeItem { title: "Clean", description: "Free up disk space", @@ -43,6 +43,10 @@ pub const HOME_ITEMS: [HomeItem; 6] = [ title: "Worktree", description: "Remove leftover git worktrees", }, + HomeItem { + title: "Agent", + description: "Remove leftover agent data", + }, ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -53,6 +57,7 @@ pub enum HomeCommand { Analyze, Status, Worktree, + Agent, TouchId, Update, } @@ -66,6 +71,7 @@ impl HomeCommand { Self::Analyze => &["analyze"], Self::Status => &["status"], Self::Worktree => &["worktree"], + Self::Agent => &["agent"], Self::TouchId => &["touchid"], Self::Update => &["update"], } @@ -120,7 +126,8 @@ impl HomeMenuState { 2 => HomeCommand::Optimize, 3 => HomeCommand::Analyze, 4 => HomeCommand::Status, - _ => HomeCommand::Worktree, + 5 => HomeCommand::Worktree, + _ => HomeCommand::Agent, } } @@ -139,7 +146,7 @@ impl HomeMenuState { None } HomeKey::Enter => Some(HomeAction::Launch(Self::cmd_at(self.cursor))), - HomeKey::Digit(d) if (1..=6).contains(&d) => { + HomeKey::Digit(d) if (1..=7).contains(&d) => { Some(HomeAction::Launch(Self::cmd_at((d - 1) as usize))) } HomeKey::Help => Some(HomeAction::ShowHelp), @@ -187,6 +194,10 @@ mod tests { format_home_item_line(5, false, &HOME_ITEMS[5]), " 6. Worktree Remove leftover git worktrees" ); + assert_eq!( + format_home_item_line(6, false, &HOME_ITEMS[6]), + " 7. Agent Remove leftover agent data" + ); } #[test] @@ -201,9 +212,11 @@ mod tests { assert_eq!(HOME_ITEMS[3].description, "Explore disk usage"); assert_eq!(HOME_ITEMS[4].title, "Status"); assert_eq!(HOME_ITEMS[4].description, "Monitor system health"); - assert_eq!(HOME_ITEMS.len(), 6); + assert_eq!(HOME_ITEMS.len(), 7); assert_eq!(HOME_ITEMS[5].title, "Worktree"); assert_eq!(HOME_ITEMS[5].description, "Remove leftover git worktrees"); + assert_eq!(HOME_ITEMS[6].title, "Agent"); + assert_eq!(HOME_ITEMS[6].description, "Remove leftover agent data"); } #[test] @@ -319,4 +332,33 @@ mod tests { Some(HomeAction::Launch(HomeCommand::Clean)) ); } + + #[test] + fn digit_seven_launches_agent_digits_one_to_six_unchanged() { + let mut st = HomeMenuState::new(HomeMenuConfig { + touchid_configured: true, + show_update: false, + }); + assert_eq!( + st.handle_key(HomeKey::Digit(7)), + Some(HomeAction::Launch(HomeCommand::Agent)) + ); + assert_eq!(HomeCommand::Agent.argv(), &["agent"]); + assert_eq!( + st.handle_key(HomeKey::Digit(6)), + Some(HomeAction::Launch(HomeCommand::Worktree)) + ); + assert_eq!( + st.handle_key(HomeKey::Digit(1)), + Some(HomeAction::Launch(HomeCommand::Clean)) + ); + for _ in 0..6 { + assert!(st.handle_key(HomeKey::Down).is_none()); + } + assert_eq!(st.cursor(), 6); + assert_eq!( + st.handle_key(HomeKey::Enter), + Some(HomeAction::Launch(HomeCommand::Agent)) + ); + } } diff --git a/crates/vole-cli/tests/agent_cli.rs b/crates/vole-cli/tests/agent_cli.rs new file mode 100644 index 0000000..a9c81c0 --- /dev/null +++ b/crates/vole-cli/tests/agent_cli.rs @@ -0,0 +1,116 @@ +use std::fs; +use std::process::Command; + +#[test] +fn agent_help_lists_command_and_avoids_safe_verdict() { + let output = Command::new(env!("CARGO_BIN_EXE_vole")) + .args(["agent", "--help"]) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase(); + assert!(stdout.contains("--plan")); + assert!(stdout.contains("--apply")); + assert!(stdout.contains("trash")); + assert!(!stdout.contains("safe to delete")); + assert!(!stdout.contains("deletable")); +} + +#[test] +fn plan_json_lists_cache_and_apply_trashes_it() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let cache = home.join(".cursor/Cache"); + fs::create_dir_all(&cache).unwrap(); + fs::write(cache.join("x"), b"hello").unwrap(); + let cwd = home.join("Projects/demo"); + fs::create_dir_all(&cwd).unwrap(); + + let out = Command::new(env!("CARGO_BIN_EXE_vole")) + .env("HOME", home) + .env("VOLE_TIMEOUT_AGENT_SCAN_SEC", "15") + .current_dir(&cwd) + .args(["agent", "--plan", "--json"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("agent:cache")); + assert!(!stdout.contains("\"safe\"")); + let plan: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(plan["schema_version"], 1); + + let plan_path = dir.path().join("plan.json"); + fs::write(&plan_path, stdout.as_bytes()).unwrap(); + let trash = dir.path().join("trash"); + fs::create_dir_all(&trash).unwrap(); + let apply = Command::new(env!("CARGO_BIN_EXE_vole")) + .env("HOME", home) + .env("MOLE_TEST_TRASH_DIR", &trash) + .current_dir(&cwd) + .args(["agent", "--apply", plan_path.to_str().unwrap()]) + .output() + .unwrap(); + assert!( + apply.status.success(), + "stderr={}", + String::from_utf8_lossy(&apply.stderr) + ); + assert!(!cache.exists(), "cache should be gone"); +} + +#[test] +fn plan_excludes_worktree_checkout() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let repo = home.join("Projects/demo"); + fs::create_dir_all(&repo).unwrap(); + let git = |args: &[&str]| { + let st = Command::new("git") + .args(args) + .current_dir(&repo) + .env("GIT_AUTHOR_NAME", "vole") + .env("GIT_AUTHOR_EMAIL", "vole@test") + .env("GIT_COMMITTER_NAME", "vole") + .env("GIT_COMMITTER_EMAIL", "vole@test") + .status() + .unwrap(); + assert!(st.success(), "git {args:?}"); + }; + git(&["init"]); + fs::write(repo.join("README"), b"x").unwrap(); + git(&["add", "README"]); + git(&["commit", "-m", "init"]); + let wt = repo.join(".worktrees/old"); + fs::create_dir_all(repo.join(".worktrees")).unwrap(); + let st = Command::new("git") + .args(["worktree", "add", "--detach", wt.to_str().unwrap()]) + .current_dir(&repo) + .status() + .unwrap(); + assert!(st.success()); + + let out = Command::new(env!("CARGO_BIN_EXE_vole")) + .env("HOME", home) + .current_dir(&repo) + .args(["agent", "--plan", "--json"]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(!stdout.contains(wt.to_string_lossy().as_ref())); + assert!(!stdout.contains("worktree:")); +} + +#[test] +fn top_level_hints_still_rejected() { + let output = Command::new(env!("CARGO_BIN_EXE_vole")) + .args(["hints"]) + .output() + .unwrap(); + assert!(!output.status.success()); +} diff --git a/crates/vole-cli/tests/interactive_cli.rs b/crates/vole-cli/tests/interactive_cli.rs index eca53ef..e0f8a80 100644 --- a/crates/vole-cli/tests/interactive_cli.rs +++ b/crates/vole-cli/tests/interactive_cli.rs @@ -58,6 +58,7 @@ fn subcommand_help_has_no_mole_mentions() { "remove", "completions", "worktree", + "agent", ] { let output = Command::new(env!("CARGO_BIN_EXE_vole")) .args([cmd, "--help"]) @@ -138,6 +139,7 @@ fn top_level_help_includes_subcommand_options() { "Usage: vole history", "Usage: vole completions", "Usage: vole worktree", + "Usage: vole agent", ] { assert!( stdout.contains(needle), diff --git a/crates/vole-cli/tests/plan_alias_cli.rs b/crates/vole-cli/tests/plan_alias_cli.rs index 04765ec..fb9332b 100644 --- a/crates/vole-cli/tests/plan_alias_cli.rs +++ b/crates/vole-cli/tests/plan_alias_cli.rs @@ -14,6 +14,7 @@ const PLAN_COMMANDS: &[&str] = &[ "installer", "touchid", "remove", + "agent", ]; #[test] diff --git a/crates/vole-core/src/mutex.rs b/crates/vole-core/src/mutex.rs index 315a60e..bb9e0fa 100644 --- a/crates/vole-core/src/mutex.rs +++ b/crates/vole-core/src/mutex.rs @@ -75,6 +75,10 @@ pub fn try_lock_worktree() -> Result { try_lock_config("worktree") } +pub fn try_lock_agent() -> Result { + try_lock_config("agent") +} + pub fn try_lock_config(name: &str) -> Result { let path = cache_dir().join(format!("{}.lock", name)); let file = try_lock_path(&path)?; @@ -111,4 +115,17 @@ mod tests { std::env::remove_var("HOME"); std::fs::remove_dir_all(&dir).ok(); } + + #[test] + fn second_agent_lock_fails_nonblocking() { + let _guard = test_env::lock(); + let dir = std::env::temp_dir().join(format!("vole-mutex-agent-{}", std::process::id())); + std::fs::remove_dir_all(&dir).ok(); + std::env::set_var("HOME", dir.join("home")); + let _a = try_lock_agent().expect("first lock"); + let b = try_lock_agent(); + assert!(matches!(b, Err(MutexError::Rustix(_)))); + std::env::remove_var("HOME"); + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/crates/vole-core/src/ops/agent_apply.rs b/crates/vole-core/src/ops/agent_apply.rs new file mode 100644 index 0000000..071b77b --- /dev/null +++ b/crates/vole-core/src/ops/agent_apply.rs @@ -0,0 +1,500 @@ +//! `agent` apply:TTL + TOCTOU + 既有废纸篓漏斗。 + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use thiserror::Error; +use vole_sys::Trash; + +use crate::delete::{ + mole_delete_verified, DeleteMode, DeletionLogger, MoleDeleteError, MoleDeleteOptions, +}; +use crate::oplog::OperationLogger; +use crate::ops::agent_plan::is_cwd_excluded; +use crate::ops::worktree_plan::{ + collect_worktree_claimed_paths, looks_like_git_checkout, GitProbe, LiveGitProbe, +}; +use crate::protection::AppProtection; +use crate::safety::{ + verify_plan_entry_for_apply, PlanApplyError, PlanEntryIdentity, ValidationError, +}; +use crate::vole_proto::{ + Plan as ProtoPlan, PlanEntry as ProtoPlanEntry, Report, SkipReason, SkipSummary, StreamEvent, + SCHEMA_VERSION, +}; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum AgentApplyError { + #[error("plan expired; rescan with `vole agent --plan`")] + Expired, + #[error("unsupported plan schema version {got} (expected {expected})")] + UnsupportedSchema { expected: u32, got: u32 }, +} + +#[derive(Debug, Clone, Copy)] +pub struct AgentApplyOptions { + pub permanent: bool, +} + +pub struct AgentApplyContext<'a> { + pub protection: &'a AppProtection, + pub whitelist_patterns: &'a [String], + pub options: AgentApplyOptions, + pub trash: &'a dyn Trash, + pub deletion_log: &'a DeletionLogger, + pub oplog: &'a mut OperationLogger, + pub on_event: Option<&'a dyn Fn(StreamEvent)>, + pub now: SystemTime, + pub cwd: PathBuf, + pub home: PathBuf, + pub git: &'a dyn GitProbe, + pub search_roots: Option<&'a [PathBuf]>, +} + +pub fn apply_agent_plan( + plan: &ProtoPlan, + protection: &AppProtection, + options: AgentApplyOptions, + on_event: Option<&dyn Fn(StreamEvent)>, +) -> Result { + let deletion_log = DeletionLogger::from_env(); + let mut oplog = OperationLogger::new("agent"); + let _ = oplog.session_start(); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/")); + let git = LiveGitProbe; + let mut ctx = AgentApplyContext { + protection, + whitelist_patterns: &[], + options, + trash: &vole_sys::macos::MacTrash, + deletion_log: &deletion_log, + oplog: &mut oplog, + on_event, + now: SystemTime::now(), + cwd, + home, + git: &git, + search_roots: None, + }; + let report = apply_agent_proto_plan(plan, &mut ctx)?; + let _ = oplog.session_end( + report.succeeded, + report.trashed_bytes / 1024 + report.deleted_bytes / 1024, + ); + Ok(report) +} + +pub fn apply_agent_proto_plan( + plan: &ProtoPlan, + ctx: &mut AgentApplyContext<'_>, +) -> Result { + if plan.schema_version != SCHEMA_VERSION { + return Err(AgentApplyError::UnsupportedSchema { + expected: SCHEMA_VERSION, + got: plan.schema_version, + }); + } + if plan_is_expired(plan, ctx.now) { + return Err(AgentApplyError::Expired); + } + + let delete_mode = if ctx.options.permanent { + DeleteMode::Permanent + } else { + DeleteMode::Trash + }; + + let mut succeeded = 0u64; + let mut skipped = 0u64; + let mut failed = 0u64; + let mut trashed_bytes = 0u64; + let mut deleted_bytes = 0u64; + let mut skip_tracker = SkipTracker::default(); + let cwd = ctx.cwd.canonicalize().unwrap_or_else(|_| ctx.cwd.clone()); + let claimed = collect_worktree_claimed_paths(&ctx.home, &ctx.cwd, ctx.git, ctx.search_roots); + + for (idx, entry) in plan.entries.iter().enumerate() { + if let Some(event) = &ctx.on_event { + event(StreamEvent::Progress { + scanned: idx as u64 + 1, + current: entry.path.display().to_string(), + }); + } + + if entry.skip_reason.is_some() { + skipped += 1; + skip_tracker.record(SkipReason::PathVanished, &entry.rule_id); + continue; + } + + if !is_agent_rule(&entry.rule_id) { + skipped += 1; + skip_tracker.record(SkipReason::Whitelisted, &entry.rule_id); + continue; + } + + let canon = entry + .path + .canonicalize() + .unwrap_or_else(|_| entry.path.clone()); + if is_hard_excluded(&canon, &cwd) || claimed.contains(&canon) { + skipped += 1; + skip_tracker.record(SkipReason::Whitelisted, &entry.rule_id); + continue; + } + + let path = entry.path.display().to_string(); + let identity = proto_identity(entry); + if let Err(err) = verify_plan_entry_for_apply(&path, &identity, ctx.protection) { + skipped += 1; + skip_tracker.record(skip_reason_for_apply(&err), &entry.rule_id); + continue; + } + + let delete_opts = MoleDeleteOptions { + mode: delete_mode, + dry_run: false, + needs_sudo: false, + privilege: None, + }; + + match mole_delete_verified( + &path, + &identity, + ctx.protection, + ctx.whitelist_patterns, + delete_opts, + ctx.trash, + ctx.deletion_log, + ctx.oplog, + ) { + Ok(outcome) => { + succeeded += 1; + match delete_mode { + DeleteMode::Trash => trashed_bytes += outcome.bytes, + DeleteMode::Permanent => deleted_bytes += outcome.bytes, + } + } + Err(MoleDeleteError::Whitelisted) => { + skipped += 1; + skip_tracker.record(SkipReason::Whitelisted, &entry.rule_id); + } + Err(MoleDeleteError::Rejected) + | Err(MoleDeleteError::IdentityMismatch) + | Err(MoleDeleteError::Vanished) => { + skipped += 1; + skip_tracker.record(SkipReason::PathVanished, &entry.rule_id); + } + Err(_) => { + failed += 1; + } + } + } + + let report = Report { + succeeded, + skipped, + failed, + skipped_by_reason: skip_tracker.into_summaries(), + trashed_bytes, + deleted_bytes, + coverage_note: plan.coverage_note.clone(), + }; + + if let Some(event) = &ctx.on_event { + event(StreamEvent::Done { + report: report.clone(), + }); + } + + Ok(report) +} + +fn is_agent_rule(rule_id: &str) -> bool { + matches!(rule_id, "agent:container" | "agent:session" | "agent:cache") +} + +fn is_hard_excluded(canon: &Path, cwd: &Path) -> bool { + if is_cwd_excluded(canon, cwd) { + return true; + } + if looks_like_git_checkout(canon) { + return true; + } + canon.components().any(|c| { + let n = c.as_os_str(); + n == "worktrees" || n == ".worktrees" + }) +} + +fn plan_is_expired(plan: &ProtoPlan, now: SystemTime) -> bool { + let ttl = Duration::from_secs(plan.ttl_secs); + plan.created_at + .checked_add(ttl) + .is_none_or(|expires| now > expires) +} + +fn proto_identity(entry: &ProtoPlanEntry) -> PlanEntryIdentity { + let mtime = entry + .mtime + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs() as i64; + PlanEntryIdentity { + dev: entry.dev, + ino: entry.ino, + mtime, + } +} + +fn skip_reason_for_apply(err: &PlanApplyError) -> SkipReason { + match err { + PlanApplyError::Policy(ValidationError::EndpointSecurityCache) => SkipReason::TccDenied, + PlanApplyError::Policy(ValidationError::ProtectedPath) + | PlanApplyError::Policy(ValidationError::CriticalSystemPath) + | PlanApplyError::Policy(ValidationError::SymlinkToCritical) + | PlanApplyError::Policy(ValidationError::AncestorResolvesToCritical) => { + SkipReason::NeedsPrivilege + } + _ => SkipReason::PathVanished, + } +} + +#[derive(Default)] +struct SkipTracker { + entries: Vec, +} + +impl SkipTracker { + fn record(&mut self, reason: SkipReason, rule_id: &str) { + if let Some(summary) = self.entries.iter_mut().find(|s| s.reason == reason) { + summary.count += 1; + if !summary.rule_ids.iter().any(|id| id == rule_id) { + summary.rule_ids.push(rule_id.to_string()); + } + return; + } + self.entries.push(SkipSummary { + reason, + count: 1, + rule_ids: vec![rule_id.to_string()], + }); + } + + fn into_summaries(self) -> Vec { + self.entries + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ops::worktree_plan::GitProbe; + use crate::protection::AppProtection; + use crate::vole_proto::{Plan as ProtoPlan, PlanEntry as ProtoPlanEntry, SCHEMA_VERSION}; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct NoopGit; + + impl GitProbe for NoopGit { + fn worktree_list(&self, _repo: &Path) -> Result { + Ok(String::new()) + } + fn status_porcelain(&self, _w: &Path, _i: bool) -> Result { + Ok(String::new()) + } + fn log_unpushed(&self, _w: &Path) -> Result { + Ok(String::new()) + } + fn last_commit_unix(&self, _w: &Path) -> Result, String> { + Ok(None) + } + fn rev_parse_toplevel(&self, _cwd: &Path) -> Result { + Err("no repo".into()) + } + fn prune(&self, _repo: &Path) -> Result<(), String> { + Ok(()) + } + fn unlock(&self, _repo: &Path, _w: &Path) -> Result<(), String> { + Ok(()) + } + } + + struct ClaimGit { + repo: PathBuf, + extra: PathBuf, + } + + impl GitProbe for ClaimGit { + fn worktree_list(&self, repo: &Path) -> Result { + if repo != self.repo { + return Ok(String::new()); + } + Ok(format!( + "worktree {}\nHEAD abc\n\nworktree {}\nHEAD def\n", + self.repo.display(), + self.extra.display() + )) + } + fn status_porcelain(&self, _w: &Path, _i: bool) -> Result { + Ok(String::new()) + } + fn log_unpushed(&self, _w: &Path) -> Result { + Ok(String::new()) + } + fn last_commit_unix(&self, _w: &Path) -> Result, String> { + Ok(None) + } + fn rev_parse_toplevel(&self, _cwd: &Path) -> Result { + Err("no repo".into()) + } + fn prune(&self, _repo: &Path) -> Result<(), String> { + Ok(()) + } + fn unlock(&self, _repo: &Path, _w: &Path) -> Result<(), String> { + Ok(()) + } + } + + fn empty_plan(rule_id: &str, path: PathBuf) -> ProtoPlan { + ProtoPlan { + schema_version: SCHEMA_VERSION, + created_at: SystemTime::now(), + ttl_secs: 900, + coverage_note: None, + entries: vec![ProtoPlanEntry { + id: "x".into(), + path, + label: "cache cursor blockers=- /tmp".into(), + size: 0, + rule_id: rule_id.into(), + skip_reason: None, + dev: 0, + ino: 0, + mtime: UNIX_EPOCH, + blockers: vec![], + }], + } + } + + fn apply_with( + plan: &ProtoPlan, + git: &dyn GitProbe, + home: PathBuf, + cwd: PathBuf, + search_roots: &[PathBuf], + ) -> Result { + let protection = AppProtection::new(); + let deletion_log = DeletionLogger::from_env(); + let mut oplog = OperationLogger::new("agent"); + let mut ctx = AgentApplyContext { + protection: &protection, + whitelist_patterns: &[], + options: AgentApplyOptions { permanent: false }, + trash: &vole_sys::macos::MacTrash, + deletion_log: &deletion_log, + oplog: &mut oplog, + on_event: None, + now: SystemTime::now(), + cwd, + home, + git, + search_roots: Some(search_roots), + }; + apply_agent_proto_plan(plan, &mut ctx) + } + + #[test] + fn skips_non_agent_rule_ids() { + let report = apply_agent_plan( + &empty_plan("worktree:linked", PathBuf::from("/tmp")), + &AppProtection::new(), + AgentApplyOptions { permanent: false }, + None, + ) + .unwrap(); + assert_eq!(report.succeeded, 0); + assert_eq!(report.skipped, 1); + } + + #[test] + fn skips_cwd_and_git_checkout_even_if_in_plan() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("cwd"); + let checkout = dir.path().join("wt"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(checkout.join(".git")).unwrap(); + let mut plan = empty_plan("agent:cache", cwd.clone()); + plan.entries.push(ProtoPlanEntry { + id: "y".into(), + path: checkout.clone(), + label: "container cursor blockers=- x".into(), + size: 0, + rule_id: "agent:container".into(), + skip_reason: None, + dev: 0, + ino: 0, + mtime: UNIX_EPOCH, + blockers: vec![], + }); + let report = + apply_with(&plan, &NoopGit, dir.path().to_path_buf(), cwd.clone(), &[]).unwrap(); + assert_eq!(report.succeeded, 0); + assert!(report.skipped >= 2); + assert!(cwd.exists()); + assert!(checkout.exists()); + } + + #[test] + fn skips_worktree_claimed_orphan_dir_even_if_stuffed_in_plan() { + let _guard = crate::test_env::lock(); + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path().join("repo"); + std::fs::create_dir_all(repo.join(".git")).unwrap(); + let orphan = dir.path().join("orphan-wt"); + std::fs::create_dir_all(&orphan).unwrap(); + std::fs::write(orphan.join("keep-me"), b"x").unwrap(); + let identity = crate::safety::capture_plan_entry_identity(&orphan).unwrap(); + let trash_dir = dir.path().join("trash"); + std::fs::create_dir_all(&trash_dir).unwrap(); + std::env::set_var("MOLE_TEST_TRASH_DIR", &trash_dir); + let git = ClaimGit { + repo: repo.clone(), + extra: orphan.clone(), + }; + let plan = ProtoPlan { + schema_version: SCHEMA_VERSION, + created_at: SystemTime::now(), + ttl_secs: 900, + coverage_note: None, + entries: vec![ProtoPlanEntry { + id: "stuffed".into(), + path: orphan.clone(), + label: format!("container cursor blockers=- {}", orphan.display()), + size: 1, + rule_id: "agent:container".into(), + skip_reason: None, + dev: identity.dev, + ino: identity.ino, + mtime: UNIX_EPOCH + Duration::from_secs(identity.mtime.max(0) as u64), + blockers: vec![], + }], + }; + let report = apply_with( + &plan, + &git, + dir.path().to_path_buf(), + dir.path().join("cwd"), + &[dir.path().to_path_buf()], + ) + .unwrap(); + std::env::remove_var("MOLE_TEST_TRASH_DIR"); + assert_eq!(report.succeeded, 0, "claimed checkout must not be deleted"); + assert!(report.skipped >= 1); + assert!(orphan.join("keep-me").exists()); + } +} diff --git a/crates/vole-core/src/ops/agent_plan.rs b/crates/vole-core/src/ops/agent_plan.rs new file mode 100644 index 0000000..e94c8e4 --- /dev/null +++ b/crates/vole-core/src/ops/agent_plan.rs @@ -0,0 +1,696 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use thiserror::Error; + +use super::clean_hints::{DuPathSize, PathSizeKb}; +use super::worktree_plan::{looks_like_git_checkout, GitProbe}; +use crate::protection::AppProtection; +use crate::safety::{capture_plan_entry_identity, validate_path_for_deletion}; +use crate::vole_proto::{Plan as ProtoPlan, PlanEntry as ProtoPlanEntry, SCHEMA_VERSION}; + +pub const DEFAULT_AGENT_TTL_SECS: u64 = 900; +pub const DEFAULT_AGENT_SCAN_BUDGET_SECS: u64 = 15; +pub const DEFAULT_AGENT_PER_ROOT_SECS: u64 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentKind { + Container, + Session, + Cache, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentSource { + Cursor, + Codex, + Claude, + Repo, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentRecord { + pub path: PathBuf, + pub kind: AgentKind, + pub source: AgentSource, + pub size: u64, + pub age_unix: i64, + pub blockers: Vec, +} + +pub fn rule_id_for(kind: AgentKind) -> &'static str { + match kind { + AgentKind::Container => "agent:container", + AgentKind::Session => "agent:session", + AgentKind::Cache => "agent:cache", + } +} + +pub fn source_for_path(home: &Path, path: &Path) -> AgentSource { + let p = path.to_string_lossy(); + if path.starts_with(home.join(".codex")) { + return AgentSource::Codex; + } + if path.starts_with(home.join(".claude")) || p.contains("/.claude/") { + return AgentSource::Claude; + } + if path.starts_with(home.join(".cursor")) || p.contains("/.cursor/") { + return AgentSource::Cursor; + } + AgentSource::Repo +} + +pub fn named_relatives(kind: AgentKind) -> &'static [&'static str] { + match kind { + AgentKind::Container => &["projects/*"], + AgentKind::Session => &[ + "projects/*/agent-transcripts", + "chats", + "sessions", + "archived_sessions", + "history.jsonl", + "todos", + "file-history", + ], + AgentKind::Cache => &[ + "Cache", + "CachedData", + "CachedExtensionVSIXs", + "CachedProfilesData", + "GPUCache", + "Code Cache", + "DawnGraphiteCache", + "DawnWebGPUCache", + "blob_storage", + "logs", + "Crashpad", + "sentry", + ".tmp", + "tmp", + "log", + "statsig", + "debug", + "shell-snapshots", + "ide", + "telemetry", + ], + } +} + +pub fn expand_allowlist(root: &Path) -> Vec<(AgentKind, PathBuf)> { + let mut out = Vec::new(); + if !root.is_dir() { + return out; + } + for child in list_dir_names(root.join("projects")) { + push_if_ok(&mut out, AgentKind::Container, child.clone()); + push_if_ok( + &mut out, + AgentKind::Session, + child.join("agent-transcripts"), + ); + } + for rel in [ + "chats", + "sessions", + "archived_sessions", + "todos", + "file-history", + ] { + push_if_ok(&mut out, AgentKind::Session, root.join(rel)); + } + push_if_ok(&mut out, AgentKind::Session, root.join("history.jsonl")); + for rel in [ + "Cache", + "CachedData", + "CachedExtensionVSIXs", + "CachedProfilesData", + "GPUCache", + "Code Cache", + "DawnGraphiteCache", + "DawnWebGPUCache", + "blob_storage", + "logs", + "Crashpad", + "sentry", + ".tmp", + "tmp", + "log", + "statsig", + "debug", + "shell-snapshots", + "ide", + "telemetry", + ] { + push_if_ok(&mut out, AgentKind::Cache, root.join(rel)); + } + out +} + +fn push_if_ok(out: &mut Vec<(AgentKind, PathBuf)>, kind: AgentKind, path: PathBuf) { + if !path.exists() { + return; + } + if is_never_candidate(&path) || looks_like_git_checkout(&path) { + return; + } + if path.components().any(|c| { + let n = c.as_os_str(); + n == "worktrees" || n == ".worktrees" + }) { + return; + } + out.push((kind, path)); +} + +fn list_dir_names(dir: PathBuf) -> Vec { + let Ok(rd) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + rd.flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect() +} + +pub fn is_never_candidate(path: &Path) -> bool { + const FILES: &[&str] = &[ + "auth.json", + "config.toml", + "settings.json", + ".credentials.json", + "argv.json", + "mcp.json", + ]; + const DIRS: &[&str] = &[ + "extensions", + "User", + "plugins", + "skills", + "prompts", + "memories", + "rules", + ".git", + "worktrees", + ]; + if path + .file_name() + .and_then(|s| s.to_str()) + .is_some_and(|n| FILES.contains(&n)) + { + return true; + } + path.components() + .any(|c| c.as_os_str().to_str().is_some_and(|n| DIRS.contains(&n))) +} + +pub fn is_cwd_excluded(canon: &Path, cwd: &Path) -> bool { + canon == cwd || cwd.starts_with(canon) +} + +pub fn sort_agent_records(rows: &mut [AgentRecord]) { + rows.sort_by(|a, b| { + b.size + .cmp(&a.size) + .then_with(|| age_key(a.age_unix).cmp(&age_key(b.age_unix))) + .then_with(|| a.path.cmp(&b.path)) + }); +} + +fn age_key(age_unix: i64) -> i64 { + if age_unix == 0 { + i64::MAX + } else { + age_unix + } +} + +fn kind_word(kind: AgentKind) -> &'static str { + match kind { + AgentKind::Container => "container", + AgentKind::Session => "session", + AgentKind::Cache => "cache", + } +} + +fn source_word(source: AgentSource) -> &'static str { + match source { + AgentSource::Cursor => "cursor", + AgentSource::Codex => "codex", + AgentSource::Claude => "claude", + AgentSource::Repo => "repo", + } +} + +pub fn format_agent_label(row: &AgentRecord) -> String { + let blockers = if row.blockers.is_empty() { + "-".to_string() + } else { + row.blockers.join(",") + }; + format!( + "{} {} blockers={blockers} {}", + kind_word(row.kind), + source_word(row.source), + row.path.display() + ) +} + +pub fn agent_id(kind: AgentKind, canon: &Path) -> String { + format!("agent:{}:{}", kind_word(kind), canon.display()) +} + +const COVERAGE_NOTE: &str = + "agent scan skips git checkouts claimed by worktree; positive removal verdicts are out of scope."; + +const DU_TIMEOUT: Duration = Duration::from_millis(800); + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum AgentPlanError { + #[error("HOME not usable: {0}")] + Home(String), +} + +pub struct AgentPlanOptions<'a> { + pub home: &'a Path, + pub cwd: &'a Path, + pub ttl_secs: u64, + pub now: SystemTime, + pub search_roots: Option<&'a [PathBuf]>, + pub budget: Duration, + pub per_root: Duration, + pub git: &'a dyn GitProbe, + pub size_probe: Option>, +} + +fn empty_agent_plan(opts: &AgentPlanOptions<'_>) -> ProtoPlan { + ProtoPlan { + schema_version: SCHEMA_VERSION, + created_at: opts.now, + ttl_secs: opts.ttl_secs, + entries: Vec::new(), + coverage_note: Some(COVERAGE_NOTE.to_string()), + } +} + +pub fn build_agent_plan( + protection: &AppProtection, + opts: &AgentPlanOptions<'_>, +) -> Result { + if !opts.home.is_absolute() { + return Err(AgentPlanError::Home(opts.home.display().to_string())); + } + + let deadline = Instant::now() + opts.budget; + if Instant::now() >= deadline { + return Ok(empty_agent_plan(opts)); + } + + let cwd_canon = opts + .cwd + .canonicalize() + .unwrap_or_else(|_| opts.cwd.to_path_buf()); + + let mut discover_roots = match opts.search_roots { + Some(r) => r.to_vec(), + None => super::purge_plan::resolve_search_roots(opts.home), + }; + if let Ok(top) = opts.git.rev_parse_toplevel(opts.cwd) { + if !discover_roots.iter().any(|r| { + r.canonicalize().unwrap_or_else(|_| r.clone()) + == top.canonicalize().unwrap_or_else(|_| top.clone()) + }) { + discover_roots.push(top); + } + } + + if Instant::now() >= deadline { + return Ok(empty_agent_plan(opts)); + } + let repos = + super::worktree_plan::discover_git_repos_with_deadline(&discover_roots, Some(deadline)); + if Instant::now() >= deadline { + return Ok(empty_agent_plan(opts)); + } + let claimed = super::worktree_plan::claimed_paths_for_repos(opts.home, opts.git, &repos); + + let mut scan_roots = vec![ + opts.home.join(".cursor"), + opts.home.join(".codex"), + opts.home.join(".claude"), + ]; + for repo in &repos { + scan_roots.push(repo.join(".cursor")); + scan_roots.push(repo.join(".claude")); + } + + let mut records: Vec = Vec::new(); + let mut seen = BTreeSet::new(); + + for root in scan_roots { + if Instant::now() >= deadline { + break; + } + let root_deadline = Instant::now() + opts.per_root; + let mut root_rows = Vec::new(); + let mut timed_out = false; + for (kind, path) in expand_allowlist(&root) { + if Instant::now() >= deadline || Instant::now() >= root_deadline { + timed_out = true; + break; + } + let canon = path.canonicalize().unwrap_or_else(|_| path.clone()); + if is_cwd_excluded(&canon, &cwd_canon) { + continue; + } + if claimed.contains(&canon) { + continue; + } + if looks_like_git_checkout(&canon) { + continue; + } + if canon.components().any(|c| { + let n = c.as_os_str(); + n == "worktrees" || n == ".worktrees" + }) { + continue; + } + if !seen.insert(canon.clone()) { + continue; + } + let (size, age_unix, blockers) = + measure_candidate(&canon, opts, deadline, root_deadline); + if Instant::now() >= deadline || Instant::now() >= root_deadline { + timed_out = true; + break; + } + root_rows.push(AgentRecord { + path: canon, + kind, + source: source_for_path(opts.home, &path), + size, + age_unix, + blockers, + }); + } + if timed_out { + continue; + } + records.extend(root_rows); + } + + sort_agent_records(&mut records); + + let mut entries = Vec::new(); + for row in records { + if let Some(entry) = record_to_entry(&row, protection) { + entries.push(entry); + } + } + + Ok(ProtoPlan { + schema_version: SCHEMA_VERSION, + created_at: opts.now, + ttl_secs: opts.ttl_secs, + entries, + coverage_note: Some(COVERAGE_NOTE.to_string()), + }) +} + +fn measure_candidate( + path: &Path, + opts: &AgentPlanOptions<'_>, + deadline: Instant, + root_deadline: Instant, +) -> (u64, i64, Vec) { + let mut blockers = Vec::new(); + let meta = fs::symlink_metadata(path); + let age_unix = match &meta { + Ok(m) => m + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0), + Err(_) => { + blockers.push("status-unknown".into()); + 0 + } + }; + + let size = if Instant::now() >= deadline || Instant::now() >= root_deadline { + push_status_unknown(&mut blockers); + 0 + } else { + let default_probe = DuPathSize; + let probe = opts.size_probe.as_deref().unwrap_or(&default_probe); + let timeout = opts.per_root.min(DU_TIMEOUT); + match probe.size_kb(path, timeout) { + Some(kb) => kb.saturating_mul(1024), + None => { + push_status_unknown(&mut blockers); + 0 + } + } + }; + + (size, age_unix, blockers) +} + +fn push_status_unknown(blockers: &mut Vec) { + if !blockers.iter().any(|b| b == "status-unknown") { + blockers.push("status-unknown".into()); + } +} + +fn record_to_entry(row: &AgentRecord, protection: &AppProtection) -> Option { + let path_str = row.path.display().to_string(); + validate_path_for_deletion(&path_str, protection).ok()?; + let identity = capture_plan_entry_identity(&row.path).ok()?; + Some(ProtoPlanEntry { + id: agent_id(row.kind, &row.path), + path: row.path.clone(), + label: format_agent_label(row), + size: row.size, + rule_id: rule_id_for(row.kind).to_string(), + skip_reason: None, + dev: identity.dev, + ino: identity.ino, + mtime: UNIX_EPOCH + Duration::from_secs(identity.mtime.max(0) as u64), + blockers: row.blockers.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::{Path, PathBuf}; + + #[test] + fn rule_ids_are_stable() { + assert_eq!(rule_id_for(AgentKind::Container), "agent:container"); + assert_eq!(rule_id_for(AgentKind::Session), "agent:session"); + assert_eq!(rule_id_for(AgentKind::Cache), "agent:cache"); + } + + #[test] + fn expand_allowlist_lists_named_paths_only() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("projects/alpha")).unwrap(); + std::fs::create_dir_all(root.path().join("projects/alpha/agent-transcripts")).unwrap(); + std::fs::create_dir_all(root.path().join("Cache")).unwrap(); + std::fs::create_dir_all(root.path().join("worktrees/checkout")).unwrap(); + std::fs::create_dir_all(root.path().join("worktrees/checkout/.git")).unwrap(); + std::fs::create_dir_all(root.path().join("extensions/foo")).unwrap(); + std::fs::write(root.path().join("auth.json"), b"{}").unwrap(); + std::fs::create_dir_all(root.path().join("mystery")).unwrap(); + let got = expand_allowlist(root.path()); + let paths: Vec<_> = got + .iter() + .map(|(_, p)| p.strip_prefix(root.path()).unwrap().to_path_buf()) + .collect(); + assert!(paths.contains(&PathBuf::from("projects/alpha"))); + assert!(paths.contains(&PathBuf::from("projects/alpha/agent-transcripts"))); + assert!(paths.contains(&PathBuf::from("Cache"))); + assert!(!paths.iter().any(|p| p.starts_with("worktrees"))); + assert!(!paths.iter().any(|p| p.starts_with("extensions"))); + assert!(!paths.iter().any(|p| p.ends_with("auth.json"))); + assert!(!paths.iter().any(|p| p == Path::new("mystery"))); + } + + #[test] + fn cwd_inside_candidate_is_excluded() { + let cand = PathBuf::from("/Users/me/.cursor/projects/app"); + let cwd = PathBuf::from("/Users/me/.cursor/projects/app/src"); + assert!(is_cwd_excluded(&cand, &cwd)); + assert!(is_cwd_excluded(&cand, &cand)); + assert!(!is_cwd_excluded( + &PathBuf::from("/Users/me/.cursor/Cache"), + &cwd + )); + } + + #[test] + fn sort_prefers_large_then_old() { + let mut rows = vec![ + AgentRecord { + path: PathBuf::from("/b"), + kind: AgentKind::Cache, + source: AgentSource::Cursor, + size: 10, + age_unix: 100, + blockers: vec!["status-unknown".into()], + }, + AgentRecord { + path: PathBuf::from("/a"), + kind: AgentKind::Cache, + source: AgentSource::Cursor, + size: 50, + age_unix: 200, + blockers: vec![], + }, + AgentRecord { + path: PathBuf::from("/c"), + kind: AgentKind::Cache, + source: AgentSource::Cursor, + size: 50, + age_unix: 50, + blockers: vec![], + }, + ]; + sort_agent_records(&mut rows); + assert_eq!(rows[0].path, PathBuf::from("/c")); + assert_eq!(rows[1].path, PathBuf::from("/a")); + assert_eq!(rows[2].path, PathBuf::from("/b")); + } + + #[test] + fn label_and_id_have_no_safe_words() { + let row = AgentRecord { + path: PathBuf::from("/Users/me/.cursor/Cache"), + kind: AgentKind::Cache, + source: AgentSource::Cursor, + size: 1, + age_unix: 1, + blockers: vec!["status-unknown".into()], + }; + let label = format_agent_label(&row); + assert!(label.starts_with("cache cursor blockers=status-unknown ")); + assert!(!label.contains("safe")); + assert!(!label.contains("deletable")); + assert_eq!( + agent_id(AgentKind::Cache, Path::new("/Users/me/.cursor/Cache")), + "agent:cache:/Users/me/.cursor/Cache" + ); + } + + #[test] + fn build_plan_lists_cache_and_skips_checkout_and_cwd() { + use crate::ops::worktree_plan::LiveGitProbe; + use crate::protection::AppProtection; + use std::sync::Arc; + use std::time::{Duration, SystemTime}; + + let home = tempfile::tempdir().unwrap(); + let cursor = home.path().join(".cursor"); + std::fs::create_dir_all(cursor.join("Cache")).unwrap(); + std::fs::create_dir_all(cursor.join("worktrees/feat/.git")).unwrap(); + std::fs::create_dir_all(cursor.join("projects/demo")).unwrap(); + let cwd = cursor.join("projects/demo"); + std::fs::write(cursor.join("auth.json"), b"{}").unwrap(); + + let git = LiveGitProbe; + let opts = AgentPlanOptions { + home: home.path(), + cwd: &cwd, + ttl_secs: DEFAULT_AGENT_TTL_SECS, + now: SystemTime::now(), + search_roots: Some(&[]), + budget: Duration::from_secs(15), + per_root: Duration::from_secs(2), + git: &git, + size_probe: Some(Arc::new(crate::ops::clean_hints::DuPathSize)), + }; + let plan = build_agent_plan(&AppProtection::new(), &opts).unwrap(); + assert_eq!(plan.schema_version, 1); + assert_eq!(plan.ttl_secs, 900); + let json = serde_json::to_string(&plan).unwrap(); + assert!(!json.contains("safe")); + assert!(!json.contains("deletable")); + assert!(plan + .entries + .iter() + .any(|e| e.rule_id == "agent:cache" && e.path.ends_with("Cache"))); + assert!(!plan.entries.iter().any(|e| e.path.ends_with("auth.json"))); + assert!(!plan + .entries + .iter() + .any(|e| e.path.to_string_lossy().contains("worktrees"))); + assert!(!plan.entries.iter().any(|e| e.path == cwd)); + assert!(plan.coverage_note.as_deref().unwrap().contains("worktree")); + } + + #[test] + fn zero_budget_returns_empty_plan_not_partial_root() { + use crate::ops::worktree_plan::LiveGitProbe; + use crate::protection::AppProtection; + use std::time::{Duration, SystemTime}; + + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(home.path().join(".cursor/Cache")).unwrap(); + let cwd = home.path().join("cwd"); + std::fs::create_dir_all(&cwd).unwrap(); + let opts = AgentPlanOptions { + home: home.path(), + cwd: &cwd, + ttl_secs: 900, + now: SystemTime::now(), + search_roots: Some(&[]), + budget: Duration::ZERO, + per_root: Duration::from_secs(2), + git: &LiveGitProbe, + size_probe: None, + }; + let plan = build_agent_plan(&AppProtection::new(), &opts).unwrap(); + assert!(plan.entries.is_empty()); + } + + #[test] + fn none_size_probe_still_measures_with_timed_du() { + use crate::ops::worktree_plan::LiveGitProbe; + use crate::protection::AppProtection; + use std::time::{Duration, SystemTime}; + + let home = tempfile::tempdir().unwrap(); + let cache = home.path().join(".cursor/Cache"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(cache.join("blob"), vec![0u8; 8192]).unwrap(); + let cwd = home.path().join("cwd"); + std::fs::create_dir_all(&cwd).unwrap(); + let opts = AgentPlanOptions { + home: home.path(), + cwd: &cwd, + ttl_secs: 900, + now: SystemTime::now(), + search_roots: Some(&[]), + budget: Duration::from_secs(15), + per_root: Duration::from_secs(2), + git: &LiveGitProbe, + size_probe: None, + }; + let plan = build_agent_plan(&AppProtection::new(), &opts).unwrap(); + let entry = plan + .entries + .iter() + .find(|e| e.rule_id == "agent:cache" && e.path.ends_with("Cache")) + .expect("cache entry"); + assert!( + entry.size > 0, + "None size_probe must still run timed du, got {}", + entry.size + ); + } +} diff --git a/crates/vole-core/src/ops/coverage.rs b/crates/vole-core/src/ops/coverage.rs index 3f436b9..7b64574 100644 --- a/crates/vole-core/src/ops/coverage.rs +++ b/crates/vole-core/src/ops/coverage.rs @@ -91,6 +91,7 @@ pub fn coverage_note(enabled_rules: usize) -> String { user.sh 广域 `~/Library/Caches/*` / `~/Library/Logs/*`(plan 目录递归 du + 父子重叠扣减;保护跳过子集仍 keep)。\ 桌面 SMAppService / 特权助手见 vole-macos(真机通道已验收)。\ clean hints 长尾(LaunchAgents / orphan dotdirs)已落地、\ + vole agent(Cursor/Codex/Claude 容器/会话/缓存残留,确认后废纸篓)已落地、\ 如需完整清理(含 Developer 大户整树等长尾),请关注后续版本。" ) } @@ -223,6 +224,14 @@ mod tests { } } + #[test] + fn coverage_note_mentions_vole_agent() { + let note = coverage_note(540); + assert!(note + .contains("vole agent(Cursor/Codex/Claude 容器/会话/缓存残留,确认后废纸篓)已落地")); + assert!(!note.to_ascii_lowercase().contains("mole")); + } + #[test] fn coverage_note_mentions_mole_and_count() { let note = coverage_note(150); diff --git a/crates/vole-core/src/ops/mod.rs b/crates/vole-core/src/ops/mod.rs index 8f678a5..0b685f2 100644 --- a/crates/vole-core/src/ops/mod.rs +++ b/crates/vole-core/src/ops/mod.rs @@ -1,5 +1,7 @@ //! 编排骨架:进度事件经 channel 发出,供 CLI/TUI/sidecar 消费。 +mod agent_apply; +mod agent_plan; mod apply_plan; mod clean_hints; mod coverage; @@ -29,6 +31,15 @@ use crate::cancel::{CancelToken, Cancelled}; use crate::orphan::{orphan_deps_for_runtime, OrphanDeps}; use crate::rules::{PgrepProcessProbe, ProcessProbe, StrategyBuildError}; +pub use agent_apply::{ + apply_agent_plan, apply_agent_proto_plan, AgentApplyContext, AgentApplyError, AgentApplyOptions, +}; +pub use agent_plan::{ + agent_id, build_agent_plan, expand_allowlist, format_agent_label, is_cwd_excluded, + is_never_candidate, named_relatives, rule_id_for, sort_agent_records, AgentKind, + AgentPlanError, AgentPlanOptions, AgentRecord, AgentSource, DEFAULT_AGENT_PER_ROOT_SECS, + DEFAULT_AGENT_SCAN_BUDGET_SECS, DEFAULT_AGENT_TTL_SECS, +}; pub use apply_plan::{ apply_plan, apply_proto_plan, ApplyPlanContext, ApplyPlanError, ApplyPlanOptions, }; @@ -95,7 +106,8 @@ pub use worktree_apply::{ WorktreeApplyOptions, }; pub use worktree_plan::{ - build_worktree_plan, format_worktree_label, parse_repo_from_label, parse_worktree_porcelain, + build_worktree_plan, collect_worktree_claimed_paths, discover_git_repos, format_worktree_label, + looks_like_git_checkout, parse_repo_from_label, parse_worktree_porcelain, sort_worktree_records, source_for_path, GitProbe, LiveGitProbe, WorktreeHead, WorktreeKind, WorktreePlanError, WorktreePlanOptions, WorktreeRecord, WorktreeSource, DEFAULT_WORKTREE_TTL_SECS, diff --git a/crates/vole-core/src/ops/worktree_plan.rs b/crates/vole-core/src/ops/worktree_plan.rs index 4b054cb..fc9d41e 100644 --- a/crates/vole-core/src/ops/worktree_plan.rs +++ b/crates/vole-core/src/ops/worktree_plan.rs @@ -417,14 +417,24 @@ fn same_path(a: &Path, b: &Path) -> bool { a == b } -fn looks_like_git_checkout(path: &Path) -> bool { +pub fn looks_like_git_checkout(path: &Path) -> bool { let git = path.join(".git"); git.is_dir() || git.is_file() } -fn discover_git_repos(roots: &[PathBuf]) -> Vec { +pub fn discover_git_repos(roots: &[PathBuf]) -> Vec { + discover_git_repos_with_deadline(roots, None) +} + +pub(crate) fn discover_git_repos_with_deadline( + roots: &[PathBuf], + deadline: Option, +) -> Vec { let mut repos = BTreeSet::new(); for root in roots { + if deadline.is_some_and(|d| Instant::now() >= d) { + break; + } if !root.is_dir() { continue; } @@ -443,6 +453,9 @@ fn discover_git_repos(roots: &[PathBuf]) -> Vec { }); }) { + if deadline.is_some_and(|d| Instant::now() >= d) { + break; + } let Ok(ent) = ent else { continue; }; @@ -466,7 +479,7 @@ fn has_purge_component(path: &Path) -> bool { }) } -fn agent_checkout_dirs(home: &Path, repos: &[PathBuf]) -> Vec { +pub(crate) fn agent_checkout_dirs(home: &Path, repos: &[PathBuf]) -> Vec { let mut dirs = Vec::new(); push_checkout_children(&home.join(".codex/worktrees"), &mut dirs); push_checkout_children(&home.join(".claude/worktrees"), &mut dirs); @@ -477,6 +490,52 @@ fn agent_checkout_dirs(home: &Path, repos: &[PathBuf]) -> Vec { dirs } +pub fn collect_worktree_claimed_paths( + home: &Path, + cwd: &Path, + git: &dyn GitProbe, + search_roots: Option<&[PathBuf]>, +) -> BTreeSet { + let mut roots: Vec = match search_roots { + Some(r) => r.to_vec(), + None => super::purge_plan::resolve_search_roots(home), + }; + let mut claimed = BTreeSet::new(); + if let Ok(top) = git.rev_parse_toplevel(cwd) { + if !roots.iter().any(|r| same_path(r, &top)) { + roots.push(top.clone()); + } + } + let repos = discover_git_repos(&roots); + claimed.extend(claimed_paths_for_repos(home, git, &repos)); + claimed +} + +pub(crate) fn claimed_paths_for_repos( + home: &Path, + git: &dyn GitProbe, + repos: &[PathBuf], +) -> BTreeSet { + let mut claimed = BTreeSet::new(); + for repo in repos { + let Ok(text) = git.worktree_list(repo) else { + continue; + }; + for (idx, wt) in parse_worktree_porcelain(&text).into_iter().enumerate() { + if idx == 0 { + continue; + } + if wt.path.exists() { + claimed.insert(wt.path.canonicalize().unwrap_or(wt.path)); + } + } + } + for child in agent_checkout_dirs(home, repos) { + claimed.insert(child.canonicalize().unwrap_or(child)); + } + claimed +} + fn push_checkout_children(container: &Path, dirs: &mut Vec) { let Ok(rd) = fs::read_dir(container) else { return; diff --git a/scripts/check-command-surface.sh b/scripts/check-command-surface.sh index 2b463a9..b9d3fe1 100755 --- a/scripts/check-command-surface.sh +++ b/scripts/check-command-surface.sh @@ -50,6 +50,7 @@ vole_cmds=$( if grep -Eq '^\s+Update\b' "$VOLE_MAIN"; then echo update; fi if grep -Eq '^\s+Remove\b' "$VOLE_MAIN"; then echo remove; fi if grep -Eq '^\s+Worktree\b' "$VOLE_MAIN"; then echo worktree; fi + if grep -Eq '^\s+Agent\b' "$VOLE_MAIN"; then echo agent; fi } | sort -u ) @@ -74,6 +75,12 @@ else echo "NOTE: vole-native worktree missing (not a Mole required gap)" fi +if printf '%s\n' "$vole_cmds" | grep -qx agent; then + echo "OK: vole-native agent" +else + echo "NOTE: vole-native agent missing (not a Mole required gap)" +fi + if grep -Eq '^\s+Hints\b' "$VOLE_MAIN"; then echo "UNEXPECTED: top-level Hints command" gaps=$((gaps + 1))