use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tokio::net::UnixStream;
use super::protocol::AgentMetadata;
pub fn get_agent_metadata_path(sessions_dir: &Path, session_id: &str) -> PathBuf {
sessions_dir.join(format!("{}.meta.json", session_id))
}
pub fn write_agent_metadata(sessions_dir: &Path, metadata: &AgentMetadata) -> Result<()> {
let meta_path = get_agent_metadata_path(sessions_dir, &metadata.session_id);
if let Some(parent) = meta_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(metadata)
.context("Failed to serialize agent metadata")?;
std::fs::write(&meta_path, json)
.with_context(|| format!("Failed to write metadata to {}", meta_path.display()))?;
tracing::debug!("Wrote agent metadata: {}", meta_path.display());
Ok(())
}
pub fn read_agent_metadata(sessions_dir: &Path, session_id: &str) -> Result<Option<AgentMetadata>> {
let meta_path = get_agent_metadata_path(sessions_dir, session_id);
if !meta_path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&meta_path)
.with_context(|| format!("Failed to read metadata from {}", meta_path.display()))?;
let metadata: AgentMetadata = serde_json::from_str(&json)
.with_context(|| format!("Failed to parse metadata from {}", meta_path.display()))?;
Ok(Some(metadata))
}
pub fn update_agent_metadata<F>(sessions_dir: &Path, session_id: &str, updater: F) -> Result<()>
where
F: FnOnce(&mut AgentMetadata),
{
let mut metadata = read_agent_metadata(sessions_dir, session_id)?
.ok_or_else(|| anyhow::anyhow!("No metadata found for session {}", session_id))?;
updater(&mut metadata);
write_agent_metadata(sessions_dir, &metadata)?;
Ok(())
}
pub fn delete_agent_metadata(sessions_dir: &Path, session_id: &str) -> Result<()> {
let meta_path = get_agent_metadata_path(sessions_dir, session_id);
if meta_path.exists() {
std::fs::remove_file(&meta_path)
.with_context(|| format!("Failed to delete metadata: {}", meta_path.display()))?;
tracing::debug!("Deleted agent metadata: {}", meta_path.display());
}
Ok(())
}
pub fn list_agent_sessions(sessions_dir: &Path) -> Result<Vec<String>> {
if !sessions_dir.exists() {
return Ok(Vec::new());
}
let mut sessions = Vec::new();
for entry in std::fs::read_dir(sessions_dir)? {
let entry = entry?;
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.ends_with(".pty.sock") {
continue;
}
if let Some(session_id) = filename.strip_suffix(".sock") {
sessions.push(session_id.to_string());
}
}
}
Ok(sessions)
}
pub async fn is_agent_alive(sessions_dir: &Path, session_id: &str) -> bool {
let socket_path = super::socket::get_agent_socket_path(sessions_dir, session_id);
if !socket_path.exists() {
return false;
}
matches!(tokio::time::timeout(
std::time::Duration::from_secs(2),
UnixStream::connect(&socket_path),
)
.await, Ok(Ok(_)))
}
pub async fn cleanup_stale_sockets(sessions_dir: &Path) -> Result<()> {
let sessions = list_agent_sessions(sessions_dir)?;
for session_id in sessions {
if !is_agent_alive(sessions_dir, &session_id).await {
cleanup_session(sessions_dir, &session_id)?;
}
}
Ok(())
}
pub fn cleanup_session(sessions_dir: &Path, session_id: &str) -> Result<()> {
let extensions = [
"sock",
"pty.sock",
"token",
"meta.json",
"log",
"stdout.log",
"stderr.log",
];
for ext in extensions {
let file_path = sessions_dir.join(format!("{}.{}", session_id, ext));
if file_path.exists() {
if let Err(e) = std::fs::remove_file(&file_path) {
tracing::warn!("Failed to remove {}: {}", file_path.display(), e);
} else {
tracing::debug!("Cleaned up: {}", file_path.display());
}
}
}
Ok(())
}
pub fn list_agent_sessions_with_metadata(sessions_dir: &Path) -> Result<Vec<AgentMetadata>> {
if !sessions_dir.exists() {
return Ok(Vec::new());
}
let mut agents = Vec::new();
for entry in std::fs::read_dir(sessions_dir)? {
let entry = entry?;
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.ends_with(".pty.sock") {
continue;
}
if let Some(session_id) = filename.strip_suffix(".sock") {
match read_agent_metadata(sessions_dir, session_id) {
Ok(Some(metadata)) => {
agents.push(metadata);
}
Ok(None) => {
let metadata = AgentMetadata::new(
session_id.to_string(),
"unknown".to_string(),
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
);
agents.push(metadata);
}
Err(e) => {
tracing::warn!(
"Failed to read metadata for session {}: {}",
session_id, e
);
}
}
}
}
}
agents.sort_by_key(|a| a.created_at);
Ok(agents)
}
pub fn get_child_agents(sessions_dir: &Path, parent_session_id: &str) -> Result<Vec<AgentMetadata>> {
let all_agents = list_agent_sessions_with_metadata(sessions_dir)?;
Ok(all_agents
.into_iter()
.filter(|a| a.parent_agent_id.as_deref() == Some(parent_session_id))
.collect())
}
pub fn get_root_agents(sessions_dir: &Path) -> Result<Vec<AgentMetadata>> {
let all_agents = list_agent_sessions_with_metadata(sessions_dir)?;
Ok(all_agents
.into_iter()
.filter(|a| a.parent_agent_id.is_none())
.collect())
}
pub fn get_agent_depth(sessions_dir: &Path, session_id: &str) -> Result<u32> {
let mut depth = 0;
let mut current_id = session_id.to_string();
loop {
match read_agent_metadata(sessions_dir, ¤t_id) {
Ok(Some(metadata)) => {
match metadata.parent_agent_id {
Some(parent_id) => {
depth += 1;
current_id = parent_id;
}
None => break,
}
}
Ok(None) => break, Err(_) => break, }
}
Ok(depth)
}
pub fn format_agent_tree(sessions_dir: &Path, current_session_id: Option<&str>) -> Result<String> {
let all_agents = list_agent_sessions_with_metadata(sessions_dir)?;
if all_agents.is_empty() {
return Ok("No active agents".to_string());
}
let mut output = String::new();
fn render_subtree(
agents: &[AgentMetadata],
parent_id: Option<&str>,
prefix: &str,
is_last: bool,
current_session_id: Option<&str>,
output: &mut String,
) {
let children: Vec<_> = agents
.iter()
.filter(|a| a.parent_agent_id.as_deref() == parent_id)
.collect();
for (i, agent) in children.iter().enumerate() {
let is_last_child = i == children.len() - 1;
let connector = if is_last { "└── " } else { "├── " };
let child_prefix = if is_last { " " } else { "│ " };
let marker = if current_session_id == Some(agent.session_id.as_str()) {
" ← current"
} else {
""
};
let status = if agent.is_busy { "busy" } else { "idle" };
let reason = agent.spawn_reason.as_deref().unwrap_or("");
let reason_str = if reason.is_empty() {
String::new()
} else {
format!(" ({})", reason)
};
output.push_str(&format!(
"{}{}{} [{}] {}{}{}\n",
prefix,
connector,
agent.session_id,
agent.model,
status,
reason_str,
marker,
));
render_subtree(
agents,
Some(&agent.session_id),
&format!("{}{}", prefix, child_prefix),
is_last_child,
current_session_id,
output,
);
}
}
output.push_str("Agents:\n");
render_subtree(&all_agents, None, "", true, current_session_id, &mut output);
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_metadata_serialization() {
let metadata = AgentMetadata::new(
"test-session-123".to_string(),
"gpt-4".to_string(),
"/home/user/project".to_string(),
);
let json = serde_json::to_string(&metadata).unwrap();
let parsed: AgentMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.session_id, "test-session-123");
assert_eq!(parsed.model, "gpt-4");
assert_eq!(parsed.working_directory, "/home/user/project");
assert!(parsed.parent_agent_id.is_none());
assert!(!parsed.is_busy);
}
#[test]
fn test_agent_metadata_with_parent() {
let metadata = AgentMetadata::new(
"child-session".to_string(),
"gpt-3.5".to_string(),
"/home/user/project".to_string(),
).with_parent("parent-session".to_string(), Some("investigate bug".to_string()));
assert_eq!(metadata.parent_agent_id, Some("parent-session".to_string()));
assert_eq!(metadata.spawn_reason, Some("investigate bug".to_string()));
}
#[test]
fn test_metadata_file_io() {
let temp_dir = tempfile::tempdir().unwrap();
let sessions_dir = temp_dir.path();
let metadata = AgentMetadata::new(
"test-io-session".to_string(),
"claude-2".to_string(),
"/home/user/project".to_string(),
);
write_agent_metadata(sessions_dir, &metadata).unwrap();
let read_back = read_agent_metadata(sessions_dir, "test-io-session").unwrap();
assert!(read_back.is_some());
let read_back = read_back.unwrap();
assert_eq!(read_back.session_id, "test-io-session");
assert_eq!(read_back.model, "claude-2");
update_agent_metadata(sessions_dir, "test-io-session", |m| {
m.set_busy(true);
}).unwrap();
let updated = read_agent_metadata(sessions_dir, "test-io-session").unwrap().unwrap();
assert!(updated.is_busy);
delete_agent_metadata(sessions_dir, "test-io-session").unwrap();
let deleted = read_agent_metadata(sessions_dir, "test-io-session").unwrap();
assert!(deleted.is_none());
}
#[test]
fn test_agent_tree_relationships() {
let agents = vec![
AgentMetadata::new(
"parent-1".to_string(),
"gpt-4".to_string(),
"/home".to_string(),
),
AgentMetadata::new(
"child-1".to_string(),
"gpt-3.5".to_string(),
"/home".to_string(),
).with_parent("parent-1".to_string(), Some("investigate".to_string())),
AgentMetadata::new(
"child-2".to_string(),
"claude".to_string(),
"/home".to_string(),
).with_parent("parent-1".to_string(), Some("code review".to_string())),
];
let children: Vec<_> = agents.iter()
.filter(|a| a.parent_agent_id.as_deref() == Some("parent-1"))
.collect();
assert_eq!(children.len(), 2);
assert!(children.iter().any(|a| a.session_id == "child-1"));
assert!(children.iter().any(|a| a.session_id == "child-2"));
}
}