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 code review for a wave
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct ReviewState {
113    /// Tasks that were reviewed
114    pub reviewed_tasks: Vec<String>,
115    /// Whether all reviewed tasks passed
116    pub all_passed: bool,
117    /// Tasks flagged for improvement
118    pub tasks_needing_improvement: Vec<String>,
119    /// When the review completed
120    pub completed_at: String,
121}
122
123/// Record of a repair attempt
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RepairAttempt {
126    /// Attempt number (1-indexed)
127    pub attempt_number: usize,
128    /// Tasks attributed as responsible for the failure
129    pub attributed_tasks: Vec<String>,
130    /// Tasks cleared as not responsible
131    pub cleared_tasks: Vec<String>,
132    /// Attribution confidence level: "high", "medium", "low"
133    pub attribution_confidence: String,
134    /// Whether validation passed after this repair
135    pub validation_passed: bool,
136    /// When the repair attempt completed
137    pub completed_at: String,
138}
139
140/// State of a single wave
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct WaveState {
143    /// Wave number (1-indexed)
144    pub wave_number: usize,
145    /// Rounds executed in this wave
146    pub rounds: Vec<RoundState>,
147    /// Validation result (if validation was run)
148    pub validation: Option<ValidationResult>,
149    /// Summary of what was done
150    pub summary: Option<WaveSummary>,
151    /// Git commit SHA at wave start (for tracking changes)
152    #[serde(default)]
153    pub start_commit: Option<String>,
154    /// Review result (if review was run)
155    #[serde(default)]
156    pub review: Option<ReviewState>,
157    /// Repair attempts (if validation failed)
158    #[serde(default)]
159    pub repairs: Vec<RepairAttempt>,
160    /// Start time
161    pub started_at: String,
162    /// End time (set when complete)
163    pub completed_at: Option<String>,
164}
165
166impl WaveState {
167    pub fn new(wave_number: usize) -> Self {
168        Self {
169            wave_number,
170            rounds: Vec::new(),
171            validation: None,
172            summary: None,
173            start_commit: get_current_commit(),
174            review: None,
175            repairs: Vec::new(),
176            started_at: chrono::Utc::now().to_rfc3339(),
177            completed_at: None,
178        }
179    }
180
181    pub fn mark_complete(&mut self) {
182        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
183    }
184
185    /// Get all task IDs from all rounds
186    pub fn all_task_ids(&self) -> Vec<String> {
187        self.rounds
188            .iter()
189            .flat_map(|r| r.task_ids.clone())
190            .collect()
191    }
192
193    /// Get task ID to tag mapping
194    pub fn task_tags(&self) -> Vec<(String, String)> {
195        self.rounds
196            .iter()
197            .flat_map(|r| {
198                r.task_ids
199                    .iter()
200                    .zip(r.tags.iter())
201                    .map(|(id, tag)| (id.clone(), tag.clone()))
202            })
203            .collect()
204    }
205}
206
207/// Full swarm session state
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct SwarmSession {
210    /// Session name
211    pub session_name: String,
212    /// Tag being executed
213    pub tag: String,
214    /// Terminal type
215    pub terminal: String,
216    /// Working directory
217    pub working_dir: String,
218    /// Round size (max tasks per round)
219    pub round_size: usize,
220    /// Waves executed
221    pub waves: Vec<WaveState>,
222    /// Session start time
223    pub started_at: String,
224    /// Session end time
225    pub completed_at: Option<String>,
226}
227
228impl SwarmSession {
229    pub fn new(
230        session_name: &str,
231        tag: &str,
232        terminal: &str,
233        working_dir: &str,
234        round_size: usize,
235    ) -> Self {
236        Self {
237            session_name: session_name.to_string(),
238            tag: tag.to_string(),
239            terminal: terminal.to_string(),
240            working_dir: working_dir.to_string(),
241            round_size,
242            waves: Vec::new(),
243            started_at: chrono::Utc::now().to_rfc3339(),
244            completed_at: None,
245        }
246    }
247
248    pub fn mark_complete(&mut self) {
249        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
250    }
251
252    /// Get total tasks executed
253    pub fn total_tasks(&self) -> usize {
254        self.waves
255            .iter()
256            .flat_map(|w| &w.rounds)
257            .map(|r| r.task_ids.len())
258            .sum()
259    }
260
261    /// Get total failures
262    pub fn total_failures(&self) -> usize {
263        self.waves
264            .iter()
265            .flat_map(|w| &w.rounds)
266            .map(|r| r.failures.len())
267            .sum()
268    }
269
270    /// Get brief summary of the previous wave (if any)
271    /// This is just "what was done", not accumulated context
272    pub fn get_previous_summary(&self) -> Option<String> {
273        self.waves
274            .last()
275            .and_then(|w| w.summary.as_ref().map(|s| s.to_text()))
276    }
277}
278
279/// Get the swarm session directory
280pub fn swarm_dir(project_root: Option<&PathBuf>) -> PathBuf {
281    let root = project_root
282        .cloned()
283        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
284    root.join(".scud").join("swarm")
285}
286
287/// Get the path to the session lock file for a given tag
288pub fn lock_file_path(project_root: Option<&PathBuf>, tag: &str) -> PathBuf {
289    swarm_dir(project_root).join(format!("{}.lock", tag))
290}
291
292/// A session lock that prevents concurrent swarm sessions on the same tag.
293/// The lock is automatically released when this struct is dropped.
294pub struct SessionLock {
295    _file: fs::File,
296    path: PathBuf,
297}
298
299impl SessionLock {
300    /// Get the path to the lock file
301    pub fn path(&self) -> &PathBuf {
302        &self.path
303    }
304}
305
306impl Drop for SessionLock {
307    fn drop(&mut self) {
308        // Lock is released automatically when file is dropped
309        // Optionally remove the lock file
310        let _ = fs::remove_file(&self.path);
311    }
312}
313
314/// Acquire an exclusive session lock for a tag.
315/// Returns a SessionLock that will be released when dropped.
316/// Returns an error if another session already holds the lock.
317pub fn acquire_session_lock(project_root: Option<&PathBuf>, tag: &str) -> Result<SessionLock> {
318    use fs2::FileExt;
319
320    let dir = swarm_dir(project_root);
321    fs::create_dir_all(&dir)?;
322
323    let lock_path = lock_file_path(project_root, tag);
324    let file = fs::OpenOptions::new()
325        .write(true)
326        .create(true)
327        .truncate(true)
328        .open(&lock_path)?;
329
330    // Try to acquire exclusive lock (non-blocking)
331    file.try_lock_exclusive().map_err(|_| {
332        anyhow::anyhow!(
333            "Another swarm session is already running for tag '{}'. \
334             If this is incorrect, remove the lock file: {}",
335            tag,
336            lock_path.display()
337        )
338    })?;
339
340    // Write PID and timestamp to lock file for debugging
341    use std::io::Write;
342    let mut file = file;
343    writeln!(
344        file,
345        "pid={}\nstarted={}",
346        std::process::id(),
347        chrono::Utc::now().to_rfc3339()
348    )?;
349
350    Ok(SessionLock {
351        _file: file,
352        path: lock_path,
353    })
354}
355
356/// Get the path to a session's state file
357pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
358    swarm_dir(project_root).join(format!("{}.json", session_name))
359}
360
361/// Save swarm session state
362pub fn save_session(project_root: Option<&PathBuf>, session: &SwarmSession) -> Result<()> {
363    let dir = swarm_dir(project_root);
364    fs::create_dir_all(&dir)?;
365
366    let file = session_file(project_root, &session.session_name);
367    let json = serde_json::to_string_pretty(session)?;
368    fs::write(file, json)?;
369
370    Ok(())
371}
372
373/// Load swarm session state
374pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SwarmSession> {
375    let file = session_file(project_root, session_name);
376    let json = fs::read_to_string(&file)?;
377    let session: SwarmSession = serde_json::from_str(&json)?;
378    Ok(session)
379}
380
381/// List all swarm sessions
382pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
383    let dir = swarm_dir(project_root);
384    if !dir.exists() {
385        return Ok(Vec::new());
386    }
387
388    let mut sessions = Vec::new();
389    for entry in fs::read_dir(dir)? {
390        let entry = entry?;
391        let path = entry.path();
392        if path.extension().map(|e| e == "json").unwrap_or(false) {
393            if let Some(stem) = path.file_stem() {
394                sessions.push(stem.to_string_lossy().to_string());
395            }
396        }
397    }
398
399    Ok(sessions)
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_round_state_new() {
408        let round = RoundState::new(0);
409        assert_eq!(round.round_number, 0);
410        assert!(round.task_ids.is_empty());
411        assert!(round.completed_at.is_none());
412    }
413
414    #[test]
415    fn test_wave_state_all_task_ids() {
416        let mut wave = WaveState::new(1);
417
418        let mut round1 = RoundState::new(0);
419        round1.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
420
421        let mut round2 = RoundState::new(1);
422        round2.task_ids = vec!["task:3".to_string()];
423
424        wave.rounds.push(round1);
425        wave.rounds.push(round2);
426
427        let all_ids = wave.all_task_ids();
428        assert_eq!(all_ids.len(), 3);
429        assert!(all_ids.contains(&"task:1".to_string()));
430        assert!(all_ids.contains(&"task:2".to_string()));
431        assert!(all_ids.contains(&"task:3".to_string()));
432    }
433
434    #[test]
435    fn test_swarm_session_total_tasks() {
436        let mut session = SwarmSession::new("test-session", "test-tag", "tmux", "/test/path", 5);
437
438        let mut wave = WaveState::new(1);
439        let mut round = RoundState::new(0);
440        round.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
441        wave.rounds.push(round);
442        session.waves.push(wave);
443
444        assert_eq!(session.total_tasks(), 2);
445    }
446
447    #[test]
448    fn test_wave_summary_to_text() {
449        let summary = WaveSummary {
450            wave_number: 1,
451            tasks_completed: vec!["task:1".to_string(), "task:2".to_string()],
452            files_changed: vec!["src/main.rs".to_string()],
453        };
454
455        let text = summary.to_text();
456        assert!(text.contains("Wave 1"));
457        assert!(text.contains("task:1"));
458        assert!(text.contains("src/main.rs"));
459    }
460
461    #[test]
462    fn test_get_previous_summary() {
463        let mut session = SwarmSession::new("test", "tag", "tmux", "/path", 5);
464
465        // No waves yet
466        assert!(session.get_previous_summary().is_none());
467
468        // Add wave with summary
469        let mut wave = WaveState::new(1);
470        wave.summary = Some(WaveSummary {
471            wave_number: 1,
472            tasks_completed: vec!["task:1".to_string()],
473            files_changed: vec![],
474        });
475        session.waves.push(wave);
476
477        let summary = session.get_previous_summary();
478        assert!(summary.is_some());
479        assert!(summary.unwrap().contains("task:1"));
480    }
481
482    #[test]
483    fn test_session_lock_contention() {
484        use tempfile::TempDir;
485
486        // Create a temporary directory for testing
487        let temp_dir = TempDir::new().unwrap();
488        let project_root = temp_dir.path().to_path_buf();
489
490        // Acquire first lock
491        let _lock1 = acquire_session_lock(Some(&project_root), "test-tag")
492            .expect("First lock should succeed");
493
494        // Try to acquire second lock for same tag while first is held
495        let result = acquire_session_lock(Some(&project_root), "test-tag");
496
497        // Verify the second attempt fails and error message mentions "already running"
498        match result {
499            Ok(_) => panic!("Second lock should fail"),
500            Err(e) => {
501                let error_msg = e.to_string();
502                assert!(
503                    error_msg.contains("already running"),
504                    "Error message should mention 'already running', got: {}",
505                    error_msg
506                );
507            }
508        }
509    }
510
511    #[test]
512    fn test_get_current_commit() {
513        let result = get_current_commit();
514
515        // Should return Some(sha) since we're in a git repo
516        assert!(result.is_some(), "Expected Some(sha) in a git repository");
517
518        let sha = result.unwrap();
519
520        // Verify the SHA is 40 characters long (full SHA)
521        assert_eq!(
522            sha.len(),
523            40,
524            "Expected SHA to be 40 characters long, got {}",
525            sha.len()
526        );
527
528        // Verify the SHA contains only hex characters (0-9, a-f)
529        assert!(
530            sha.chars().all(|c| c.is_ascii_hexdigit()),
531            "Expected SHA to contain only hex characters, got: {}",
532            sha
533        );
534    }
535}