scud/commands/spawn/
monitor.rs

1//! Monitor data structures for TUI integration
2//!
3//! Provides data structures and functions for tracking spawn session state,
4//! to be consumed by a TUI control window.
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::PathBuf;
10
11/// State of a spawned agent
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "kebab-case")]
14pub enum AgentStatus {
15    Starting,
16    Running,
17    Completed,
18    Failed,
19}
20
21/// Information about a spawned agent
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct AgentState {
24    pub task_id: String,
25    pub task_title: String,
26    pub window_name: String,
27    pub status: AgentStatus,
28    pub started_at: String,
29    pub tag: String,
30}
31
32/// Spawn session state for TUI
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SpawnSession {
35    pub session_name: String,
36    pub tag: String,
37    pub terminal: String,
38    pub created_at: String,
39    pub working_dir: String,
40    pub agents: Vec<AgentState>,
41}
42
43impl SpawnSession {
44    /// Create a new spawn session
45    pub fn new(session_name: &str, tag: &str, terminal: &str, working_dir: &str) -> Self {
46        Self {
47            session_name: session_name.to_string(),
48            tag: tag.to_string(),
49            terminal: terminal.to_string(),
50            created_at: chrono::Utc::now().to_rfc3339(),
51            working_dir: working_dir.to_string(),
52            agents: Vec::new(),
53        }
54    }
55
56    /// Add an agent to the session
57    pub fn add_agent(&mut self, task_id: &str, task_title: &str, tag: &str) {
58        let window_name = format!("task-{}", task_id);
59        self.agents.push(AgentState {
60            task_id: task_id.to_string(),
61            task_title: task_title.to_string(),
62            window_name,
63            status: AgentStatus::Starting,
64            started_at: chrono::Utc::now().to_rfc3339(),
65            tag: tag.to_string(),
66        });
67    }
68
69    /// Update agent status
70    pub fn update_agent_status(&mut self, task_id: &str, status: AgentStatus) {
71        if let Some(agent) = self.agents.iter_mut().find(|a| a.task_id == task_id) {
72            agent.status = status;
73        }
74    }
75
76    /// Count agents by status
77    pub fn count_by_status(&self, status: AgentStatus) -> usize {
78        self.agents.iter().filter(|a| a.status == status).count()
79    }
80}
81
82/// Statistics for a spawn session
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SpawnStats {
85    pub session_name: String,
86    pub tag: String,
87    pub total_agents: usize,
88    pub starting: usize,
89    pub running: usize,
90    pub completed: usize,
91    pub failed: usize,
92    pub created_at: String,
93}
94
95impl From<&SpawnSession> for SpawnStats {
96    fn from(session: &SpawnSession) -> Self {
97        Self {
98            session_name: session.session_name.clone(),
99            tag: session.tag.clone(),
100            total_agents: session.agents.len(),
101            starting: session.count_by_status(AgentStatus::Starting),
102            running: session.count_by_status(AgentStatus::Running),
103            completed: session.count_by_status(AgentStatus::Completed),
104            failed: session.count_by_status(AgentStatus::Failed),
105            created_at: session.created_at.clone(),
106        }
107    }
108}
109
110/// Get the spawn metadata directory
111pub fn spawn_dir(project_root: Option<&PathBuf>) -> PathBuf {
112    let root = project_root
113        .cloned()
114        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
115    root.join(".scud").join("spawn")
116}
117
118/// Get the path to a session's metadata file
119pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
120    spawn_dir(project_root).join(format!("{}.json", session_name))
121}
122
123/// Save spawn session metadata
124pub fn save_session(project_root: Option<&PathBuf>, session: &SpawnSession) -> Result<()> {
125    let dir = spawn_dir(project_root);
126    fs::create_dir_all(&dir)?;
127
128    let file = session_file(project_root, &session.session_name);
129    let json = serde_json::to_string_pretty(session)?;
130    fs::write(file, json)?;
131
132    Ok(())
133}
134
135/// Load spawn session metadata
136pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SpawnSession> {
137    let file = session_file(project_root, session_name);
138    let json = fs::read_to_string(&file)?;
139    let session: SpawnSession = serde_json::from_str(&json)?;
140    Ok(session)
141}
142
143/// List all spawn sessions
144pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
145    let dir = spawn_dir(project_root);
146    if !dir.exists() {
147        return Ok(Vec::new());
148    }
149
150    let mut sessions = Vec::new();
151    for entry in fs::read_dir(dir)? {
152        let entry = entry?;
153        let path = entry.path();
154        if path.extension().map(|e| e == "json").unwrap_or(false) {
155            if let Some(stem) = path.file_stem() {
156                sessions.push(stem.to_string_lossy().to_string());
157            }
158        }
159    }
160
161    Ok(sessions)
162}
163
164/// Delete a spawn session metadata file
165pub fn delete_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<()> {
166    let file = session_file(project_root, session_name);
167    if file.exists() {
168        fs::remove_file(file)?;
169    }
170    Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_spawn_session_new() {
179        let session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
180
181        assert_eq!(session.session_name, "scud-test");
182        assert_eq!(session.tag, "test-tag");
183        assert_eq!(session.terminal, "tmux");
184        assert!(session.agents.is_empty());
185    }
186
187    #[test]
188    fn test_add_agent() {
189        let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
190        session.add_agent("auth:1", "Implement auth", "auth");
191
192        assert_eq!(session.agents.len(), 1);
193        assert_eq!(session.agents[0].task_id, "auth:1");
194        assert_eq!(session.agents[0].window_name, "task-auth:1");
195        assert_eq!(session.agents[0].status, AgentStatus::Starting);
196    }
197
198    #[test]
199    fn test_update_agent_status() {
200        let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
201        session.add_agent("auth:1", "Implement auth", "auth");
202        session.update_agent_status("auth:1", AgentStatus::Running);
203
204        assert_eq!(session.agents[0].status, AgentStatus::Running);
205    }
206
207    #[test]
208    fn test_spawn_stats() {
209        let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
210        session.add_agent("auth:1", "Task 1", "auth");
211        session.add_agent("auth:2", "Task 2", "auth");
212        session.add_agent("auth:3", "Task 3", "auth");
213
214        session.update_agent_status("auth:1", AgentStatus::Running);
215        session.update_agent_status("auth:2", AgentStatus::Completed);
216
217        let stats = SpawnStats::from(&session);
218
219        assert_eq!(stats.total_agents, 3);
220        assert_eq!(stats.starting, 1); // auth:3
221        assert_eq!(stats.running, 1); // auth:1
222        assert_eq!(stats.completed, 1); // auth:2
223        assert_eq!(stats.failed, 0);
224    }
225}