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