ralph_workflow/checkpoint/
state.rs1use chrono::Local;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::io;
10use std::path::Path;
11
12const AGENT_DIR: &str = ".agent";
14
15const CHECKPOINT_FILE: &str = "checkpoint.json";
17
18fn checkpoint_path() -> String {
25 format!("{AGENT_DIR}/{CHECKPOINT_FILE}")
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum PipelinePhase {
34 Planning,
36 Development,
38 Review,
40 Fix,
42 ReviewAgain,
44 CommitMessage,
46 FinalValidation,
48 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#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct PipelineCheckpoint {
74 pub phase: PipelinePhase,
76 pub iteration: u32,
78 pub total_iterations: u32,
80 pub reviewer_pass: u32,
82 pub total_reviewer_passes: u32,
84 pub timestamp: String,
86 pub developer_agent: String,
88 pub reviewer_agent: String,
90}
91
92impl PipelineCheckpoint {
93 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 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
158pub fn timestamp() -> String {
160 Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
161}
162
163pub 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 fs::create_dir_all(AGENT_DIR)?;
182
183 let checkpoint_path_str = checkpoint_path();
185 let temp_path = format!("{checkpoint_path_str}.tmp");
186
187 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
203pub 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
231pub 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
248pub 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}