Skip to main content

ralph_workflow/checkpoint/
validation.rs

1//! Checkpoint validation for resume functionality.
2//!
3//! This module provides validation for checkpoint state before resuming,
4//! ensuring the environment matches the checkpoint and detecting configuration changes.
5
6use crate::agents::AgentRegistry;
7use crate::checkpoint::state::{calculate_file_checksum, AgentConfigSnapshot, PipelineCheckpoint};
8use crate::config::Config;
9use std::path::Path;
10
11/// Result of checkpoint validation.
12#[derive(Debug)]
13pub struct ValidationResult {
14    /// Whether the checkpoint is valid for resume.
15    pub is_valid: bool,
16    /// Warnings that don't prevent resume but should be shown.
17    pub warnings: Vec<String>,
18    /// Errors that prevent resume.
19    pub errors: Vec<String>,
20}
21
22impl ValidationResult {
23    /// Create a successful validation result with no issues.
24    pub fn ok() -> Self {
25        Self {
26            is_valid: true,
27            warnings: Vec::new(),
28            errors: Vec::new(),
29        }
30    }
31
32    /// Create a validation result with a single error.
33    pub fn error(msg: impl Into<String>) -> Self {
34        Self {
35            is_valid: false,
36            warnings: Vec::new(),
37            errors: vec![msg.into()],
38        }
39    }
40
41    /// Add a warning to the result.
42    pub fn with_warning(mut self, msg: impl Into<String>) -> Self {
43        self.warnings.push(msg.into());
44        self
45    }
46
47    /// Merge another validation result into this one.
48    pub fn merge(mut self, other: ValidationResult) -> Self {
49        if !other.is_valid {
50            self.is_valid = false;
51        }
52        self.warnings.extend(other.warnings);
53        self.errors.extend(other.errors);
54        self
55    }
56}
57
58/// Validate a checkpoint before resuming.
59///
60/// Performs comprehensive validation to ensure the checkpoint can be safely resumed:
61/// - Working directory matches
62/// - PROMPT.md hasn't changed (if checksum available)
63/// - Agent configurations are compatible
64///
65/// Note: File system state validation is handled separately with recovery strategy
66/// in the resume flow (see validate_file_system_state_with_strategy).
67///
68/// # Arguments
69///
70/// * `checkpoint` - The checkpoint to validate
71/// * `current_config` - Current configuration to compare against
72/// * `registry` - Agent registry for agent validation
73///
74/// # Returns
75///
76/// A `ValidationResult` with any warnings or errors found.
77pub fn validate_checkpoint(
78    checkpoint: &PipelineCheckpoint,
79    current_config: &Config,
80    registry: &AgentRegistry,
81) -> ValidationResult {
82    let mut result = ValidationResult::ok();
83
84    // Validate working directory
85    result = result.merge(validate_working_directory(checkpoint));
86
87    // Validate PROMPT.md checksum
88    result = result.merge(validate_prompt_md(checkpoint));
89
90    // Validate agent configurations
91    result = result.merge(validate_agent_config(
92        &checkpoint.developer_agent_config,
93        &checkpoint.developer_agent,
94        registry,
95    ));
96    result = result.merge(validate_agent_config(
97        &checkpoint.reviewer_agent_config,
98        &checkpoint.reviewer_agent,
99        registry,
100    ));
101
102    // Check for iteration count mismatches (warning only)
103    result = result.merge(validate_iteration_counts(checkpoint, current_config));
104
105    // Note: File system state validation is NOT included here because it requires
106    // recovery strategy handling. It's called separately in the resume flow.
107
108    result
109}
110
111/// Validate that the working directory matches the checkpoint.
112pub fn validate_working_directory(checkpoint: &PipelineCheckpoint) -> ValidationResult {
113    if checkpoint.working_dir.is_empty() {
114        return ValidationResult::ok().with_warning(
115            "Checkpoint has no working directory recorded (legacy checkpoint)".to_string(),
116        );
117    }
118
119    let current_dir = std::env::current_dir()
120        .map(|p| p.to_string_lossy().to_string())
121        .unwrap_or_default();
122
123    if current_dir != checkpoint.working_dir {
124        return ValidationResult::error(format!(
125            "Working directory mismatch: checkpoint was created in '{}', but current directory is '{}'",
126            checkpoint.working_dir, current_dir
127        ));
128    }
129
130    ValidationResult::ok()
131}
132
133/// Validate that PROMPT.md hasn't changed since checkpoint.
134pub fn validate_prompt_md(checkpoint: &PipelineCheckpoint) -> ValidationResult {
135    let Some(ref saved_checksum) = checkpoint.prompt_md_checksum else {
136        return ValidationResult::ok()
137            .with_warning("Checkpoint has no PROMPT.md checksum (legacy checkpoint)");
138    };
139
140    let current_checksum = calculate_file_checksum(Path::new("PROMPT.md"));
141
142    match current_checksum {
143        Some(current) if current == *saved_checksum => ValidationResult::ok(),
144        Some(current) => ValidationResult::ok().with_warning(format!(
145            "PROMPT.md has changed since checkpoint was created (checksum: {} -> {})",
146            &saved_checksum[..8],
147            &current[..8]
148        )),
149        None => ValidationResult::ok()
150            .with_warning("PROMPT.md not found or unreadable - cannot verify integrity"),
151    }
152}
153
154/// Validate that an agent configuration matches the current registry.
155pub fn validate_agent_config(
156    saved_config: &AgentConfigSnapshot,
157    agent_name: &str,
158    registry: &AgentRegistry,
159) -> ValidationResult {
160    // Skip validation if the saved config has empty command (legacy/minimal checkpoint)
161    if saved_config.cmd.is_empty() {
162        return ValidationResult::ok();
163    }
164
165    let Some(current_config) = registry.resolve_config(agent_name) else {
166        return ValidationResult::ok().with_warning(format!(
167            "Agent '{}' not found in current registry (may have been removed)",
168            agent_name
169        ));
170    };
171
172    let mut result = ValidationResult::ok();
173
174    // Check command
175    if current_config.cmd != saved_config.cmd {
176        result = result.with_warning(format!(
177            "Agent '{}' command changed: '{}' -> '{}'",
178            agent_name, saved_config.cmd, current_config.cmd
179        ));
180    }
181
182    // Check output flag
183    if current_config.output_flag != saved_config.output_flag {
184        result = result.with_warning(format!(
185            "Agent '{}' output flag changed: '{}' -> '{}'",
186            agent_name, saved_config.output_flag, current_config.output_flag
187        ));
188    }
189
190    // Check can_commit flag
191    if current_config.can_commit != saved_config.can_commit {
192        result = result.with_warning(format!(
193            "Agent '{}' can_commit flag changed: {} -> {}",
194            agent_name, saved_config.can_commit, current_config.can_commit
195        ));
196    }
197
198    result
199}
200
201/// Validate iteration counts between checkpoint and current config.
202///
203/// This is a soft validation - mismatches generate warnings but don't block resume.
204/// The checkpoint values take precedence during resume.
205pub fn validate_iteration_counts(
206    checkpoint: &PipelineCheckpoint,
207    current_config: &Config,
208) -> ValidationResult {
209    let mut result = ValidationResult::ok();
210
211    // Check developer iterations
212    let saved_dev_iters = checkpoint.cli_args.developer_iters;
213    if saved_dev_iters > 0 && saved_dev_iters != current_config.developer_iters {
214        result = result.with_warning(format!(
215            "Developer iterations changed: {} (checkpoint) vs {} (current config). Using checkpoint value.",
216            saved_dev_iters, current_config.developer_iters
217        ));
218    }
219
220    // Check reviewer reviews
221    let saved_rev_reviews = checkpoint.cli_args.reviewer_reviews;
222    if saved_rev_reviews > 0 && saved_rev_reviews != current_config.reviewer_reviews {
223        result = result.with_warning(format!(
224            "Reviewer reviews changed: {} (checkpoint) vs {} (current config). Using checkpoint value.",
225            saved_rev_reviews, current_config.reviewer_reviews
226        ));
227    }
228
229    result
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::checkpoint::state::{CheckpointParams, CliArgsSnapshot, PipelinePhase, RebaseState};
236
237    fn make_test_checkpoint() -> PipelineCheckpoint {
238        let cli_args =
239            CliArgsSnapshot::new(5, 2, "test".to_string(), None, false, true, 2, false, None);
240        let dev_config =
241            AgentConfigSnapshot::new("claude".into(), "claude".into(), "-p".into(), None, true);
242        let rev_config =
243            AgentConfigSnapshot::new("codex".into(), "codex".into(), "-p".into(), None, true);
244        let run_id = uuid::Uuid::new_v4().to_string();
245
246        PipelineCheckpoint::from_params(CheckpointParams {
247            phase: PipelinePhase::Development,
248            iteration: 2,
249            total_iterations: 5,
250            reviewer_pass: 0,
251            total_reviewer_passes: 2,
252            developer_agent: "claude",
253            reviewer_agent: "codex",
254            cli_args,
255            developer_agent_config: dev_config,
256            reviewer_agent_config: rev_config,
257            rebase_state: RebaseState::default(),
258            git_user_name: None,
259            git_user_email: None,
260            run_id: &run_id,
261            parent_run_id: None,
262            resume_count: 0,
263            actual_developer_runs: 2,
264            actual_reviewer_runs: 0,
265        })
266    }
267
268    #[test]
269    fn test_validation_result_ok() {
270        let result = ValidationResult::ok();
271        assert!(result.is_valid);
272        assert!(result.warnings.is_empty());
273        assert!(result.errors.is_empty());
274    }
275
276    #[test]
277    fn test_validation_result_error() {
278        let result = ValidationResult::error("test error");
279        assert!(!result.is_valid);
280        assert!(result.warnings.is_empty());
281        assert_eq!(result.errors.len(), 1);
282        assert_eq!(result.errors[0], "test error");
283    }
284
285    #[test]
286    fn test_validation_result_with_warning() {
287        let result = ValidationResult::ok().with_warning("test warning");
288        assert!(result.is_valid);
289        assert_eq!(result.warnings.len(), 1);
290        assert_eq!(result.warnings[0], "test warning");
291    }
292
293    #[test]
294    fn test_validation_result_merge() {
295        let result1 = ValidationResult::ok().with_warning("warning 1");
296        let result2 = ValidationResult::ok().with_warning("warning 2");
297
298        let merged = result1.merge(result2);
299        assert!(merged.is_valid);
300        assert_eq!(merged.warnings.len(), 2);
301    }
302
303    #[test]
304    fn test_validation_result_merge_with_error() {
305        let result1 = ValidationResult::ok();
306        let result2 = ValidationResult::error("error");
307
308        let merged = result1.merge(result2);
309        assert!(!merged.is_valid);
310        assert_eq!(merged.errors.len(), 1);
311    }
312
313    #[test]
314    fn test_validate_working_directory_empty() {
315        let mut checkpoint = make_test_checkpoint();
316        checkpoint.working_dir = String::new();
317
318        let result = validate_working_directory(&checkpoint);
319        assert!(result.is_valid);
320        assert_eq!(result.warnings.len(), 1);
321        assert!(result.warnings[0].contains("legacy checkpoint"));
322    }
323
324    #[test]
325    fn test_validate_working_directory_mismatch() {
326        let mut checkpoint = make_test_checkpoint();
327        checkpoint.working_dir = "/some/other/directory".to_string();
328
329        let result = validate_working_directory(&checkpoint);
330        assert!(
331            !result.is_valid,
332            "Should fail validation on working_dir mismatch"
333        );
334        assert_eq!(result.errors.len(), 1);
335        assert!(result.errors[0].contains("Working directory mismatch"));
336    }
337
338    #[test]
339    fn test_validate_prompt_md_no_checksum() {
340        let mut checkpoint = make_test_checkpoint();
341        checkpoint.prompt_md_checksum = None;
342
343        let result = validate_prompt_md(&checkpoint);
344        assert!(result.is_valid);
345        assert_eq!(result.warnings.len(), 1);
346        assert!(result.warnings[0].contains("legacy checkpoint"));
347    }
348}