use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEntry {
pub session_id: String,
pub pid: u32,
pub agent: String,
pub started_at_secs: u64,
pub policy: String,
pub tier: String,
pub cwd: String,
}
pub struct SessionRegistry {
run_dir: PathBuf,
}
impl SessionRegistry {
pub fn default_run_dir() -> Result<PathBuf> {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.context("failed to resolve HOME directory for session registry")?;
Ok(home.join(".vetto").join("run"))
}
pub fn new() -> Result<Self> {
let run_dir = Self::default_run_dir()?;
fs::create_dir_all(&run_dir)?;
Ok(Self { run_dir })
}
pub fn with_dir(run_dir: PathBuf) -> Self {
let _ = fs::create_dir_all(&run_dir);
Self { run_dir }
}
pub fn register(
&self,
session_id: &str,
pid: u32,
agent: &str,
policy: &str,
tier: &str,
cwd: &Path,
) -> Result<()> {
let entry_dir = self.run_dir.join(session_id);
fs::create_dir_all(&entry_dir)?;
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let entry = SessionEntry {
session_id: session_id.to_string(),
pid,
agent: agent.to_string(),
started_at_secs: now_secs,
policy: policy.to_string(),
tier: tier.to_string(),
cwd: cwd.display().to_string(),
};
let json = serde_json::to_string_pretty(&entry)?;
fs::write(entry_dir.join("info.json"), json)?;
fs::write(entry_dir.join("pid"), pid.to_string())?;
fs::write(entry_dir.join("policy"), policy)?;
fs::write(entry_dir.join("started"), now_secs.to_string())?;
Ok(())
}
pub fn unregister(&self, session_id: &str) {
let entry_dir = self.run_dir.join(session_id);
let _ = fs::remove_dir_all(&entry_dir);
}
pub fn list_active(&self) -> Result<Vec<SessionEntry>> {
let mut active = Vec::new();
if !self.run_dir.exists() {
return Ok(active);
}
let entries = match fs::read_dir(&self.run_dir) {
Ok(entries) => entries,
Err(_) => return Ok(active),
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let info_file = path.join("info.json");
if let Ok(content) = fs::read_to_string(&info_file) {
if let Ok(session) = serde_json::from_str::<SessionEntry>(&content) {
if is_pid_alive(session.pid) {
active.push(session);
} else {
let _ = fs::remove_dir_all(&path);
}
} else {
let _ = fs::remove_dir_all(&path);
}
} else {
let _ = fs::remove_dir_all(&path);
}
}
active.sort_by_key(|s| s.started_at_secs);
Ok(active)
}
}
pub fn is_pid_alive(pid: u32) -> bool {
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
if ret == 0 {
true
} else {
let err = std::io::Error::last_os_error();
err.raw_os_error() == Some(libc::EPERM)
}
}
#[cfg(windows)]
{
crate::rescue::lock::is_process_alive(pid)
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
true
}
}
pub fn run_cli(json: bool) -> Result<()> {
let registry = SessionRegistry::new()?;
let sessions = registry.list_active()?;
if json {
println!("{}", serde_json::to_string_pretty(&sessions)?);
return Ok(());
}
if sessions.is_empty() {
println!("No active vetto sessions.");
return Ok(());
}
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
println!(
"{:<10} {:<15} {:<12} {:<15} {:<10} {:<8}",
"PID", "AGENT", "POLICY", "TIER", "UPTIME", "SESSION"
);
println!("{}", "-".repeat(75));
for s in &sessions {
let uptime_secs = now_secs.saturating_sub(s.started_at_secs);
let uptime_str = format_uptime(uptime_secs);
let short_sid = short_session_id(&s.session_id);
println!(
"{:<10} {:<15} {:<12} {:<15} {:<10} {:<8}",
s.pid, s.agent, s.policy, s.tier, uptime_str, short_sid
);
}
let wrapped_agents =
crate::cli::enable::get_wrapped_agents(crate::cli::hook::HookScope::Global)
.unwrap_or_default();
if !wrapped_agents.is_empty() {
println!();
println!("Enabled Agents (vetto enable):");
for w in &wrapped_agents {
let real_str = w
.real_binary
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "unknown".to_string());
println!(" {:<10} -> {} (preset: {})", w.name, real_str, w.preset);
}
}
Ok(())
}
fn format_uptime(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
fn short_session_id(session_id: &str) -> &str {
if session_id.len() <= 8 {
return session_id;
}
let mut end = 8;
while end > 0 && !session_id.is_char_boundary(end) {
end -= 1;
}
&session_id[..end]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_session_id_never_splits_utf8() {
assert_eq!(short_session_id("abc"), "abc");
assert_eq!(short_session_id("12345678"), "12345678");
assert_eq!(short_session_id("123456789"), "12345678");
let unicode = "ééééé"; let short = short_session_id(unicode);
assert!(short.len() <= 8);
assert!(unicode.is_char_boundary(short.len()));
let emoji = "😀😀😀";
let short_emoji = short_session_id(emoji);
assert!(emoji.is_char_boundary(short_emoji.len()));
}
#[test]
fn registry_lifecycle_and_pruning() {
let temp = std::env::temp_dir().join(format!("vetto-test-reg-{}", std::process::id()));
let _ = fs::remove_dir_all(&temp);
let registry = SessionRegistry::with_dir(temp.clone());
let my_pid = std::process::id();
registry
.register(
"test-sess-1",
my_pid,
"codex",
"default",
"full",
Path::new("/tmp"),
)
.unwrap();
registry
.register(
"test-sess-dead",
999_999_999,
"claude",
"strict",
"fs-only",
Path::new("/tmp"),
)
.unwrap();
let active = registry.list_active().unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].session_id, "test-sess-1");
registry.unregister("test-sess-1");
let active = registry.list_active().unwrap();
assert_eq!(active.len(), 0);
let _ = fs::remove_dir_all(&temp);
}
}