ralph_workflow/checkpoint/
state.rs

1//! Pipeline checkpoint state and persistence.
2//!
3//! This module contains the checkpoint data structures and file operations
4//! for saving and loading pipeline state.
5
6use chrono::Local;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::io;
10use std::path::Path;
11
12/// Default directory for Ralph's internal files.
13const AGENT_DIR: &str = ".agent";
14
15/// Default checkpoint file name.
16const CHECKPOINT_FILE: &str = "checkpoint.json";
17
18/// Get the checkpoint file path.
19///
20/// By default, the checkpoint is stored in `.agent/checkpoint.json`
21/// relative to the current working directory. This function provides
22/// a single point of control for the checkpoint location, making it
23/// easier to configure or override in the future if needed.
24fn checkpoint_path() -> String {
25    format!("{AGENT_DIR}/{CHECKPOINT_FILE}")
26}
27
28/// Pipeline phases for checkpoint tracking.
29///
30/// These phases represent the major stages of the Ralph pipeline.
31/// Checkpoints are saved at phase boundaries to enable resume functionality.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum PipelinePhase {
34    /// Planning phase (creating PLAN.md)
35    Planning,
36    /// Development/implementation phase
37    Development,
38    /// Review-fix cycles phase (N iterations of review + fix)
39    Review,
40    /// Fix phase (deprecated: kept for backward compatibility with old checkpoints)
41    Fix,
42    /// Verification review phase (deprecated: kept for backward compatibility with old checkpoints)
43    ReviewAgain,
44    /// Commit message generation
45    CommitMessage,
46    /// Final validation phase
47    FinalValidation,
48    /// Pipeline complete
49    Complete,
50}
51
52impl std::fmt::Display for PipelinePhase {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::Planning => write!(f, "Planning"),
56            Self::Development => write!(f, "Development"),
57            Self::Review => write!(f, "Review"),
58            Self::Fix => write!(f, "Fix"),
59            Self::ReviewAgain => write!(f, "Verification Review"),
60            Self::CommitMessage => write!(f, "Commit Message Generation"),
61            Self::FinalValidation => write!(f, "Final Validation"),
62            Self::Complete => write!(f, "Complete"),
63        }
64    }
65}
66
67/// Pipeline checkpoint for resume functionality.
68///
69/// Contains all state needed to resume an interrupted pipeline from
70/// where it left off, including iteration counts, agent names, and
71/// the timestamp when the checkpoint was saved.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct PipelineCheckpoint {
74    /// Current pipeline phase
75    pub phase: PipelinePhase,
76    /// Current iteration number (for developer iterations)
77    pub iteration: u32,
78    /// Total iterations configured
79    pub total_iterations: u32,
80    /// Current reviewer pass number
81    pub reviewer_pass: u32,
82    /// Total reviewer passes configured
83    pub total_reviewer_passes: u32,
84    /// Timestamp when checkpoint was saved
85    pub timestamp: String,
86    /// Developer agent name
87    pub developer_agent: String,
88    /// Reviewer agent name
89    pub reviewer_agent: String,
90}
91
92impl PipelineCheckpoint {
93    /// Create a new checkpoint with the given state.
94    ///
95    /// # Arguments
96    ///
97    /// * `phase` - Current pipeline phase
98    /// * `iteration` - Current developer iteration number
99    /// * `total_iterations` - Total developer iterations configured
100    /// * `reviewer_pass` - Current reviewer pass number
101    /// * `total_reviewer_passes` - Total reviewer passes configured
102    /// * `developer_agent` - Name of the developer agent
103    /// * `reviewer_agent` - Name of the reviewer agent
104    pub fn new(
105        phase: PipelinePhase,
106        iteration: u32,
107        total_iterations: u32,
108        reviewer_pass: u32,
109        total_reviewer_passes: u32,
110        developer_agent: &str,
111        reviewer_agent: &str,
112    ) -> Self {
113        Self {
114            phase,
115            iteration,
116            total_iterations,
117            reviewer_pass,
118            total_reviewer_passes,
119            timestamp: timestamp(),
120            developer_agent: developer_agent.to_string(),
121            reviewer_agent: reviewer_agent.to_string(),
122        }
123    }
124
125    /// Get a human-readable description of the checkpoint.
126    ///
127    /// Returns a string describing the current phase and progress,
128    /// suitable for display to the user when resuming.
129    pub fn description(&self) -> String {
130        match self.phase {
131            PipelinePhase::Planning => {
132                format!(
133                    "Planning phase, iteration {}/{}",
134                    self.iteration, self.total_iterations
135                )
136            }
137            PipelinePhase::Development => {
138                format!(
139                    "Development iteration {}/{}",
140                    self.iteration, self.total_iterations
141                )
142            }
143            PipelinePhase::Review => "Initial review".to_string(),
144            PipelinePhase::Fix => "Applying fixes".to_string(),
145            PipelinePhase::ReviewAgain => {
146                format!(
147                    "Verification review {}/{}",
148                    self.reviewer_pass, self.total_reviewer_passes
149                )
150            }
151            PipelinePhase::CommitMessage => "Commit message generation".to_string(),
152            PipelinePhase::FinalValidation => "Final validation".to_string(),
153            PipelinePhase::Complete => "Pipeline complete".to_string(),
154        }
155    }
156}
157
158/// Get current timestamp in "YYYY-MM-DD HH:MM:SS" format.
159pub fn timestamp() -> String {
160    Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
161}
162
163/// Save a pipeline checkpoint to disk.
164///
165/// Writes the checkpoint atomically by writing to a temp file first,
166/// then renaming to the final path. This prevents corruption if the
167/// process is interrupted during the write.
168///
169/// # Errors
170///
171/// Returns an error if serialization fails or the file cannot be written.
172pub fn save_checkpoint(checkpoint: &PipelineCheckpoint) -> io::Result<()> {
173    let json = serde_json::to_string_pretty(checkpoint).map_err(|e| {
174        io::Error::new(
175            io::ErrorKind::InvalidData,
176            format!("Failed to serialize checkpoint: {e}"),
177        )
178    })?;
179
180    // Ensure the .agent directory exists before attempting to write
181    fs::create_dir_all(AGENT_DIR)?;
182
183    // Write atomically by writing to temp file then renaming
184    let checkpoint_path_str = checkpoint_path();
185    let temp_path = format!("{checkpoint_path_str}.tmp");
186
187    // Ensure temp file is cleaned up even if write or rename fails
188    let write_result = fs::write(&temp_path, &json);
189    if write_result.is_err() {
190        let _ = fs::remove_file(&temp_path);
191        return write_result;
192    }
193
194    let rename_result = fs::rename(&temp_path, &checkpoint_path_str);
195    if rename_result.is_err() {
196        let _ = fs::remove_file(&temp_path);
197        return rename_result;
198    }
199
200    Ok(())
201}
202
203/// Load an existing checkpoint if one exists.
204///
205/// Returns `Ok(Some(checkpoint))` if a valid checkpoint was loaded,
206/// `Ok(None)` if no checkpoint file exists, or an error if the file
207/// exists but cannot be parsed.
208///
209/// # Errors
210///
211/// Returns an error if the checkpoint file exists but cannot be read
212/// or contains invalid JSON.
213pub fn load_checkpoint() -> io::Result<Option<PipelineCheckpoint>> {
214    let checkpoint = checkpoint_path();
215    let path = Path::new(&checkpoint);
216    if !path.exists() {
217        return Ok(None);
218    }
219
220    let content = fs::read_to_string(path)?;
221    let loaded_checkpoint: PipelineCheckpoint = serde_json::from_str(&content).map_err(|e| {
222        io::Error::new(
223            io::ErrorKind::InvalidData,
224            format!("Failed to parse checkpoint: {e}"),
225        )
226    })?;
227
228    Ok(Some(loaded_checkpoint))
229}
230
231/// Delete the checkpoint file.
232///
233/// Called on successful pipeline completion to clean up the checkpoint.
234/// Does nothing if the checkpoint file doesn't exist.
235///
236/// # Errors
237///
238/// Returns an error if the file exists but cannot be deleted.
239pub fn clear_checkpoint() -> io::Result<()> {
240    let checkpoint = checkpoint_path();
241    let path = Path::new(&checkpoint);
242    if path.exists() {
243        fs::remove_file(path)?;
244    }
245    Ok(())
246}
247
248/// Check if a checkpoint exists.
249///
250/// Returns `true` if a checkpoint file exists, `false` otherwise.
251pub fn checkpoint_exists() -> bool {
252    Path::new(&checkpoint_path()).exists()
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use test_helpers::with_temp_cwd;
259
260    #[test]
261    fn test_timestamp_format() {
262        let ts = timestamp();
263        assert!(ts.contains('-'));
264        assert!(ts.contains(':'));
265        assert_eq!(ts.len(), 19);
266    }
267
268    #[test]
269    fn test_pipeline_phase_display() {
270        assert_eq!(format!("{}", PipelinePhase::Planning), "Planning");
271        assert_eq!(format!("{}", PipelinePhase::Development), "Development");
272        assert_eq!(format!("{}", PipelinePhase::Review), "Review");
273        assert_eq!(format!("{}", PipelinePhase::Fix), "Fix");
274        assert_eq!(
275            format!("{}", PipelinePhase::ReviewAgain),
276            "Verification Review"
277        );
278        assert_eq!(
279            format!("{}", PipelinePhase::CommitMessage),
280            "Commit Message Generation"
281        );
282        assert_eq!(
283            format!("{}", PipelinePhase::FinalValidation),
284            "Final Validation"
285        );
286        assert_eq!(format!("{}", PipelinePhase::Complete), "Complete");
287    }
288
289    #[test]
290    fn test_checkpoint_new() {
291        let checkpoint =
292            PipelineCheckpoint::new(PipelinePhase::Development, 2, 5, 0, 2, "claude", "codex");
293
294        assert_eq!(checkpoint.phase, PipelinePhase::Development);
295        assert_eq!(checkpoint.iteration, 2);
296        assert_eq!(checkpoint.total_iterations, 5);
297        assert_eq!(checkpoint.reviewer_pass, 0);
298        assert_eq!(checkpoint.total_reviewer_passes, 2);
299        assert_eq!(checkpoint.developer_agent, "claude");
300        assert_eq!(checkpoint.reviewer_agent, "codex");
301        assert!(!checkpoint.timestamp.is_empty());
302    }
303
304    #[test]
305    fn test_checkpoint_description() {
306        let checkpoint =
307            PipelineCheckpoint::new(PipelinePhase::Development, 3, 5, 0, 2, "claude", "codex");
308        assert_eq!(checkpoint.description(), "Development iteration 3/5");
309
310        let checkpoint =
311            PipelineCheckpoint::new(PipelinePhase::ReviewAgain, 5, 5, 2, 3, "claude", "codex");
312        assert_eq!(checkpoint.description(), "Verification review 2/3");
313    }
314
315    #[test]
316    fn test_checkpoint_save_load() {
317        with_temp_cwd(|_dir| {
318            fs::create_dir_all(".agent").unwrap();
319
320            let checkpoint =
321                PipelineCheckpoint::new(PipelinePhase::Review, 5, 5, 1, 2, "claude", "codex");
322
323            save_checkpoint(&checkpoint).unwrap();
324            assert!(checkpoint_exists());
325
326            let loaded = load_checkpoint()
327                .unwrap()
328                .expect("checkpoint should exist after save_checkpoint");
329            assert_eq!(loaded.phase, PipelinePhase::Review);
330            assert_eq!(loaded.iteration, 5);
331            assert_eq!(loaded.developer_agent, "claude");
332            assert_eq!(loaded.reviewer_agent, "codex");
333        });
334    }
335
336    #[test]
337    fn test_checkpoint_clear() {
338        with_temp_cwd(|_dir| {
339            fs::create_dir_all(".agent").unwrap();
340
341            let checkpoint =
342                PipelineCheckpoint::new(PipelinePhase::Development, 1, 5, 0, 2, "claude", "codex");
343
344            save_checkpoint(&checkpoint).unwrap();
345            assert!(checkpoint_exists());
346
347            clear_checkpoint().unwrap();
348            assert!(!checkpoint_exists());
349        });
350    }
351
352    #[test]
353    fn test_load_checkpoint_nonexistent() {
354        with_temp_cwd(|_dir| {
355            fs::create_dir_all(".agent").unwrap();
356
357            let result = load_checkpoint().unwrap();
358            assert!(result.is_none());
359        });
360    }
361
362    #[test]
363    fn test_checkpoint_serialization() {
364        let checkpoint =
365            PipelineCheckpoint::new(PipelinePhase::Fix, 3, 5, 1, 2, "aider", "opencode");
366
367        let json = serde_json::to_string(&checkpoint).unwrap();
368        assert!(json.contains("Fix"));
369        assert!(json.contains("aider"));
370        assert!(json.contains("opencode"));
371
372        let deserialized: PipelineCheckpoint = serde_json::from_str(&json).unwrap();
373        assert_eq!(deserialized.phase, checkpoint.phase);
374        assert_eq!(deserialized.iteration, checkpoint.iteration);
375    }
376}