Skip to main content

ralph_workflow/checkpoint/
restore.rs

1//! State restoration from checkpoints.
2//!
3//! This module provides functionality to restore pipeline state from a checkpoint,
4//! including CLI arguments and configuration overrides.
5
6use crate::checkpoint::execution_history::ExecutionHistory;
7use crate::checkpoint::state::{PipelineCheckpoint, PipelinePhase, RebaseState};
8use crate::config::Config;
9
10/// Rich context about a resumed session for use in agent prompts.
11///
12/// This struct contains information that helps AI agents understand where
13/// they are in the pipeline when resuming from a checkpoint, enabling them
14/// to provide more contextual and appropriate responses.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ResumeContext {
17    /// The phase being resumed from
18    pub phase: PipelinePhase,
19    /// Current iteration number (for development)
20    pub iteration: u32,
21    /// Total iterations
22    pub total_iterations: u32,
23    /// Current reviewer pass
24    pub reviewer_pass: u32,
25    /// Total reviewer passes
26    pub total_reviewer_passes: u32,
27    /// Number of times this session has been resumed
28    pub resume_count: u32,
29    /// Rebase state if applicable
30    pub rebase_state: RebaseState,
31    /// Run ID for tracing
32    pub run_id: String,
33    /// Captured prompts from the original run for deterministic replay
34    pub prompt_history: Option<std::collections::HashMap<String, String>>,
35    /// Execution history from the checkpoint (if available)
36    pub execution_history: Option<ExecutionHistory>,
37}
38
39impl ResumeContext {
40    /// Display name for the current phase.
41    pub fn phase_name(&self) -> String {
42        match self.phase {
43            PipelinePhase::Rebase => "Rebase".to_string(),
44            PipelinePhase::Planning => "Planning".to_string(),
45            PipelinePhase::Development => format!(
46                "Development iteration {}/{}",
47                self.iteration + 1,
48                self.total_iterations
49            ),
50            PipelinePhase::Review => format!(
51                "Review (pass {}/{})",
52                self.reviewer_pass + 1,
53                self.total_reviewer_passes
54            ),
55            PipelinePhase::CommitMessage => "Commit Message Generation".to_string(),
56            PipelinePhase::FinalValidation => "Final Validation".to_string(),
57            PipelinePhase::Complete => "Complete".to_string(),
58            PipelinePhase::PreRebase => "Pre-Rebase".to_string(),
59            PipelinePhase::PreRebaseConflict => "Pre-Rebase Conflict".to_string(),
60            PipelinePhase::PostRebase => "Post-Rebase".to_string(),
61            PipelinePhase::PostRebaseConflict => "Post-Rebase Conflict".to_string(),
62            PipelinePhase::AwaitingDevFix => "Awaiting Dev Fix".to_string(),
63            PipelinePhase::Interrupted => "Interrupted".to_string(),
64        }
65    }
66}
67
68impl PipelineCheckpoint {
69    /// Extract rich resume context from this checkpoint.
70    ///
71    /// This method creates a `ResumeContext` containing all the information
72    /// needed to generate informative prompts for agents when resuming.
73    pub fn resume_context(&self) -> ResumeContext {
74        ResumeContext {
75            phase: self.phase,
76            iteration: self.iteration,
77            total_iterations: self.total_iterations,
78            reviewer_pass: self.reviewer_pass,
79            total_reviewer_passes: self.total_reviewer_passes,
80            resume_count: self.resume_count,
81            rebase_state: self.rebase_state.clone(),
82            run_id: self.run_id.clone(),
83            prompt_history: self.prompt_history.clone(),
84            execution_history: self.execution_history.clone(),
85        }
86    }
87}
88
89/// Apply checkpoint values to the current config for deterministic resume.
90pub fn apply_checkpoint_to_config(config: &mut Config, checkpoint: &PipelineCheckpoint) {
91    let cli_args = &checkpoint.cli_args;
92
93    // Always restore developer_iters and reviewer_reviews from checkpoint
94    // to ensure exact state restoration, even if zero
95    config.developer_iters = cli_args.developer_iters;
96    config.reviewer_reviews = cli_args.reviewer_reviews;
97
98    // Note: review_depth is stored as a string in the checkpoint
99    // but as an enum in Config. For now, we don't override it.
100    // This could be enhanced to parse the string back to an enum.
101
102    // Apply model overrides if they exist in the checkpoint
103    if let Some(ref model) = checkpoint.developer_agent_config.model_override {
104        config.developer_model = Some(model.clone());
105    }
106    if let Some(ref model) = checkpoint.reviewer_agent_config.model_override {
107        config.reviewer_model = Some(model.clone());
108    }
109
110    // Apply provider overrides if they exist in the checkpoint
111    if let Some(ref provider) = checkpoint.developer_agent_config.provider_override {
112        config.developer_provider = Some(provider.clone());
113    }
114    if let Some(ref provider) = checkpoint.reviewer_agent_config.provider_override {
115        config.reviewer_provider = Some(provider.clone());
116    }
117
118    // Apply context levels if they exist in the checkpoint
119    config.developer_context = checkpoint.developer_agent_config.context_level;
120    config.reviewer_context = checkpoint.reviewer_agent_config.context_level;
121
122    // Apply git identity if it exists in the checkpoint
123    if let Some(ref name) = checkpoint.git_user_name {
124        config.git_user_name = Some(name.clone());
125    }
126    if let Some(ref email) = checkpoint.git_user_email {
127        config.git_user_email = Some(email.clone());
128    }
129
130    // Always restore isolation_mode from checkpoint for exact state restoration
131    config.isolation_mode = cli_args.isolation_mode;
132
133    // Apply verbosity level from checkpoint
134    config.verbosity = crate::config::types::Verbosity::from(cli_args.verbosity);
135
136    // Apply show_streaming_metrics from checkpoint
137    config.show_streaming_metrics = cli_args.show_streaming_metrics;
138
139    // Apply reviewer_json_parser from checkpoint if it exists
140    if let Some(ref parser) = cli_args.reviewer_json_parser {
141        config.reviewer_json_parser = Some(parser.clone());
142    }
143}
144
145/// Restore environment variables from a checkpoint.
146///
147/// Restore safe environment variables from the checkpoint snapshot.
148pub fn restore_environment_from_checkpoint(checkpoint: &PipelineCheckpoint) -> usize {
149    let Some(ref env_snap) = checkpoint.env_snapshot else {
150        return 0;
151    };
152
153    let mut restored = 0;
154
155    // Restore RALPH_* variables (safe only)
156    for (key, value) in &env_snap.ralph_vars {
157        if crate::checkpoint::state::is_sensitive_env_key(key) {
158            continue;
159        }
160        std::env::set_var(key, value);
161        restored += 1;
162    }
163
164    // Restore other relevant variables (safe only)
165    for (key, value) in &env_snap.other_vars {
166        if crate::checkpoint::state::is_sensitive_env_key(key) {
167            continue;
168        }
169        std::env::set_var(key, value);
170        restored += 1;
171    }
172
173    restored
174}
175
176/// Calculate the starting iteration for development phase resume.
177pub fn calculate_start_iteration(checkpoint: &PipelineCheckpoint, max_iterations: u32) -> u32 {
178    match checkpoint.phase {
179        PipelinePhase::Planning | PipelinePhase::Development => {
180            checkpoint.iteration.clamp(1, max_iterations)
181        }
182        // For later phases, development is already complete
183        _ => max_iterations,
184    }
185}
186
187/// Calculate the starting reviewer pass for review phase resume.
188///
189/// When resuming from a checkpoint in the review phase, this determines
190/// which pass to start from based on the checkpoint state.
191///
192/// # Arguments
193///
194/// * `checkpoint` - The checkpoint to calculate from
195/// * `max_passes` - Maximum review passes configured
196///
197/// # Returns
198///
199/// The pass number to start from (1-indexed).
200pub fn calculate_start_reviewer_pass(checkpoint: &PipelineCheckpoint, max_passes: u32) -> u32 {
201    match checkpoint.phase {
202        PipelinePhase::Review => checkpoint.reviewer_pass.clamp(1, max_passes.max(1)),
203        // For earlier phases, start from the beginning
204        PipelinePhase::Planning
205        | PipelinePhase::Development
206        | PipelinePhase::PreRebase
207        | PipelinePhase::PreRebaseConflict => 1,
208        // For later phases, review is already complete
209        _ => max_passes,
210    }
211}
212
213/// Determine if a phase should be skipped based on checkpoint.
214///
215/// Returns true if the checkpoint indicates this phase has already been completed.
216pub fn should_skip_phase(phase: PipelinePhase, checkpoint: &PipelineCheckpoint) -> bool {
217    phase_rank(phase) < phase_rank(checkpoint.phase)
218}
219
220/// Get the rank (position) of a phase in the pipeline.
221///
222/// Lower values indicate earlier phases in the pipeline.
223fn phase_rank(phase: PipelinePhase) -> u32 {
224    match phase {
225        PipelinePhase::Planning => 0,
226        PipelinePhase::Development => 1,
227        PipelinePhase::Review => 2,
228        PipelinePhase::CommitMessage => 3,
229        PipelinePhase::FinalValidation => 4,
230        PipelinePhase::Complete => 5,
231        PipelinePhase::AwaitingDevFix => 6,
232        PipelinePhase::Interrupted => 7,
233        // Pre-rebase phases map to Review rank
234        PipelinePhase::PreRebase | PipelinePhase::PreRebaseConflict => 2,
235        // Rebase phases map between Development and Review
236        PipelinePhase::Rebase | PipelinePhase::PostRebase | PipelinePhase::PostRebaseConflict => 2,
237    }
238}
239#[cfg(test)]
240#[derive(Debug, Clone)]
241pub struct RestoredContext {
242    pub phase: PipelinePhase,
243    pub resume_iteration: u32,
244    pub total_iterations: u32,
245    pub resume_reviewer_pass: u32,
246    pub total_reviewer_passes: u32,
247    pub developer_agent: String,
248    pub reviewer_agent: String,
249    pub cli_args: Option<crate::checkpoint::state::CliArgsSnapshot>,
250}
251
252#[cfg(test)]
253impl RestoredContext {
254    /// Create a restored context from a checkpoint.
255    pub fn from_checkpoint(checkpoint: &PipelineCheckpoint) -> Self {
256        // Determine if CLI args are meaningful (non-default values)
257        let cli_args = if checkpoint.cli_args.developer_iters > 0
258            || checkpoint.cli_args.reviewer_reviews > 0
259        {
260            Some(checkpoint.cli_args.clone())
261        } else {
262            None
263        };
264
265        Self {
266            phase: checkpoint.phase,
267            resume_iteration: checkpoint.iteration,
268            total_iterations: checkpoint.total_iterations,
269            resume_reviewer_pass: checkpoint.reviewer_pass,
270            total_reviewer_passes: checkpoint.total_reviewer_passes,
271            developer_agent: checkpoint.developer_agent.clone(),
272            reviewer_agent: checkpoint.reviewer_agent.clone(),
273            cli_args,
274        }
275    }
276
277    /// Check if we should use checkpoint values for iteration counts.
278    ///
279    /// Returns true if the checkpoint has meaningful CLI args that should
280    /// override the current configuration.
281    pub fn should_use_checkpoint_iterations(&self) -> bool {
282        self.cli_args
283            .as_ref()
284            .is_some_and(|args| args.developer_iters > 0)
285    }
286
287    /// Check if we should use checkpoint values for reviewer counts.
288    pub fn should_use_checkpoint_reviewer_passes(&self) -> bool {
289        self.cli_args
290            .as_ref()
291            .is_some_and(|args| args.reviewer_reviews > 0)
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::checkpoint::state::{
299        AgentConfigSnapshot, CheckpointParams, CliArgsSnapshot, EnvironmentSnapshot, RebaseState,
300    };
301
302    fn make_test_checkpoint(phase: PipelinePhase, iteration: u32, pass: u32) -> PipelineCheckpoint {
303        let cli_args = CliArgsSnapshot::new(5, 3, None, true, 2, false, None);
304        let dev_config =
305            AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
306        let rev_config =
307            AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
308        let run_id = uuid::Uuid::new_v4().to_string();
309
310        PipelineCheckpoint::from_params(CheckpointParams {
311            phase,
312            iteration,
313            total_iterations: 5,
314            reviewer_pass: pass,
315            total_reviewer_passes: 3,
316            developer_agent: "claude",
317            reviewer_agent: "codex",
318            cli_args,
319            developer_agent_config: dev_config,
320            reviewer_agent_config: rev_config,
321            rebase_state: RebaseState::default(),
322            git_user_name: None,
323            git_user_email: None,
324            run_id: &run_id,
325            parent_run_id: None,
326            resume_count: 0,
327            actual_developer_runs: iteration,
328            actual_reviewer_runs: pass,
329            working_dir: "/test/repo".to_string(),
330            prompt_md_checksum: None,
331            config_path: None,
332            config_checksum: None,
333        })
334    }
335
336    #[test]
337    fn test_restored_context_from_checkpoint() {
338        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
339        let context = RestoredContext::from_checkpoint(&checkpoint);
340
341        assert_eq!(context.phase, PipelinePhase::Development);
342        assert_eq!(context.resume_iteration, 3);
343        assert_eq!(context.total_iterations, 5);
344        assert_eq!(context.resume_reviewer_pass, 0);
345        assert_eq!(context.developer_agent, "claude");
346        assert!(context.cli_args.is_some());
347    }
348
349    #[test]
350    fn test_should_use_checkpoint_iterations() {
351        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
352        let context = RestoredContext::from_checkpoint(&checkpoint);
353
354        assert!(context.should_use_checkpoint_iterations());
355    }
356
357    #[test]
358    fn test_calculate_start_iteration_development() {
359        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
360        let start = calculate_start_iteration(&checkpoint, 5);
361        assert_eq!(start, 3);
362    }
363
364    #[test]
365    fn test_calculate_start_iteration_later_phase() {
366        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 1);
367        let start = calculate_start_iteration(&checkpoint, 5);
368        assert_eq!(start, 5); // Development complete
369    }
370
371    #[test]
372    fn test_calculate_start_reviewer_pass() {
373        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 2);
374        let start = calculate_start_reviewer_pass(&checkpoint, 3);
375        assert_eq!(start, 2);
376    }
377
378    #[test]
379    fn test_calculate_start_reviewer_pass_early_phase() {
380        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 0);
381        let start = calculate_start_reviewer_pass(&checkpoint, 3);
382        assert_eq!(start, 1); // Start from beginning
383    }
384
385    #[test]
386    fn test_should_skip_phase() {
387        let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5, 1);
388
389        // Earlier phases should be skipped
390        assert!(should_skip_phase(PipelinePhase::Planning, &checkpoint));
391        assert!(should_skip_phase(PipelinePhase::Development, &checkpoint));
392
393        // Current and later phases should not be skipped
394        assert!(!should_skip_phase(PipelinePhase::Review, &checkpoint));
395        assert!(!should_skip_phase(
396            PipelinePhase::FinalValidation,
397            &checkpoint
398        ));
399    }
400
401    #[test]
402    fn test_resume_context_from_checkpoint() {
403        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3, 1);
404        let resume_ctx = checkpoint.resume_context();
405
406        assert_eq!(resume_ctx.phase, PipelinePhase::Development);
407        assert_eq!(resume_ctx.iteration, 3);
408        assert_eq!(resume_ctx.total_iterations, 5);
409        assert_eq!(resume_ctx.reviewer_pass, 1);
410        assert_eq!(resume_ctx.total_reviewer_passes, 3);
411        assert_eq!(resume_ctx.resume_count, 0);
412        assert_eq!(resume_ctx.run_id, checkpoint.run_id);
413        assert!(resume_ctx.prompt_history.is_none());
414    }
415
416    #[test]
417    fn test_resume_context_phase_name_development() {
418        let ctx = ResumeContext {
419            phase: PipelinePhase::Development,
420            iteration: 2,
421            total_iterations: 5,
422            reviewer_pass: 0,
423            total_reviewer_passes: 3,
424            resume_count: 0,
425            rebase_state: RebaseState::default(),
426            run_id: "test".to_string(),
427            prompt_history: None,
428            execution_history: None,
429        };
430
431        assert_eq!(ctx.phase_name(), "Development iteration 3/5");
432    }
433
434    #[test]
435    fn test_resume_context_phase_name_review() {
436        let ctx = ResumeContext {
437            phase: PipelinePhase::Review,
438            iteration: 5,
439            total_iterations: 5,
440            reviewer_pass: 1,
441            total_reviewer_passes: 3,
442            resume_count: 0,
443            rebase_state: RebaseState::default(),
444            run_id: "test".to_string(),
445            prompt_history: None,
446            execution_history: None,
447        };
448
449        assert_eq!(ctx.phase_name(), "Review (pass 2/3)");
450    }
451
452    #[test]
453    fn test_restore_environment_skips_sensitive_vars() {
454        let mut checkpoint = make_test_checkpoint(PipelinePhase::Development, 1, 0);
455        let mut snapshot = EnvironmentSnapshot::default();
456        snapshot
457            .ralph_vars
458            .insert("RALPH_SAFE_SETTING".to_string(), "ok".to_string());
459        snapshot
460            .ralph_vars
461            .insert("RALPH_API_TOKEN".to_string(), "secret".to_string());
462        snapshot
463            .other_vars
464            .insert("EDITOR".to_string(), "vim".to_string());
465        snapshot
466            .other_vars
467            .insert("GIT_PASSWORD".to_string(), "nope".to_string());
468        checkpoint.env_snapshot = Some(snapshot);
469
470        std::env::remove_var("RALPH_SAFE_SETTING");
471        std::env::remove_var("RALPH_API_TOKEN");
472        std::env::remove_var("EDITOR");
473        std::env::remove_var("GIT_PASSWORD");
474
475        let restored = restore_environment_from_checkpoint(&checkpoint);
476        assert_eq!(restored, 2);
477        assert_eq!(
478            std::env::var("RALPH_SAFE_SETTING").ok().as_deref(),
479            Some("ok")
480        );
481        assert!(std::env::var("RALPH_API_TOKEN").is_err());
482        assert_eq!(std::env::var("EDITOR").ok().as_deref(), Some("vim"));
483        assert!(std::env::var("GIT_PASSWORD").is_err());
484
485        std::env::remove_var("RALPH_SAFE_SETTING");
486        std::env::remove_var("EDITOR");
487    }
488}