ralph_workflow/checkpoint/
validation.rs1use 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#[derive(Debug)]
16pub struct ValidationResult {
17 pub is_valid: bool,
19 pub warnings: Vec<String>,
21 pub errors: Vec<String>,
23}
24
25impl ValidationResult {
26 #[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 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 #[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 #[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
70pub 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
115pub 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
143pub 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 ¤t[..8]
167 )),
168 None => ValidationResult::ok()
169 .with_warning("PROMPT.md not found or unreadable - cannot verify integrity"),
170 }
171}
172
173#[must_use]
177pub fn validate_agent_config(
178 saved_config: &AgentConfigSnapshot,
179 agent_name: &str,
180 registry: &AgentRegistry,
181) -> ValidationResult {
182 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#[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}