Skip to main content

ralph_workflow/checkpoint/
builder.rs

1//! Checkpoint builder for convenient checkpoint creation.
2//!
3//! This module provides a builder pattern for creating checkpoints
4//! from various contexts in the pipeline.
5
6use crate::agents::AgentRegistry;
7use crate::checkpoint::execution_history::ExecutionHistory;
8use crate::checkpoint::file_state::FileSystemState;
9use crate::checkpoint::state::{
10    AgentConfigSnapshot, CheckpointParams, CliArgsSnapshot, PipelineCheckpoint, PipelinePhase,
11    RebaseState,
12};
13use crate::checkpoint::RunContext;
14use crate::config::{Config, ReviewDepth};
15use crate::executor::ProcessExecutor;
16use crate::logger::Logger;
17use crate::workspace::Workspace;
18use std::sync::Arc;
19
20/// Builder for creating pipeline checkpoints.
21///
22/// Provides a convenient interface for capturing all necessary state
23/// when creating checkpoints during pipeline execution.
24///
25/// # Example
26///
27/// ```ignore
28/// let checkpoint = CheckpointBuilder::new()
29///     .phase(PipelinePhase::Development, 3, 5)
30///     .reviewer_pass(1, 2)
31///     .capture_from_config(&ctx, &registry, "claude", "codex")
32///     .build();
33/// ```
34pub struct CheckpointBuilder {
35    phase: Option<PipelinePhase>,
36    iteration: u32,
37    total_iterations: u32,
38    reviewer_pass: u32,
39    total_reviewer_passes: u32,
40    developer_agent: Option<String>,
41    reviewer_agent: Option<String>,
42    cli_args: Option<CliArgsSnapshot>,
43    developer_agent_config: Option<AgentConfigSnapshot>,
44    reviewer_agent_config: Option<AgentConfigSnapshot>,
45    rebase_state: RebaseState,
46    config_path: Option<std::path::PathBuf>,
47    git_user_name: Option<String>,
48    git_user_email: Option<String>,
49    // Run context for tracking execution lineage and state
50    run_context: Option<RunContext>,
51    // Hardened resume fields
52    execution_history: Option<ExecutionHistory>,
53    prompt_history: Option<std::collections::HashMap<String, String>>,
54    // Optional skip_rebase flag for CLI args capture
55    skip_rebase: Option<bool>,
56    // Process executor for external process execution
57    executor: Option<Arc<dyn ProcessExecutor>>,
58}
59
60impl Default for CheckpointBuilder {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl CheckpointBuilder {
67    /// Create a new checkpoint builder with default values.
68    pub fn new() -> Self {
69        Self {
70            phase: None,
71            iteration: 1,
72            total_iterations: 1,
73            reviewer_pass: 0,
74            total_reviewer_passes: 0,
75            developer_agent: None,
76            reviewer_agent: None,
77            cli_args: None,
78            developer_agent_config: None,
79            reviewer_agent_config: None,
80            rebase_state: RebaseState::default(),
81            config_path: None,
82            git_user_name: None,
83            git_user_email: None,
84            run_context: None,
85            execution_history: None,
86            prompt_history: None,
87            skip_rebase: None,
88            executor: None,
89        }
90    }
91
92    /// Set the phase and iteration information.
93    pub fn phase(mut self, phase: PipelinePhase, iteration: u32, total_iterations: u32) -> Self {
94        self.phase = Some(phase);
95        self.iteration = iteration;
96        self.total_iterations = total_iterations;
97        self
98    }
99
100    /// Set the reviewer pass information.
101    pub fn reviewer_pass(mut self, pass: u32, total: u32) -> Self {
102        self.reviewer_pass = pass;
103        self.total_reviewer_passes = total;
104        self
105    }
106
107    /// Set the agent names.
108    pub fn agents(mut self, developer: &str, reviewer: &str) -> Self {
109        self.developer_agent = Some(developer.to_string());
110        self.reviewer_agent = Some(reviewer.to_string());
111        self
112    }
113
114    /// Set the CLI arguments snapshot.
115    pub fn cli_args(mut self, args: CliArgsSnapshot) -> Self {
116        self.cli_args = Some(args);
117        self
118    }
119
120    /// Set the developer agent configuration snapshot.
121    pub fn developer_config(mut self, config: AgentConfigSnapshot) -> Self {
122        self.developer_agent_config = Some(config);
123        self
124    }
125
126    /// Set the reviewer agent configuration snapshot.
127    pub fn reviewer_config(mut self, config: AgentConfigSnapshot) -> Self {
128        self.reviewer_agent_config = Some(config);
129        self
130    }
131
132    /// Set the rebase state.
133    pub fn rebase_state(mut self, state: RebaseState) -> Self {
134        self.rebase_state = state;
135        self
136    }
137
138    /// Set the config path.
139    pub fn config_path(mut self, path: Option<std::path::PathBuf>) -> Self {
140        self.config_path = path;
141        self
142    }
143
144    /// Set the git user name and email.
145    pub fn git_identity(mut self, name: Option<&str>, email: Option<&str>) -> Self {
146        self.git_user_name = name.map(String::from);
147        self.git_user_email = email.map(String::from);
148        self
149    }
150
151    /// Set the skip_rebase flag for CLI args capture.
152    pub fn skip_rebase(mut self, value: bool) -> Self {
153        self.skip_rebase = Some(value);
154        self
155    }
156
157    /// Set the process executor for external process execution.
158    pub fn with_executor(mut self, executor: Arc<dyn ProcessExecutor>) -> Self {
159        self.executor = Some(executor);
160        self
161    }
162
163    /// Capture CLI arguments from a Config.
164    pub fn capture_cli_args(mut self, config: &Config) -> Self {
165        let review_depth_str = review_depth_to_string(config.review_depth);
166        let skip_rebase = self.skip_rebase.unwrap_or(false);
167
168        let snapshot = crate::checkpoint::state::CliArgsSnapshotBuilder::new(
169            config.developer_iters,
170            config.reviewer_reviews,
171            config.commit_msg.clone(),
172            review_depth_str,
173            skip_rebase,
174            config.isolation_mode,
175        )
176        .verbosity(config.verbosity as u8)
177        .show_streaming_metrics(config.show_streaming_metrics)
178        .reviewer_json_parser(config.reviewer_json_parser.clone())
179        .build();
180        self.cli_args = Some(snapshot);
181        self
182    }
183
184    /// Capture all configuration from a PhaseContext and AgentRegistry.
185    ///
186    /// This is a convenience method that captures CLI args and both agent configs.
187    /// It takes a PhaseContext which provides access to config, registry, and agents.
188    pub fn capture_from_context(
189        mut self,
190        config: &Config,
191        registry: &AgentRegistry,
192        developer_name: &str,
193        reviewer_name: &str,
194        logger: &Logger,
195        run_context: &RunContext,
196    ) -> Self {
197        // Store run context (cloned for builder ownership)
198        self.run_context = Some(run_context.clone());
199
200        // Capture CLI args
201        self = self.capture_cli_args(config);
202
203        // Capture developer agent config
204        if let Some(agent_config) = registry.resolve_config(developer_name) {
205            let snapshot = AgentConfigSnapshot::new(
206                developer_name.to_string(),
207                agent_config.cmd.clone(),
208                agent_config.output_flag.clone(),
209                Some(agent_config.yolo_flag.clone()),
210                agent_config.can_commit,
211            )
212            .with_model_override(config.developer_model.clone())
213            .with_provider_override(config.developer_provider.clone())
214            .with_context_level(config.developer_context);
215            self.developer_agent_config = Some(snapshot);
216            self.developer_agent = Some(developer_name.to_string());
217        } else {
218            logger.warn(&format!(
219                "Developer agent '{}' not found in registry",
220                developer_name
221            ));
222        }
223
224        // Capture reviewer agent config
225        if let Some(agent_config) = registry.resolve_config(reviewer_name) {
226            let snapshot = AgentConfigSnapshot::new(
227                reviewer_name.to_string(),
228                agent_config.cmd.clone(),
229                agent_config.output_flag.clone(),
230                Some(agent_config.yolo_flag.clone()),
231                agent_config.can_commit,
232            )
233            .with_model_override(config.reviewer_model.clone())
234            .with_provider_override(config.reviewer_provider.clone())
235            .with_context_level(config.reviewer_context);
236            self.reviewer_agent_config = Some(snapshot);
237            self.reviewer_agent = Some(reviewer_name.to_string());
238        } else {
239            logger.warn(&format!(
240                "Reviewer agent '{}' not found in registry",
241                reviewer_name
242            ));
243        }
244
245        // Capture git identity
246        self.git_user_name = config.git_user_name.clone();
247        self.git_user_email = config.git_user_email.clone();
248
249        self
250    }
251
252    /// Set the executor from a PhaseContext.
253    ///
254    /// This is a convenience method that extracts the executor_arc from PhaseContext
255    /// and sets it for the checkpoint builder.
256    pub fn with_executor_from_context(mut self, executor_arc: Arc<dyn ProcessExecutor>) -> Self {
257        self.executor = Some(executor_arc);
258        self
259    }
260
261    /// Attach execution history from a PhaseContext.
262    ///
263    /// This method captures the execution history from the phase context
264    /// and attaches it to the checkpoint.
265    pub fn with_execution_history(mut self, history: ExecutionHistory) -> Self {
266        self.execution_history = Some(history);
267        self
268    }
269
270    /// Set the entire prompt history from a HashMap.
271    ///
272    /// This is useful when transferring prompts from a PhaseContext.
273    ///
274    /// # Arguments
275    ///
276    /// * `history` - HashMap of prompt keys to prompt text
277    pub fn with_prompt_history(
278        mut self,
279        history: std::collections::HashMap<String, String>,
280    ) -> Self {
281        self.prompt_history = if history.is_empty() {
282            None
283        } else {
284            Some(history)
285        };
286        self
287    }
288
289    /// Build the checkpoint without workspace.
290    ///
291    /// Returns None if required fields (phase, agent configs) are missing.
292    /// Generates a new RunContext if not set.
293    ///
294    /// This method uses CWD-relative file operations for file state capture.
295    /// For pipeline code where a workspace is available, prefer `build_with_workspace()`.
296    pub fn build(self) -> Option<PipelineCheckpoint> {
297        self.build_internal(None)
298    }
299
300    /// Build the checkpoint with workspace-aware file capture.
301    ///
302    /// Returns None if required fields (phase, agent configs) are missing.
303    /// Generates a new RunContext if not set.
304    ///
305    /// This method uses the workspace abstraction for file state capture, which is
306    /// the preferred approach for pipeline code. The workspace provides:
307    /// - Explicit path resolution relative to repo root
308    /// - Testability via `MemoryWorkspace` in tests
309    pub fn build_with_workspace(self, workspace: &dyn Workspace) -> Option<PipelineCheckpoint> {
310        self.build_internal(Some(workspace))
311    }
312
313    /// Internal build implementation that handles both workspace and non-workspace cases.
314    fn build_internal(self, workspace: Option<&dyn Workspace>) -> Option<PipelineCheckpoint> {
315        let phase = self.phase?;
316        let developer_agent = self.developer_agent?;
317        let reviewer_agent = self.reviewer_agent?;
318        let cli_args = self.cli_args?;
319        let developer_config = self.developer_agent_config?;
320        let reviewer_config = self.reviewer_agent_config?;
321
322        let git_user_name = self.git_user_name.as_deref();
323        let git_user_email = self.git_user_email.as_deref();
324
325        // Use provided run context or generate a new one
326        let run_context = self.run_context.unwrap_or_default();
327
328        let mut checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
329            phase,
330            iteration: self.iteration,
331            total_iterations: self.total_iterations,
332            reviewer_pass: self.reviewer_pass,
333            total_reviewer_passes: self.total_reviewer_passes,
334            developer_agent: &developer_agent,
335            reviewer_agent: &reviewer_agent,
336            cli_args,
337            developer_agent_config: developer_config,
338            reviewer_agent_config: reviewer_config,
339            rebase_state: self.rebase_state,
340            git_user_name,
341            git_user_email,
342            run_id: &run_context.run_id,
343            parent_run_id: run_context.parent_run_id.as_deref(),
344            resume_count: run_context.resume_count,
345            actual_developer_runs: run_context.actual_developer_runs.max(self.iteration),
346            actual_reviewer_runs: run_context.actual_reviewer_runs.max(self.reviewer_pass),
347        });
348
349        if let Some(path) = self.config_path {
350            checkpoint = checkpoint.with_config(Some(path));
351        }
352
353        // Populate execution history
354        checkpoint.execution_history = self.execution_history;
355
356        // Populate prompt history
357        checkpoint.prompt_history = self.prompt_history;
358
359        // Capture and populate file system state
360        // Use workspace-based capture when workspace is available (pipeline code),
361        // fall back to CWD-based capture when not (CLI layer code)
362        let executor_ref = self.executor.as_ref().map(|e| e.as_ref());
363        checkpoint.file_system_state = if let Some(ws) = workspace {
364            let executor = executor_ref.unwrap_or_else(|| {
365                // This is safe because we're using a static reference that lives for the scope
366                // In practice, executor should always be provided when workspace is available
367                static DEFAULT_EXECUTOR: std::sync::LazyLock<crate::executor::RealProcessExecutor> =
368                    std::sync::LazyLock::new(crate::executor::RealProcessExecutor::new);
369                &*DEFAULT_EXECUTOR
370            });
371            Some(FileSystemState::capture_with_workspace(ws, executor))
372        } else {
373            Some(FileSystemState::capture_with_optional_executor_impl(
374                executor_ref,
375            ))
376        };
377
378        // Capture and populate environment snapshot
379        checkpoint.env_snapshot =
380            Some(crate::checkpoint::state::EnvironmentSnapshot::capture_current());
381
382        Some(checkpoint)
383    }
384}
385
386/// Convert ReviewDepth to a string representation.
387fn review_depth_to_string(depth: ReviewDepth) -> Option<String> {
388    match depth {
389        ReviewDepth::Standard => Some("standard".to_string()),
390        ReviewDepth::Comprehensive => Some("comprehensive".to_string()),
391        ReviewDepth::Security => Some("security".to_string()),
392        ReviewDepth::Incremental => Some("incremental".to_string()),
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::review_depth_to_string;
399    use crate::checkpoint::state::{AgentConfigSnapshot, CliArgsSnapshot};
400    use crate::checkpoint::CheckpointBuilder;
401    use crate::checkpoint::PipelinePhase;
402    use crate::config::ReviewDepth;
403
404    #[test]
405    fn test_builder_basic() {
406        let cli_args = CliArgsSnapshot::new(5, 2, "test".into(), None, false, true, 2, false, None);
407        let dev_config =
408            AgentConfigSnapshot::new("dev".into(), "cmd".into(), "-o".into(), None, true);
409        let rev_config =
410            AgentConfigSnapshot::new("rev".into(), "cmd".into(), "-o".into(), None, true);
411
412        let checkpoint = CheckpointBuilder::new()
413            .phase(PipelinePhase::Development, 2, 5)
414            .reviewer_pass(1, 2)
415            .agents("dev", "rev")
416            .cli_args(cli_args)
417            .developer_config(dev_config)
418            .reviewer_config(rev_config)
419            .build()
420            .unwrap();
421
422        assert_eq!(checkpoint.phase, PipelinePhase::Development);
423        assert_eq!(checkpoint.iteration, 2);
424        assert_eq!(checkpoint.total_iterations, 5);
425        assert_eq!(checkpoint.reviewer_pass, 1);
426        assert_eq!(checkpoint.total_reviewer_passes, 2);
427    }
428
429    #[test]
430    fn test_builder_missing_required_field() {
431        // Missing phase - should return None
432        let result = CheckpointBuilder::new().build();
433        assert!(result.is_none());
434    }
435
436    #[test]
437    fn test_review_depth_to_string() {
438        assert_eq!(
439            review_depth_to_string(ReviewDepth::Standard),
440            Some("standard".to_string())
441        );
442        assert_eq!(
443            review_depth_to_string(ReviewDepth::Comprehensive),
444            Some("comprehensive".to_string())
445        );
446        assert_eq!(
447            review_depth_to_string(ReviewDepth::Security),
448            Some("security".to_string())
449        );
450        assert_eq!(
451            review_depth_to_string(ReviewDepth::Incremental),
452            Some("incremental".to_string())
453        );
454    }
455
456    #[test]
457    fn test_builder_with_prompt_history() {
458        let cli_args = CliArgsSnapshot::new(5, 2, "test".into(), None, false, true, 2, false, None);
459        let dev_config =
460            AgentConfigSnapshot::new("dev".into(), "cmd".into(), "-o".into(), None, true);
461        let rev_config =
462            AgentConfigSnapshot::new("rev".into(), "cmd".into(), "-o".into(), None, true);
463
464        let mut prompts = std::collections::HashMap::new();
465        prompts.insert(
466            "development_1".to_string(),
467            "Implement feature X".to_string(),
468        );
469
470        let checkpoint = CheckpointBuilder::new()
471            .phase(PipelinePhase::Development, 2, 5)
472            .reviewer_pass(1, 2)
473            .agents("dev", "rev")
474            .cli_args(cli_args)
475            .developer_config(dev_config)
476            .reviewer_config(rev_config)
477            .with_prompt_history(prompts)
478            .build()
479            .unwrap();
480
481        assert_eq!(checkpoint.phase, PipelinePhase::Development);
482        assert!(checkpoint.prompt_history.is_some());
483        let history = checkpoint.prompt_history.as_ref().unwrap();
484        assert_eq!(history.len(), 1);
485        assert_eq!(
486            history.get("development_1"),
487            Some(&"Implement feature X".to_string())
488        );
489    }
490
491    #[test]
492    fn test_builder_with_prompt_history_multiple() {
493        let cli_args = CliArgsSnapshot::new(5, 2, "test".into(), None, false, true, 2, false, None);
494        let dev_config =
495            AgentConfigSnapshot::new("dev".into(), "cmd".into(), "-o".into(), None, true);
496        let rev_config =
497            AgentConfigSnapshot::new("rev".into(), "cmd".into(), "-o".into(), None, true);
498
499        let mut prompts = std::collections::HashMap::new();
500        prompts.insert(
501            "development_1".to_string(),
502            "Implement feature X".to_string(),
503        );
504        prompts.insert("review_1".to_string(), "Review the changes".to_string());
505
506        let checkpoint = CheckpointBuilder::new()
507            .phase(PipelinePhase::Development, 2, 5)
508            .reviewer_pass(1, 2)
509            .agents("dev", "rev")
510            .cli_args(cli_args)
511            .developer_config(dev_config)
512            .reviewer_config(rev_config)
513            .with_prompt_history(prompts)
514            .build()
515            .unwrap();
516
517        assert!(checkpoint.prompt_history.is_some());
518        let history = checkpoint.prompt_history.as_ref().unwrap();
519        assert_eq!(history.len(), 2);
520        assert_eq!(
521            history.get("development_1"),
522            Some(&"Implement feature X".to_string())
523        );
524        assert_eq!(
525            history.get("review_1"),
526            Some(&"Review the changes".to_string())
527        );
528    }
529
530    // =========================================================================
531    // Workspace-based tests (for testability without real filesystem)
532    // =========================================================================
533
534    #[cfg(feature = "test-utils")]
535    mod workspace_tests {
536        use super::*;
537        use crate::workspace::MemoryWorkspace;
538
539        #[test]
540        fn test_builder_with_workspace_captures_file_state() {
541            // Create a workspace with PROMPT.md file
542            let workspace =
543                MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test prompt content");
544
545            let cli_args =
546                CliArgsSnapshot::new(5, 2, "test".into(), None, false, true, 2, false, None);
547            let dev_config =
548                AgentConfigSnapshot::new("dev".into(), "cmd".into(), "-o".into(), None, true);
549            let rev_config =
550                AgentConfigSnapshot::new("rev".into(), "cmd".into(), "-o".into(), None, true);
551
552            let checkpoint = CheckpointBuilder::new()
553                .phase(PipelinePhase::Development, 2, 5)
554                .reviewer_pass(1, 2)
555                .agents("dev", "rev")
556                .cli_args(cli_args)
557                .developer_config(dev_config)
558                .reviewer_config(rev_config)
559                .build_with_workspace(&workspace)
560                .unwrap();
561
562            // Verify file system state was captured
563            assert!(checkpoint.file_system_state.is_some());
564            let fs_state = checkpoint.file_system_state.as_ref().unwrap();
565
566            // PROMPT.md should be captured
567            assert!(fs_state.files.contains_key("PROMPT.md"));
568            let snapshot = &fs_state.files["PROMPT.md"];
569            assert!(snapshot.exists);
570            assert_eq!(snapshot.size, 21); // "# Test prompt content"
571        }
572
573        #[test]
574        fn test_builder_with_workspace_captures_agent_files() {
575            // Create a workspace with PROMPT.md and agent files
576            let workspace = MemoryWorkspace::new_test()
577                .with_file("PROMPT.md", "# Test prompt")
578                .with_file(".agent/PLAN.md", "# Plan")
579                .with_file(".agent/ISSUES.md", "# Issues");
580
581            let cli_args =
582                CliArgsSnapshot::new(5, 2, "test".into(), None, false, true, 2, false, None);
583            let dev_config =
584                AgentConfigSnapshot::new("dev".into(), "cmd".into(), "-o".into(), None, true);
585            let rev_config =
586                AgentConfigSnapshot::new("rev".into(), "cmd".into(), "-o".into(), None, true);
587
588            let checkpoint = CheckpointBuilder::new()
589                .phase(PipelinePhase::Review, 2, 5)
590                .reviewer_pass(1, 2)
591                .agents("dev", "rev")
592                .cli_args(cli_args)
593                .developer_config(dev_config)
594                .reviewer_config(rev_config)
595                .build_with_workspace(&workspace)
596                .unwrap();
597
598            let fs_state = checkpoint.file_system_state.as_ref().unwrap();
599
600            // Both agent files should be captured
601            assert!(fs_state.files.contains_key(".agent/PLAN.md"));
602            assert!(fs_state.files.contains_key(".agent/ISSUES.md"));
603
604            let plan_snapshot = &fs_state.files[".agent/PLAN.md"];
605            assert!(plan_snapshot.exists);
606
607            let issues_snapshot = &fs_state.files[".agent/ISSUES.md"];
608            assert!(issues_snapshot.exists);
609        }
610    }
611}