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;
13use std::process::Command;
14
15use crate::backpressure::ValidationResult;
16
17/// Get the current git commit SHA
18pub fn get_current_commit() -> Option<String> {
19    Command::new("git")
20        .args(["rev-parse", "HEAD"])
21        .output()
22        .ok()
23        .and_then(|output| {
24            if output.status.success() {
25                Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
26            } else {
27                None
28            }
29        })
30}
31
32/// Brief summary of what was done in a wave
33/// This is NOT accumulated context - just a simple summary for the next wave
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct WaveSummary {
36    /// Wave number
37    pub wave_number: usize,
38    /// Tasks that were completed
39    pub tasks_completed: Vec<String>,
40    /// Files that were changed
41    pub files_changed: Vec<String>,
42}
43
44impl WaveSummary {
45    /// Generate a brief text summary
46    pub fn to_text(&self) -> String {
47        let mut lines = Vec::new();
48
49        lines.push(format!(
50            "Wave {} completed {} task(s):",
51            self.wave_number,
52            self.tasks_completed.len()
53        ));
54
55        for task_id in &self.tasks_completed {
56            lines.push(format!("  - {}", task_id));
57        }
58
59        if !self.files_changed.is_empty() {
60            let file_summary = if self.files_changed.len() <= 5 {
61                self.files_changed.join(", ")
62            } else {
63                format!(
64                    "{} and {} more",
65                    self.files_changed[..5].join(", "),
66                    self.files_changed.len() - 5
67                )
68            };
69            lines.push(format!("Files changed: {}", file_summary));
70        }
71
72        lines.join("\n")
73    }
74}
75
76/// State of a single round within a wave
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct RoundState {
79    /// Round number (0-indexed)
80    pub round_number: usize,
81    /// Task IDs executed in this round
82    pub task_ids: Vec<String>,
83    /// Tags for each task
84    pub tags: Vec<String>,
85    /// Tasks that failed to spawn
86    pub failures: Vec<String>,
87    /// Start time
88    pub started_at: String,
89    /// End time (set when complete)
90    pub completed_at: Option<String>,
91}
92
93impl RoundState {
94    pub fn new(round_number: usize) -> Self {
95        Self {
96            round_number,
97            task_ids: Vec::new(),
98            tags: Vec::new(),
99            failures: Vec::new(),
100            started_at: chrono::Utc::now().to_rfc3339(),
101            completed_at: None,
102        }
103    }
104
105    pub fn mark_complete(&mut self) {
106        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
107    }
108}
109
110/// State of a single wave
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct WaveState {
113    /// Wave number (1-indexed)
114    pub wave_number: usize,
115    /// Rounds executed in this wave
116    pub rounds: Vec<RoundState>,
117    /// Validation result (if validation was run)
118    pub validation: Option<ValidationResult>,
119    /// Summary of what was done
120    pub summary: Option<WaveSummary>,
121    /// Git commit SHA at wave start (for tracking changes)
122    #[serde(default)]
123    pub start_commit: Option<String>,
124    /// Start time
125    pub started_at: String,
126    /// End time (set when complete)
127    pub completed_at: Option<String>,
128}
129
130impl WaveState {
131    pub fn new(wave_number: usize) -> Self {
132        Self {
133            wave_number,
134            rounds: Vec::new(),
135            validation: None,
136            summary: None,
137            start_commit: get_current_commit(),
138            started_at: chrono::Utc::now().to_rfc3339(),
139            completed_at: None,
140        }
141    }
142
143    pub fn mark_complete(&mut self) {
144        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
145    }
146
147    /// Get all task IDs from all rounds
148    pub fn all_task_ids(&self) -> Vec<String> {
149        self.rounds
150            .iter()
151            .flat_map(|r| r.task_ids.clone())
152            .collect()
153    }
154
155    /// Get task ID to tag mapping
156    pub fn task_tags(&self) -> Vec<(String, String)> {
157        self.rounds
158            .iter()
159            .flat_map(|r| {
160                r.task_ids
161                    .iter()
162                    .zip(r.tags.iter())
163                    .map(|(id, tag)| (id.clone(), tag.clone()))
164            })
165            .collect()
166    }
167}
168
169/// Full swarm session state
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct SwarmSession {
172    /// Session name
173    pub session_name: String,
174    /// Tag being executed
175    pub tag: String,
176    /// Terminal type
177    pub terminal: String,
178    /// Working directory
179    pub working_dir: String,
180    /// Round size (max tasks per round)
181    pub round_size: usize,
182    /// Waves executed
183    pub waves: Vec<WaveState>,
184    /// Session start time
185    pub started_at: String,
186    /// Session end time
187    pub completed_at: Option<String>,
188}
189
190impl SwarmSession {
191    pub fn new(
192        session_name: &str,
193        tag: &str,
194        terminal: &str,
195        working_dir: &str,
196        round_size: usize,
197    ) -> Self {
198        Self {
199            session_name: session_name.to_string(),
200            tag: tag.to_string(),
201            terminal: terminal.to_string(),
202            working_dir: working_dir.to_string(),
203            round_size,
204            waves: Vec::new(),
205            started_at: chrono::Utc::now().to_rfc3339(),
206            completed_at: None,
207        }
208    }
209
210    pub fn mark_complete(&mut self) {
211        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
212    }
213
214    /// Get total tasks executed
215    pub fn total_tasks(&self) -> usize {
216        self.waves
217            .iter()
218            .flat_map(|w| &w.rounds)
219            .map(|r| r.task_ids.len())
220            .sum()
221    }
222
223    /// Get total failures
224    pub fn total_failures(&self) -> usize {
225        self.waves
226            .iter()
227            .flat_map(|w| &w.rounds)
228            .map(|r| r.failures.len())
229            .sum()
230    }
231
232    /// Get brief summary of the previous wave (if any)
233    /// This is just "what was done", not accumulated context
234    pub fn get_previous_summary(&self) -> Option<String> {
235        self.waves
236            .last()
237            .and_then(|w| w.summary.as_ref().map(|s| s.to_text()))
238    }
239}
240
241/// Get the swarm session directory
242pub fn swarm_dir(project_root: Option<&PathBuf>) -> PathBuf {
243    let root = project_root
244        .cloned()
245        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
246    root.join(".scud").join("swarm")
247}
248
249/// Get the path to the session lock file for a given tag
250pub fn lock_file_path(project_root: Option<&PathBuf>, tag: &str) -> PathBuf {
251    swarm_dir(project_root).join(format!("{}.lock", tag))
252}
253
254/// A session lock that prevents concurrent swarm sessions on the same tag.
255/// The lock is automatically released when this struct is dropped.
256pub struct SessionLock {
257    _file: fs::File,
258    path: PathBuf,
259}
260
261impl SessionLock {
262    /// Get the path to the lock file
263    pub fn path(&self) -> &PathBuf {
264        &self.path
265    }
266}
267
268impl Drop for SessionLock {
269    fn drop(&mut self) {
270        // Lock is released automatically when file is dropped
271        // Optionally remove the lock file
272        let _ = fs::remove_file(&self.path);
273    }
274}
275
276/// Acquire an exclusive session lock for a tag.
277/// Returns a SessionLock that will be released when dropped.
278/// Returns an error if another session already holds the lock.
279pub fn acquire_session_lock(project_root: Option<&PathBuf>, tag: &str) -> Result<SessionLock> {
280    use fs2::FileExt;
281
282    let dir = swarm_dir(project_root);
283    fs::create_dir_all(&dir)?;
284
285    let lock_path = lock_file_path(project_root, tag);
286    let file = fs::OpenOptions::new()
287        .write(true)
288        .create(true)
289        .truncate(true)
290        .open(&lock_path)?;
291
292    // Try to acquire exclusive lock (non-blocking)
293    file.try_lock_exclusive().map_err(|_| {
294        anyhow::anyhow!(
295            "Another swarm session is already running for tag '{}'. \
296             If this is incorrect, remove the lock file: {}",
297            tag,
298            lock_path.display()
299        )
300    })?;
301
302    // Write PID and timestamp to lock file for debugging
303    use std::io::Write;
304    let mut file = file;
305    writeln!(
306        file,
307        "pid={}\nstarted={}",
308        std::process::id(),
309        chrono::Utc::now().to_rfc3339()
310    )?;
311
312    Ok(SessionLock {
313        _file: file,
314        path: lock_path,
315    })
316}
317
318/// Get the path to a session's state file
319pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
320    swarm_dir(project_root).join(format!("{}.json", session_name))
321}
322
323/// Save swarm session state
324pub fn save_session(project_root: Option<&PathBuf>, session: &SwarmSession) -> Result<()> {
325    let dir = swarm_dir(project_root);
326    fs::create_dir_all(&dir)?;
327
328    let file = session_file(project_root, &session.session_name);
329    let json = serde_json::to_string_pretty(session)?;
330    fs::write(file, json)?;
331
332    Ok(())
333}
334
335/// Load swarm session state
336pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SwarmSession> {
337    let file = session_file(project_root, session_name);
338    let json = fs::read_to_string(&file)?;
339    let session: SwarmSession = serde_json::from_str(&json)?;
340    Ok(session)
341}
342
343/// List all swarm sessions
344pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
345    let dir = swarm_dir(project_root);
346    if !dir.exists() {
347        return Ok(Vec::new());
348    }
349
350    let mut sessions = Vec::new();
351    for entry in fs::read_dir(dir)? {
352        let entry = entry?;
353        let path = entry.path();
354        if path.extension().map(|e| e == "json").unwrap_or(false) {
355            if let Some(stem) = path.file_stem() {
356                sessions.push(stem.to_string_lossy().to_string());
357            }
358        }
359    }
360
361    Ok(sessions)
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_round_state_new() {
370        let round = RoundState::new(0);
371        assert_eq!(round.round_number, 0);
372        assert!(round.task_ids.is_empty());
373        assert!(round.completed_at.is_none());
374    }
375
376    #[test]
377    fn test_wave_state_all_task_ids() {
378        let mut wave = WaveState::new(1);
379
380        let mut round1 = RoundState::new(0);
381        round1.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
382
383        let mut round2 = RoundState::new(1);
384        round2.task_ids = vec!["task:3".to_string()];
385
386        wave.rounds.push(round1);
387        wave.rounds.push(round2);
388
389        let all_ids = wave.all_task_ids();
390        assert_eq!(all_ids.len(), 3);
391        assert!(all_ids.contains(&"task:1".to_string()));
392        assert!(all_ids.contains(&"task:2".to_string()));
393        assert!(all_ids.contains(&"task:3".to_string()));
394    }
395
396    #[test]
397    fn test_swarm_session_total_tasks() {
398        let mut session = SwarmSession::new("test-session", "test-tag", "tmux", "/test/path", 5);
399
400        let mut wave = WaveState::new(1);
401        let mut round = RoundState::new(0);
402        round.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
403        wave.rounds.push(round);
404        session.waves.push(wave);
405
406        assert_eq!(session.total_tasks(), 2);
407    }
408
409    #[test]
410    fn test_wave_summary_to_text() {
411        let summary = WaveSummary {
412            wave_number: 1,
413            tasks_completed: vec!["task:1".to_string(), "task:2".to_string()],
414            files_changed: vec!["src/main.rs".to_string()],
415        };
416
417        let text = summary.to_text();
418        assert!(text.contains("Wave 1"));
419        assert!(text.contains("task:1"));
420        assert!(text.contains("src/main.rs"));
421    }
422
423    #[test]
424    fn test_get_previous_summary() {
425        let mut session = SwarmSession::new("test", "tag", "tmux", "/path", 5);
426
427        // No waves yet
428        assert!(session.get_previous_summary().is_none());
429
430        // Add wave with summary
431        let mut wave = WaveState::new(1);
432        wave.summary = Some(WaveSummary {
433            wave_number: 1,
434            tasks_completed: vec!["task:1".to_string()],
435            files_changed: vec![],
436        });
437        session.waves.push(wave);
438
439        let summary = session.get_previous_summary();
440        assert!(summary.is_some());
441        assert!(summary.unwrap().contains("task:1"));
442    }
443
444    #[test]
445    fn test_session_lock_contention() {
446        use tempfile::TempDir;
447
448        // Create a temporary directory for testing
449        let temp_dir = TempDir::new().unwrap();
450        let project_root = temp_dir.path().to_path_buf();
451
452        // Acquire first lock
453        let _lock1 = acquire_session_lock(Some(&project_root), "test-tag")
454            .expect("First lock should succeed");
455
456        // Try to acquire second lock for same tag while first is held
457        let result = acquire_session_lock(Some(&project_root), "test-tag");
458
459        // Verify the second attempt fails and error message mentions "already running"
460        match result {
461            Ok(_) => panic!("Second lock should fail"),
462            Err(e) => {
463                let error_msg = e.to_string();
464                assert!(
465                    error_msg.contains("already running"),
466                    "Error message should mention 'already running', got: {}",
467                    error_msg
468                );
469            }
470        }
471    }
472
473    #[test]
474    fn test_get_current_commit() {
475        let result = get_current_commit();
476
477        // Should return Some(sha) since we're in a git repo
478        assert!(result.is_some(), "Expected Some(sha) in a git repository");
479
480        let sha = result.unwrap();
481
482        // Verify the SHA is 40 characters long (full SHA)
483        assert_eq!(
484            sha.len(),
485            40,
486            "Expected SHA to be 40 characters long, got {}",
487            sha.len()
488        );
489
490        // Verify the SHA contains only hex characters (0-9, a-f)
491        assert!(
492            sha.chars().all(|c| c.is_ascii_hexdigit()),
493            "Expected SHA to contain only hex characters, got: {}",
494            sha
495        );
496    }
497}