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    calculate_file_checksum_with_workspace, AgentConfigSnapshot, CheckpointParams, CliArgsSnapshot,
11    PipelineCheckpoint, PipelinePhase, RebaseState,
12};
13use crate::checkpoint::RunContext;
14use crate::config::{Config, ReviewDepth};
15use crate::logger::Logger;
16use crate::reducer::state::{PromptInputsState, PromptPermissionsState};
17use crate::workspace::Workspace;
18use crate::ProcessExecutor;
19use std::sync::Arc;
20
21/// Builder for creating pipeline checkpoints.
22///
23/// Provides a convenient interface for capturing all necessary state
24/// when creating checkpoints during pipeline execution.
25///
26/// # Example
27///
28/// ```ignore
29/// let checkpoint = CheckpointBuilder::new()
30///     .phase(PipelinePhase::Development, 3, 5)
31///     .reviewer_pass(1, 2)
32///     .capture_from_config(&ctx, &registry, "claude", "codex")
33///     .build();
34/// ```
35pub struct CheckpointBuilder {
36    phase: Option<PipelinePhase>,
37    iteration: u32,
38    total_iterations: u32,
39    reviewer_pass: u32,
40    total_reviewer_passes: u32,
41    developer_agent: Option<String>,
42    reviewer_agent: Option<String>,
43    cli_args: Option<CliArgsSnapshot>,
44    developer_agent_config: Option<AgentConfigSnapshot>,
45    reviewer_agent_config: Option<AgentConfigSnapshot>,
46    rebase_state: RebaseState,
47    config_path: Option<std::path::PathBuf>,
48    git_user_name: Option<String>,
49    git_user_email: Option<String>,
50    // Run context for tracking execution lineage and state
51    run_context: Option<RunContext>,
52    // Hardened resume fields
53    execution_history: Option<ExecutionHistory>,
54    prompt_history: Option<std::collections::HashMap<String, crate::prompts::PromptHistoryEntry>>,
55    prompt_inputs: Option<PromptInputsState>,
56    prompt_permissions: PromptPermissionsState,
57    last_substitution_log: Option<crate::prompts::SubstitutionLog>,
58    // Process executor for external process execution
59    executor: Option<Arc<dyn ProcessExecutor>>,
60    // Logging run_id (timestamp-based) for per-run log directory
61    log_run_id: Option<String>,
62}
63
64impl Default for CheckpointBuilder {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl CheckpointBuilder {
71    /// Create a new checkpoint builder with default values.
72    #[must_use]
73    pub fn new() -> Self {
74        Self {
75            phase: None,
76            iteration: 1,
77            total_iterations: 1,
78            reviewer_pass: 0,
79            total_reviewer_passes: 0,
80            developer_agent: None,
81            reviewer_agent: None,
82            cli_args: None,
83            developer_agent_config: None,
84            reviewer_agent_config: None,
85            rebase_state: RebaseState::default(),
86            config_path: None,
87            git_user_name: None,
88            git_user_email: None,
89            run_context: None,
90            execution_history: None,
91            prompt_history: None,
92            prompt_inputs: None,
93            prompt_permissions: PromptPermissionsState::default(),
94            last_substitution_log: None,
95            executor: None,
96            log_run_id: None,
97        }
98    }
99
100    /// Set the phase and iteration information.
101    #[must_use]
102    pub fn phase(self, phase: PipelinePhase, iteration: u32, total_iterations: u32) -> Self {
103        Self {
104            phase: Some(phase),
105            iteration,
106            total_iterations,
107            ..self
108        }
109    }
110
111    /// Set the reviewer pass information.
112    #[must_use]
113    pub fn reviewer_pass(self, pass: u32, total: u32) -> Self {
114        Self {
115            reviewer_pass: pass,
116            total_reviewer_passes: total,
117            ..self
118        }
119    }
120
121    /// Set the agent names.
122    #[must_use]
123    pub fn agents(self, developer: &str, reviewer: &str) -> Self {
124        Self {
125            developer_agent: Some(developer.to_string()),
126            reviewer_agent: Some(reviewer.to_string()),
127            ..self
128        }
129    }
130
131    /// Set the CLI arguments snapshot.
132    #[must_use]
133    pub fn cli_args(self, args: CliArgsSnapshot) -> Self {
134        Self {
135            cli_args: Some(args),
136            ..self
137        }
138    }
139
140    /// Set the last template substitution log for validation and observability.
141    #[must_use]
142    pub fn with_last_substitution_log(self, log: Option<crate::prompts::SubstitutionLog>) -> Self {
143        Self {
144            last_substitution_log: log,
145            ..self
146        }
147    }
148
149    /// Set the developer agent configuration snapshot.
150    #[must_use]
151    pub fn developer_config(self, config: AgentConfigSnapshot) -> Self {
152        Self {
153            developer_agent_config: Some(config),
154            ..self
155        }
156    }
157
158    /// Set the reviewer agent configuration snapshot.
159    #[must_use]
160    pub fn reviewer_config(self, config: AgentConfigSnapshot) -> Self {
161        Self {
162            reviewer_agent_config: Some(config),
163            ..self
164        }
165    }
166
167    /// Set the rebase state.
168    #[must_use]
169    pub fn rebase_state(self, state: RebaseState) -> Self {
170        Self {
171            rebase_state: state,
172            ..self
173        }
174    }
175
176    /// Set the config path.
177    #[must_use]
178    pub fn config_path(self, path: Option<std::path::PathBuf>) -> Self {
179        Self {
180            config_path: path,
181            ..self
182        }
183    }
184
185    /// Set the git user name and email.
186    #[must_use]
187    pub fn git_identity(self, name: Option<&str>, email: Option<&str>) -> Self {
188        Self {
189            git_user_name: name.map(String::from),
190            git_user_email: email.map(String::from),
191            ..self
192        }
193    }
194
195    /// Set the process executor for external process execution.
196    #[must_use]
197    pub fn with_executor(self, executor: Arc<dyn ProcessExecutor>) -> Self {
198        Self {
199            executor: Some(executor),
200            ..self
201        }
202    }
203
204    /// Capture CLI arguments from a Config.
205    #[must_use]
206    pub fn capture_cli_args(self, config: &Config) -> Self {
207        let review_depth_str = Some(review_depth_to_string(config.review_depth).to_string());
208        let snapshot = crate::checkpoint::state::CliArgsSnapshotBuilder::new(
209            config.developer_iters,
210            config.reviewer_reviews,
211            review_depth_str,
212            config.isolation_mode,
213        )
214        .verbosity(config.verbosity as u8)
215        .show_streaming_metrics(config.show_streaming_metrics)
216        .reviewer_json_parser(config.reviewer_json_parser.clone())
217        .build();
218        Self {
219            cli_args: Some(snapshot),
220            ..self
221        }
222    }
223
224    /// Capture all configuration from a `PhaseContext` and `AgentRegistry`.
225    ///
226    /// This is a convenience method that captures CLI args and both agent configs.
227    /// It takes a `PhaseContext` which provides access to config, registry, and agents.
228    #[must_use]
229    pub fn capture_from_context(
230        mut self,
231        config: &Config,
232        registry: &AgentRegistry,
233        developer_name: &str,
234        reviewer_name: &str,
235        logger: &Logger,
236        run_context: &RunContext,
237    ) -> Self {
238        // Store run context (cloned for builder ownership)
239        self.run_context = Some(run_context.clone());
240
241        // Capture CLI args
242        self = self.capture_cli_args(config);
243
244        // Capture developer agent config
245        if let Some(agent_config) = registry.resolve_config(developer_name) {
246            let snapshot = AgentConfigSnapshot::new(
247                developer_name.to_string(),
248                agent_config.cmd.clone(),
249                agent_config.output_flag.clone(),
250                Some(agent_config.yolo_flag.clone()),
251                agent_config.can_commit,
252            )
253            .with_model_override(config.developer_model.clone())
254            .with_provider_override(config.developer_provider.clone())
255            .with_context_level(config.developer_context);
256            self.developer_agent_config = Some(snapshot);
257            self.developer_agent = Some(developer_name.to_string());
258        } else {
259            logger.warn(&format!(
260                "Developer agent '{developer_name}' not found in registry"
261            ));
262        }
263
264        // Capture reviewer agent config
265        if let Some(agent_config) = registry.resolve_config(reviewer_name) {
266            let snapshot = AgentConfigSnapshot::new(
267                reviewer_name.to_string(),
268                agent_config.cmd.clone(),
269                agent_config.output_flag.clone(),
270                Some(agent_config.yolo_flag.clone()),
271                agent_config.can_commit,
272            )
273            .with_model_override(config.reviewer_model.clone())
274            .with_provider_override(config.reviewer_provider.clone())
275            .with_context_level(config.reviewer_context);
276            self.reviewer_agent_config = Some(snapshot);
277            self.reviewer_agent = Some(reviewer_name.to_string());
278        } else {
279            logger.warn(&format!(
280                "Reviewer agent '{reviewer_name}' not found in registry"
281            ));
282        }
283
284        // Capture git identity
285        self.git_user_name = config.git_user_name.clone();
286        self.git_user_email = config.git_user_email.clone();
287
288        self
289    }
290
291    /// Set the executor from a `PhaseContext`.
292    ///
293    /// This is a convenience method that extracts the `executor_arc` from `PhaseContext`
294    /// and sets it for the checkpoint builder.
295    #[must_use]
296    pub fn with_executor_from_context(self, executor_arc: Arc<dyn ProcessExecutor>) -> Self {
297        Self {
298            executor: Some(executor_arc),
299            ..self
300        }
301    }
302
303    /// Attach execution history from a `PhaseContext`.
304    ///
305    /// This method captures the execution history from the phase context
306    /// and attaches it to the checkpoint.
307    #[must_use]
308    pub fn with_execution_history(self, history: ExecutionHistory) -> Self {
309        Self {
310            execution_history: Some(history),
311            ..self
312        }
313    }
314
315    /// Set the entire prompt history from a `HashMap`.
316    ///
317    /// This is used when building checkpoints from reducer-owned `PipelineState::prompt_history`.
318    ///
319    /// # Arguments
320    ///
321    /// * `history` - `HashMap` of prompt keys to `PromptHistoryEntry` values
322    #[must_use]
323    pub fn with_prompt_history(
324        self,
325        history: std::collections::HashMap<String, crate::prompts::PromptHistoryEntry>,
326    ) -> Self {
327        let prompt_history = if history.is_empty() {
328            None
329        } else {
330            Some(history)
331        };
332        Self {
333            prompt_history,
334            ..self
335        }
336    }
337
338    /// Attach reducer-managed prompt input materialization state.
339    ///
340    /// This is used by reducer-driven checkpointing so resumes can avoid repeating
341    /// oversize handling that was already materialized for a given content id and
342    /// consumer signature.
343    #[must_use]
344    pub fn with_prompt_inputs(self, prompt_inputs: PromptInputsState) -> Self {
345        let is_empty = prompt_inputs.planning.is_none()
346            && prompt_inputs.development.is_none()
347            && prompt_inputs.review.is_none()
348            && prompt_inputs.commit.is_none()
349            && prompt_inputs.xsd_retry_last_output.is_none();
350        let prompt_inputs = if is_empty { None } else { Some(prompt_inputs) };
351        Self {
352            prompt_inputs,
353            ..self
354        }
355    }
356
357    /// Set prompt permission state for resume-safe restoration.
358    #[must_use]
359    pub fn with_prompt_permissions(self, prompt_permissions: PromptPermissionsState) -> Self {
360        Self {
361            prompt_permissions,
362            ..self
363        }
364    }
365
366    /// Set the logging `run_id` (timestamp-based) for per-run log directory.
367    ///
368    /// This should be set from the `RunLogContext.run_id()` to ensure resume
369    /// continuity - when resuming, logs will continue in the same directory.
370    #[must_use]
371    pub fn with_log_run_id(self, log_run_id: String) -> Self {
372        Self {
373            log_run_id: Some(log_run_id),
374            ..self
375        }
376    }
377
378    /// Build the checkpoint without workspace.
379    ///
380    /// Returns None if required fields (phase, agent configs) are missing.
381    /// Generates a new `RunContext` if not set.
382    ///
383    /// This method uses CWD-relative file operations for file state capture.
384    /// For pipeline code where a workspace is available, prefer `build_with_workspace()`.
385    #[must_use]
386    pub fn build(self) -> Option<PipelineCheckpoint> {
387        self.build_internal(None)
388    }
389
390    /// Build the checkpoint with workspace-aware file capture.
391    ///
392    /// Returns None if required fields (phase, agent configs) are missing.
393    /// Generates a new `RunContext` if not set.
394    ///
395    /// This method uses the workspace abstraction for file state capture, which is
396    /// the preferred approach for pipeline code. The workspace provides:
397    /// - Explicit path resolution relative to repo root
398    /// - Testability via `MemoryWorkspace` in tests
399    pub fn build_with_workspace(self, workspace: &dyn Workspace) -> Option<PipelineCheckpoint> {
400        self.build_internal(Some(workspace))
401    }
402
403    /// Internal build implementation that handles both workspace and non-workspace cases.
404    fn build_internal(self, workspace: Option<&dyn Workspace>) -> Option<PipelineCheckpoint> {
405        let phase = self.phase?;
406        let developer_agent = self.developer_agent?;
407        let reviewer_agent = self.reviewer_agent?;
408        let cli_args = self.cli_args?;
409        let developer_config = self.developer_agent_config?;
410        let reviewer_config = self.reviewer_agent_config?;
411
412        let git_user_name = self.git_user_name.as_deref();
413        let git_user_email = self.git_user_email.as_deref();
414
415        // Use provided run context or generate a new one
416        let run_context = self.run_context.unwrap_or_default();
417
418        let working_dir = workspace
419            .map(|ws| ws.root().to_string_lossy().to_string())
420            .or_else(crate::checkpoint::current_dir::get_current_dir)
421            .unwrap_or_default();
422
423        let prompt_md_checksum = workspace.and_then(|ws| {
424            calculate_file_checksum_with_workspace(ws, std::path::Path::new("PROMPT.md"))
425        });
426
427        let (config_path, config_checksum) = self.config_path.map_or((None, None), |path| {
428            let path_string = path.to_string_lossy().to_string();
429            let checksum = workspace.and_then(|ws| {
430                let relative = path.strip_prefix(ws.root()).ok().unwrap_or(&path);
431                calculate_file_checksum_with_workspace(ws, relative)
432            });
433            (Some(path_string), checksum)
434        });
435
436        let executor_ref = self.executor.as_ref().map(std::convert::AsRef::as_ref);
437
438        let checkpoint = PipelineCheckpoint {
439            execution_history: self.execution_history,
440            prompt_history: self.prompt_history,
441            prompt_inputs: self.prompt_inputs,
442            prompt_permissions: self.prompt_permissions,
443            last_substitution_log: self.last_substitution_log,
444            log_run_id: self.log_run_id,
445            file_system_state: workspace.map_or_else(
446                || {
447                    Some(FileSystemState::capture_with_optional_executor_impl(
448                        executor_ref,
449                    ))
450                },
451                |ws| {
452                    executor_ref
453                        .map(|executor| FileSystemState::capture_with_workspace(ws, executor))
454                },
455            ),
456            env_snapshot: Some(crate::checkpoint::state::EnvironmentSnapshot::capture_current()),
457            ..PipelineCheckpoint::from_params(CheckpointParams {
458                phase,
459                iteration: self.iteration,
460                total_iterations: self.total_iterations,
461                reviewer_pass: self.reviewer_pass,
462                total_reviewer_passes: self.total_reviewer_passes,
463                developer_agent: &developer_agent,
464                reviewer_agent: &reviewer_agent,
465                cli_args,
466                developer_agent_config: developer_config,
467                reviewer_agent_config: reviewer_config,
468                rebase_state: self.rebase_state,
469                git_user_name,
470                git_user_email,
471                run_id: &run_context.run_id,
472                parent_run_id: run_context.parent_run_id.as_deref(),
473                resume_count: run_context.resume_count,
474                actual_developer_runs: run_context.actual_developer_runs.max(self.iteration),
475                actual_reviewer_runs: run_context.actual_reviewer_runs.max(self.reviewer_pass),
476                working_dir,
477                prompt_md_checksum,
478                config_path,
479                config_checksum,
480            })
481        };
482
483        Some(checkpoint)
484    }
485}
486
487/// Convert `ReviewDepth` to a string representation.
488const fn review_depth_to_string(depth: ReviewDepth) -> &'static str {
489    match depth {
490        ReviewDepth::Standard => "standard",
491        ReviewDepth::Comprehensive => "comprehensive",
492        ReviewDepth::Security => "security",
493        ReviewDepth::Incremental => "incremental",
494    }
495}
496
497#[cfg(test)]
498mod tests;