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