scud/commands/swarm/
session.rs

1//! Swarm session state tracking
2//!
3//! Tracks the state of a swarm execution session, including:
4//! - Waves executed
5//! - Rounds within waves
6//! - Task completion status
7//! - Validation results
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::PathBuf;
13
14use super::backpressure::ValidationResult;
15
16/// Brief summary of what was done in a wave
17/// This is NOT accumulated context - just a simple summary for the next wave
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WaveSummary {
20    /// Wave number
21    pub wave_number: usize,
22    /// Tasks that were completed
23    pub tasks_completed: Vec<String>,
24    /// Files that were changed
25    pub files_changed: Vec<String>,
26}
27
28impl WaveSummary {
29    /// Generate a brief text summary
30    pub fn to_text(&self) -> String {
31        let mut lines = Vec::new();
32
33        lines.push(format!(
34            "Wave {} completed {} task(s):",
35            self.wave_number,
36            self.tasks_completed.len()
37        ));
38
39        for task_id in &self.tasks_completed {
40            lines.push(format!("  - {}", task_id));
41        }
42
43        if !self.files_changed.is_empty() {
44            let file_summary = if self.files_changed.len() <= 5 {
45                self.files_changed.join(", ")
46            } else {
47                format!(
48                    "{} and {} more",
49                    self.files_changed[..5].join(", "),
50                    self.files_changed.len() - 5
51                )
52            };
53            lines.push(format!("Files changed: {}", file_summary));
54        }
55
56        lines.join("\n")
57    }
58}
59
60/// State of a single round within a wave
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct RoundState {
63    /// Round number (0-indexed)
64    pub round_number: usize,
65    /// Task IDs executed in this round
66    pub task_ids: Vec<String>,
67    /// Tags for each task
68    pub tags: Vec<String>,
69    /// Tasks that failed to spawn
70    pub failures: Vec<String>,
71    /// Start time
72    pub started_at: String,
73    /// End time (set when complete)
74    pub completed_at: Option<String>,
75}
76
77impl RoundState {
78    pub fn new(round_number: usize) -> Self {
79        Self {
80            round_number,
81            task_ids: Vec::new(),
82            tags: Vec::new(),
83            failures: Vec::new(),
84            started_at: chrono::Utc::now().to_rfc3339(),
85            completed_at: None,
86        }
87    }
88
89    pub fn mark_complete(&mut self) {
90        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
91    }
92}
93
94/// State of a single wave
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct WaveState {
97    /// Wave number (1-indexed)
98    pub wave_number: usize,
99    /// Rounds executed in this wave
100    pub rounds: Vec<RoundState>,
101    /// Validation result (if validation was run)
102    pub validation: Option<ValidationResult>,
103    /// Summary of what was done
104    pub summary: Option<WaveSummary>,
105    /// Start time
106    pub started_at: String,
107    /// End time (set when complete)
108    pub completed_at: Option<String>,
109}
110
111impl WaveState {
112    pub fn new(wave_number: usize) -> Self {
113        Self {
114            wave_number,
115            rounds: Vec::new(),
116            validation: None,
117            summary: None,
118            started_at: chrono::Utc::now().to_rfc3339(),
119            completed_at: None,
120        }
121    }
122
123    pub fn mark_complete(&mut self) {
124        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
125    }
126
127    /// Get all task IDs from all rounds
128    pub fn all_task_ids(&self) -> Vec<String> {
129        self.rounds
130            .iter()
131            .flat_map(|r| r.task_ids.clone())
132            .collect()
133    }
134
135    /// Get task ID to tag mapping
136    pub fn task_tags(&self) -> Vec<(String, String)> {
137        self.rounds
138            .iter()
139            .flat_map(|r| {
140                r.task_ids
141                    .iter()
142                    .zip(r.tags.iter())
143                    .map(|(id, tag)| (id.clone(), tag.clone()))
144            })
145            .collect()
146    }
147}
148
149/// Full swarm session state
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SwarmSession {
152    /// Session name
153    pub session_name: String,
154    /// Tag being executed
155    pub tag: String,
156    /// Terminal type
157    pub terminal: String,
158    /// Working directory
159    pub working_dir: String,
160    /// Round size (max tasks per round)
161    pub round_size: usize,
162    /// Waves executed
163    pub waves: Vec<WaveState>,
164    /// Session start time
165    pub started_at: String,
166    /// Session end time
167    pub completed_at: Option<String>,
168}
169
170impl SwarmSession {
171    pub fn new(
172        session_name: &str,
173        tag: &str,
174        terminal: &str,
175        working_dir: &str,
176        round_size: usize,
177    ) -> Self {
178        Self {
179            session_name: session_name.to_string(),
180            tag: tag.to_string(),
181            terminal: terminal.to_string(),
182            working_dir: working_dir.to_string(),
183            round_size,
184            waves: Vec::new(),
185            started_at: chrono::Utc::now().to_rfc3339(),
186            completed_at: None,
187        }
188    }
189
190    pub fn mark_complete(&mut self) {
191        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
192    }
193
194    /// Get total tasks executed
195    pub fn total_tasks(&self) -> usize {
196        self.waves
197            .iter()
198            .flat_map(|w| &w.rounds)
199            .map(|r| r.task_ids.len())
200            .sum()
201    }
202
203    /// Get total failures
204    pub fn total_failures(&self) -> usize {
205        self.waves
206            .iter()
207            .flat_map(|w| &w.rounds)
208            .map(|r| r.failures.len())
209            .sum()
210    }
211
212    /// Get brief summary of the previous wave (if any)
213    /// This is just "what was done", not accumulated context
214    pub fn get_previous_summary(&self) -> Option<String> {
215        self.waves
216            .last()
217            .and_then(|w| w.summary.as_ref().map(|s| s.to_text()))
218    }
219}
220
221/// Get the swarm session directory
222pub fn swarm_dir(project_root: Option<&PathBuf>) -> PathBuf {
223    let root = project_root
224        .cloned()
225        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
226    root.join(".scud").join("swarm")
227}
228
229/// Get the path to a session's state file
230pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
231    swarm_dir(project_root).join(format!("{}.json", session_name))
232}
233
234/// Save swarm session state
235pub fn save_session(project_root: Option<&PathBuf>, session: &SwarmSession) -> Result<()> {
236    let dir = swarm_dir(project_root);
237    fs::create_dir_all(&dir)?;
238
239    let file = session_file(project_root, &session.session_name);
240    let json = serde_json::to_string_pretty(session)?;
241    fs::write(file, json)?;
242
243    Ok(())
244}
245
246/// Load swarm session state
247pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SwarmSession> {
248    let file = session_file(project_root, session_name);
249    let json = fs::read_to_string(&file)?;
250    let session: SwarmSession = serde_json::from_str(&json)?;
251    Ok(session)
252}
253
254/// List all swarm sessions
255pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
256    let dir = swarm_dir(project_root);
257    if !dir.exists() {
258        return Ok(Vec::new());
259    }
260
261    let mut sessions = Vec::new();
262    for entry in fs::read_dir(dir)? {
263        let entry = entry?;
264        let path = entry.path();
265        if path.extension().map(|e| e == "json").unwrap_or(false) {
266            if let Some(stem) = path.file_stem() {
267                sessions.push(stem.to_string_lossy().to_string());
268            }
269        }
270    }
271
272    Ok(sessions)
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_round_state_new() {
281        let round = RoundState::new(0);
282        assert_eq!(round.round_number, 0);
283        assert!(round.task_ids.is_empty());
284        assert!(round.completed_at.is_none());
285    }
286
287    #[test]
288    fn test_wave_state_all_task_ids() {
289        let mut wave = WaveState::new(1);
290
291        let mut round1 = RoundState::new(0);
292        round1.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
293
294        let mut round2 = RoundState::new(1);
295        round2.task_ids = vec!["task:3".to_string()];
296
297        wave.rounds.push(round1);
298        wave.rounds.push(round2);
299
300        let all_ids = wave.all_task_ids();
301        assert_eq!(all_ids.len(), 3);
302        assert!(all_ids.contains(&"task:1".to_string()));
303        assert!(all_ids.contains(&"task:2".to_string()));
304        assert!(all_ids.contains(&"task:3".to_string()));
305    }
306
307    #[test]
308    fn test_swarm_session_total_tasks() {
309        let mut session = SwarmSession::new("test-session", "test-tag", "tmux", "/test/path", 5);
310
311        let mut wave = WaveState::new(1);
312        let mut round = RoundState::new(0);
313        round.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
314        wave.rounds.push(round);
315        session.waves.push(wave);
316
317        assert_eq!(session.total_tasks(), 2);
318    }
319
320    #[test]
321    fn test_wave_summary_to_text() {
322        let summary = WaveSummary {
323            wave_number: 1,
324            tasks_completed: vec!["task:1".to_string(), "task:2".to_string()],
325            files_changed: vec!["src/main.rs".to_string()],
326        };
327
328        let text = summary.to_text();
329        assert!(text.contains("Wave 1"));
330        assert!(text.contains("task:1"));
331        assert!(text.contains("src/main.rs"));
332    }
333
334    #[test]
335    fn test_get_previous_summary() {
336        let mut session = SwarmSession::new("test", "tag", "tmux", "/path", 5);
337
338        // No waves yet
339        assert!(session.get_previous_summary().is_none());
340
341        // Add wave with summary
342        let mut wave = WaveState::new(1);
343        wave.summary = Some(WaveSummary {
344            wave_number: 1,
345            tasks_completed: vec!["task:1".to_string()],
346            files_changed: vec![],
347        });
348        session.waves.push(wave);
349
350        let summary = session.get_previous_summary();
351        assert!(summary.is_some());
352        assert!(summary.unwrap().contains("task:1"));
353    }
354}