Skip to main content

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// ============================================================================
111// MonitorableSession Trait - Unified interface for TUI
112// ============================================================================
113
114/// Read-only view of an agent for display
115#[derive(Clone, Debug)]
116pub struct AgentView {
117    pub task_id: String,
118    pub task_title: String,
119    pub window_name: String,
120    pub status: AgentStatus,
121    pub tag: String,
122}
123
124/// Read-only view of a wave for display
125#[derive(Clone, Debug)]
126pub struct WaveView {
127    pub wave_number: usize,
128    pub tasks: Vec<WaveTaskView>,
129}
130
131/// Read-only view of a task within a wave
132#[derive(Clone, Debug)]
133pub struct WaveTaskView {
134    pub task_id: String,
135    pub task_title: String,
136    pub state: WaveTaskState,
137    pub complexity: Option<u32>,
138}
139
140/// State of a task within a wave
141#[derive(Clone, Debug, PartialEq)]
142pub enum WaveTaskState {
143    Ready,
144    Running,
145    Done,
146    Blocked,
147    InProgress,
148}
149
150/// Status counts for header display
151#[derive(Clone, Debug, Default)]
152pub struct StatusCounts {
153    pub starting: usize,
154    pub running: usize,
155    pub completed: usize,
156    pub failed: usize,
157}
158
159/// Trait for sessions that can be displayed in the TUI monitor
160pub trait MonitorableSession: Send + Sync {
161    /// Get the session name
162    fn session_name(&self) -> &str;
163
164    /// Get the tag/phase being worked on
165    fn tag(&self) -> &str;
166
167    /// Get the working directory
168    fn working_dir(&self) -> &str;
169
170    /// Get all agents with their current status
171    fn agents(&self) -> Vec<AgentView>;
172
173    /// Get computed waves for display (empty for SpawnSession, computed for SwarmSession)
174    fn waves(&self) -> Vec<WaveView>;
175
176    /// Get status counts for header display
177    fn status_counts(&self) -> StatusCounts;
178}
179
180impl MonitorableSession for SpawnSession {
181    fn session_name(&self) -> &str {
182        &self.session_name
183    }
184
185    fn tag(&self) -> &str {
186        &self.tag
187    }
188
189    fn working_dir(&self) -> &str {
190        &self.working_dir
191    }
192
193    fn agents(&self) -> Vec<AgentView> {
194        self.agents
195            .iter()
196            .map(|a| AgentView {
197                task_id: a.task_id.clone(),
198                task_title: a.task_title.clone(),
199                window_name: a.window_name.clone(),
200                status: a.status.clone(),
201                tag: a.tag.clone(),
202            })
203            .collect()
204    }
205
206    fn waves(&self) -> Vec<WaveView> {
207        // SpawnSession doesn't track waves natively
208        // The TUI computes waves dynamically from task storage
209        Vec::new()
210    }
211
212    fn status_counts(&self) -> StatusCounts {
213        StatusCounts {
214            starting: self.count_by_status(AgentStatus::Starting),
215            running: self.count_by_status(AgentStatus::Running),
216            completed: self.count_by_status(AgentStatus::Completed),
217            failed: self.count_by_status(AgentStatus::Failed),
218        }
219    }
220}
221
222/// Get the spawn metadata directory
223pub fn spawn_dir(project_root: Option<&PathBuf>) -> PathBuf {
224    let root = project_root
225        .cloned()
226        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
227    root.join(".scud").join("spawn")
228}
229
230/// Get the path to a session's metadata file
231pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
232    spawn_dir(project_root).join(format!("{}.json", session_name))
233}
234
235/// Save spawn session metadata
236pub fn save_session(project_root: Option<&PathBuf>, session: &SpawnSession) -> Result<PathBuf> {
237    let dir = spawn_dir(project_root);
238    fs::create_dir_all(&dir)?;
239
240    let file = session_file(project_root, &session.session_name);
241    let json = serde_json::to_string_pretty(session)?;
242    fs::write(&file, json)?;
243
244    Ok(file)
245}
246
247/// Load spawn session metadata
248pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SpawnSession> {
249    let file = session_file(project_root, session_name);
250    let json = fs::read_to_string(&file)?;
251    let session: SpawnSession = serde_json::from_str(&json)?;
252    Ok(session)
253}
254
255/// List all spawn sessions
256pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
257    let dir = spawn_dir(project_root);
258    if !dir.exists() {
259        return Ok(Vec::new());
260    }
261
262    let mut sessions = Vec::new();
263    for entry in fs::read_dir(dir)? {
264        let entry = entry?;
265        let path = entry.path();
266        if path.extension().map(|e| e == "json").unwrap_or(false) {
267            if let Some(stem) = path.file_stem() {
268                sessions.push(stem.to_string_lossy().to_string());
269            }
270        }
271    }
272
273    Ok(sessions)
274}
275
276/// Delete a spawn session metadata file
277pub fn delete_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<()> {
278    let file = session_file(project_root, session_name);
279    if file.exists() {
280        fs::remove_file(file)?;
281    }
282    Ok(())
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_spawn_session_new() {
291        let session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
292
293        assert_eq!(session.session_name, "scud-test");
294        assert_eq!(session.tag, "test-tag");
295        assert_eq!(session.terminal, "tmux");
296        assert!(session.agents.is_empty());
297    }
298
299    #[test]
300    fn test_add_agent() {
301        let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
302        session.add_agent("auth:1", "Implement auth", "auth");
303
304        assert_eq!(session.agents.len(), 1);
305        assert_eq!(session.agents[0].task_id, "auth:1");
306        assert_eq!(session.agents[0].window_name, "task-auth:1");
307        assert_eq!(session.agents[0].status, AgentStatus::Starting);
308    }
309
310    #[test]
311    fn test_update_agent_status() {
312        let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
313        session.add_agent("auth:1", "Implement auth", "auth");
314        session.update_agent_status("auth:1", AgentStatus::Running);
315
316        assert_eq!(session.agents[0].status, AgentStatus::Running);
317    }
318
319    #[test]
320    fn test_spawn_stats() {
321        let mut session = SpawnSession::new("scud-test", "test-tag", "tmux", "/path/to/project");
322        session.add_agent("auth:1", "Task 1", "auth");
323        session.add_agent("auth:2", "Task 2", "auth");
324        session.add_agent("auth:3", "Task 3", "auth");
325
326        session.update_agent_status("auth:1", AgentStatus::Running);
327        session.update_agent_status("auth:2", AgentStatus::Completed);
328
329        let stats = SpawnStats::from(&session);
330
331        assert_eq!(stats.total_agents, 3);
332        assert_eq!(stats.starting, 1); // auth:3
333        assert_eq!(stats.running, 1); // auth:1
334        assert_eq!(stats.completed, 1); // auth:2
335        assert_eq!(stats.failed, 0);
336    }
337
338    #[test]
339    fn test_spawn_session_implements_monitorable() {
340        let session = SpawnSession::new("test", "tag", "tmux", "/tmp");
341
342        // Verify trait is implemented
343        let monitorable: &dyn MonitorableSession = &session;
344        assert_eq!(monitorable.session_name(), "test");
345        assert_eq!(monitorable.tag(), "tag");
346        assert_eq!(monitorable.working_dir(), "/tmp");
347    }
348
349    #[test]
350    fn test_spawn_session_agents_view() {
351        let mut session = SpawnSession::new("test", "tag", "tmux", "/tmp");
352        session.add_agent("task-1", "Title One", "tag");
353        session.add_agent("task-2", "Title Two", "tag");
354
355        let agents = session.agents();
356        assert_eq!(agents.len(), 2);
357        assert_eq!(agents[0].task_id, "task-1");
358        assert_eq!(agents[0].task_title, "Title One");
359        assert_eq!(agents[1].task_id, "task-2");
360    }
361
362    #[test]
363    fn test_spawn_session_status_counts() {
364        let mut session = SpawnSession::new("test", "tag", "tmux", "/tmp");
365        session.add_agent("task-1", "Title", "tag");
366        session.add_agent("task-2", "Title", "tag");
367        session.add_agent("task-3", "Title", "tag");
368
369        session.update_agent_status("task-1", AgentStatus::Running);
370        session.update_agent_status("task-2", AgentStatus::Completed);
371        // task-3 stays Starting
372
373        let counts = session.status_counts();
374        assert_eq!(counts.starting, 1);
375        assert_eq!(counts.running, 1);
376        assert_eq!(counts.completed, 1);
377        assert_eq!(counts.failed, 0);
378    }
379
380    #[test]
381    fn test_spawn_session_waves_empty() {
382        let session = SpawnSession::new("test", "tag", "tmux", "/tmp");
383
384        // SpawnSession doesn't track waves, should return empty
385        let waves = session.waves();
386        assert!(waves.is_empty());
387    }
388}