Skip to main content

ralph_workflow/app/resume/
validation.rs

1// Checkpoint validation logic for resume functionality.
2// This module handles verifying checkpoint integrity and file system state validation.
3
4/// Result of file system validation.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum ValidationOutcome {
7    /// Validation passed, safe to resume
8    Passed,
9    /// Validation failed, cannot resume
10    Failed(String),
11}
12
13/// Validate file system state when resuming.
14///
15/// This function validates that the current file system state matches
16/// the state captured in the checkpoint. This is part of the hardened
17/// resume feature that ensures idempotent recovery.
18///
19/// Returns a `ValidationOutcome` indicating whether validation passed
20/// or failed with a reason.
21pub(crate) fn validate_file_system_state(
22    file_system_state: &FileSystemState,
23    logger: &Logger,
24    strategy: crate::checkpoint::recovery::RecoveryStrategy,
25    workspace: &dyn Workspace,
26) -> ValidationOutcome {
27    let errors = file_system_state.validate_with_workspace(workspace, None);
28
29    if errors.is_empty() {
30        logger.info("File system state validation passed.");
31        return ValidationOutcome::Passed;
32    }
33
34    logger.warn("File system state validation detected changes:");
35
36    for error in &errors {
37        let (problem, commands) = error.recovery_commands();
38        logger.warn(&format!("  - {error}"));
39        logger.info(&format!("    What's wrong: {problem}"));
40        logger.info("    How to fix:");
41        for cmd in commands {
42            logger.info(&format!("      {cmd}"));
43        }
44    }
45
46    // Handle based on the recovery strategy
47    match strategy {
48        crate::checkpoint::recovery::RecoveryStrategy::Fail => {
49            logger.error("File system state validation failed (strategy: fail).");
50            logger.info("Use --recovery-strategy=auto to attempt automatic recovery.");
51            logger.info("Use --recovery-strategy=force to proceed anyway (not recommended).");
52            ValidationOutcome::Failed(
53                "File system state changed - see errors above or use --recovery-strategy=force to proceed anyway".to_string()
54            )
55        }
56        crate::checkpoint::recovery::RecoveryStrategy::Force => {
57            logger.warn("Proceeding with resume despite file changes (strategy: force).");
58            logger.info("Note: Pipeline behavior may be unpredictable.");
59            ValidationOutcome::Passed
60        }
61        crate::checkpoint::recovery::RecoveryStrategy::Auto => {
62            // Attempt automatic recovery for recoverable errors
63            let (_recovered, remaining) =
64                attempt_auto_recovery(file_system_state, &errors, logger, workspace);
65
66            if remaining.is_empty() {
67                logger.success("Automatic recovery completed successfully.");
68            } else {
69                logger.warn("Some issues could not be automatically recovered:");
70                for error in &remaining {
71                    logger.warn(&format!("  - {error}"));
72                }
73                logger.warn("Proceeding with resume despite unrecovered issues (strategy: auto).");
74                logger.info("Note: Pipeline behavior may be unpredictable.");
75            }
76            ValidationOutcome::Passed
77        }
78    }
79}
80
81/// Attempt automatic recovery from file system state changes.
82///
83/// This function attempts to automatically fix recoverable issues:
84/// - Restores small files from content stored in snapshot
85/// - Warns about unrecoverable issues (large files, git changes)
86///
87/// # Arguments
88///
89/// * `file_system_state` - The file system state from checkpoint
90/// * `errors` - Validation errors that were detected
91/// * `logger` - Logger for output
92///
93/// # Returns
94///
95/// A tuple of (number of issues recovered, remaining errors)
96fn attempt_auto_recovery(
97    file_system_state: &FileSystemState,
98    errors: &[ValidationError],
99    logger: &Logger,
100    workspace: &dyn Workspace,
101) -> (usize, Vec<ValidationError>) {
102    let mut recovered = 0;
103    let mut remaining = Vec::new();
104
105    for error in errors {
106        match attempt_recovery_for_error(file_system_state, error, logger, workspace) {
107            Ok(()) => {
108                recovered += 1;
109                logger.success(&format!("Recovered: {error}"));
110            }
111            Err(e) => {
112                remaining.push(error.clone());
113                logger.warn(&format!("Could not recover: {error} - {e}"));
114            }
115        }
116    }
117
118    (recovered, remaining)
119}
120
121/// Attempt to recover from a single validation error.
122///
123/// # Returns
124///
125/// `Ok(())` if recovery succeeded, `Err(reason)` if it failed.
126fn attempt_recovery_for_error(
127    file_system_state: &FileSystemState,
128    error: &ValidationError,
129    logger: &Logger,
130    workspace: &dyn Workspace,
131) -> Result<(), String> {
132    match error {
133        ValidationError::FileContentChanged { path } => {
134            // Try to restore from snapshot if content is available
135            if let Some(snapshot) = file_system_state.files.get(path) {
136                if let Some(content) = snapshot.get_content() {
137                    workspace
138                        .write(Path::new(path), &content)
139                        .map_err(|e| format!("Failed to write file: {e}"))?;
140                    logger.info(&format!("Restored {path} from checkpoint content."));
141                    return Ok(());
142                }
143            }
144            Err("No content available in snapshot".to_string())
145        }
146        ValidationError::GitHeadChanged { .. } => {
147            // Git state changes are not automatically recoverable
148            // They require user intervention to reset or accept the new state
149            Err("Git HEAD changes require manual intervention".to_string())
150        }
151        ValidationError::GitStateInvalid { .. } => {
152            Err("Git state validation requires manual intervention".to_string())
153        }
154        ValidationError::GitWorkingTreeChanged { .. } => {
155            // Working tree changes are not automatically recoverable
156            Err("Git working tree changes require manual intervention".to_string())
157        }
158        ValidationError::FileMissing { path } => {
159            // Can't recover a missing file unless we have content
160            if let Some(snapshot) = file_system_state.files.get(path) {
161                if let Some(content) = snapshot.get_content() {
162                    workspace
163                        .write(Path::new(path), &content)
164                        .map_err(|e| format!("Failed to write file: {e}"))?;
165                    logger.info(&format!("Restored missing {path} from checkpoint."));
166                    return Ok(());
167                }
168            }
169            Err(format!("Cannot recover missing file {path}"))
170        }
171        ValidationError::FileUnexpectedlyExists { path } => {
172            // Unexpected files should be removed by user
173            Err(format!(
174                "File {path} should not exist - requires manual removal"
175            ))
176        }
177    }
178}
179
180/// Check for in-progress git rebase when resuming.
181///
182/// This function detects if a git rebase is in progress and provides
183/// appropriate guidance to the user.
184pub(crate) fn check_rebase_state_on_resume(checkpoint: &PipelineCheckpoint, logger: &Logger) {
185    // Only check for rebase if we're resuming from a rebase-related phase
186    let is_rebase_phase = matches!(
187        checkpoint.phase,
188        PipelinePhase::PreRebase
189            | PipelinePhase::PreRebaseConflict
190            | PipelinePhase::PostRebase
191            | PipelinePhase::PostRebaseConflict
192    );
193
194    if !is_rebase_phase {
195        return;
196    }
197
198    match rebase_in_progress() {
199        Ok(true) => {
200            logger.warn("A git rebase is currently in progress.");
201            logger.info("The checkpoint indicates you were in a rebase phase.");
202            logger.info("Options:");
203            logger.info("  - Continue: Let ralph complete the rebase process");
204            logger.info("  - Abort manually: Run 'git rebase --abort' then use --resume");
205        }
206        Ok(false) => {
207            // No rebase in progress - this is expected if rebase completed
208            // but checkpoint wasn't cleared (e.g., pipeline was interrupted)
209            logger.info("No git rebase is currently in progress.");
210        }
211        Err(e) => {
212            logger.warn(&format!("Could not check rebase state: {e}"));
213        }
214    }
215}