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