ralph_workflow/checkpoint/
validation.rs1use crate::agents::AgentRegistry;
7use crate::checkpoint::state::{calculate_file_checksum, AgentConfigSnapshot, PipelineCheckpoint};
8use crate::config::Config;
9use std::path::Path;
10
11#[derive(Debug)]
13pub struct ValidationResult {
14 pub is_valid: bool,
16 pub warnings: Vec<String>,
18 pub errors: Vec<String>,
20}
21
22impl ValidationResult {
23 pub fn ok() -> Self {
25 Self {
26 is_valid: true,
27 warnings: Vec::new(),
28 errors: Vec::new(),
29 }
30 }
31
32 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 pub fn with_warning(mut self, msg: impl Into<String>) -> Self {
43 self.warnings.push(msg.into());
44 self
45 }
46
47 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
58pub fn validate_checkpoint(
78 checkpoint: &PipelineCheckpoint,
79 current_config: &Config,
80 registry: &AgentRegistry,
81) -> ValidationResult {
82 let mut result = ValidationResult::ok();
83
84 result = result.merge(validate_working_directory(checkpoint));
86
87 result = result.merge(validate_prompt_md(checkpoint));
89
90 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 result = result.merge(validate_iteration_counts(checkpoint, current_config));
104
105 result
109}
110
111pub 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
133pub 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 ¤t[..8]
148 )),
149 None => ValidationResult::ok()
150 .with_warning("PROMPT.md not found or unreadable - cannot verify integrity"),
151 }
152}
153
154pub fn validate_agent_config(
156 saved_config: &AgentConfigSnapshot,
157 agent_name: &str,
158 registry: &AgentRegistry,
159) -> ValidationResult {
160 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 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 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 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
201pub fn validate_iteration_counts(
206 checkpoint: &PipelineCheckpoint,
207 current_config: &Config,
208) -> ValidationResult {
209 let mut result = ValidationResult::ok();
210
211 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 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}