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