Skip to main content

ralph_workflow/checkpoint/
state.rs

1//! Pipeline checkpoint state and persistence.
2//!
3//! This module contains the checkpoint data structures and file operations
4//! for saving and loading pipeline state.
5
6use chrono::Local;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::io;
12use std::path::Path;
13
14use crate::workspace::Workspace;
15
16/// Default directory for Ralph's internal files.
17const AGENT_DIR: &str = ".agent";
18
19/// Default checkpoint file name.
20const CHECKPOINT_FILE: &str = "checkpoint.json";
21
22/// Current checkpoint format version.
23///
24/// Increment this when making breaking changes to the checkpoint format.
25/// This allows for future migration logic if needed.
26/// v1: Initial checkpoint format
27/// v2: Added run_id, parent_run_id, resume_count, actual_developer_runs, actual_reviewer_runs
28/// v3: Added execution_history, file_system_state for hardened resume
29const CHECKPOINT_VERSION: u32 = 3;
30
31/// Get the checkpoint file path.
32///
33/// By default, the checkpoint is stored in `.agent/checkpoint.json`
34/// relative to the current working directory. This function provides
35/// a single point of control for the checkpoint location, making it
36/// easier to configure or override in the future if needed.
37fn checkpoint_path() -> String {
38    format!("{AGENT_DIR}/{CHECKPOINT_FILE}")
39}
40
41/// Calculate SHA-256 checksum of a file's contents.
42///
43/// Returns None if the file doesn't exist or cannot be read.
44pub(crate) fn calculate_file_checksum(path: &Path) -> Option<String> {
45    let content = fs::read(path).ok()?;
46    Some(calculate_checksum_from_bytes(&content))
47}
48
49/// Calculate SHA-256 checksum from bytes.
50///
51/// This is the core checksum calculation used by both file-based and
52/// workspace-based checksum functions.
53pub(crate) fn calculate_checksum_from_bytes(content: &[u8]) -> String {
54    let mut hasher = Sha256::new();
55    hasher.update(content);
56    format!("{:x}", hasher.finalize())
57}
58
59/// Snapshot of CLI arguments for exact restoration.
60///
61/// Captures all relevant CLI arguments so that resuming a pipeline
62/// uses the exact same configuration as the original run.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CliArgsSnapshot {
65    /// Number of developer iterations (-D flag)
66    pub developer_iters: u32,
67    /// Number of reviewer reviews (-R flag)
68    pub reviewer_reviews: u32,
69    /// Review depth level (if specified)
70    pub review_depth: Option<String>,
71    /// Whether to skip automatic rebase
72    pub skip_rebase: bool,
73    /// Isolation mode: when false, NOTES.md and ISSUES.md persist between iterations
74    /// Default is true for backward compatibility with v1/v2 checkpoints.
75    #[serde(default = "default_isolation_mode")]
76    pub isolation_mode: bool,
77    /// Verbosity level (0=Quiet, 1=Normal, 2=Verbose, 3=Full, 4=Debug)
78    /// Default is 2 (Verbose) for backward compatibility.
79    #[serde(default = "default_verbosity")]
80    pub verbosity: u8,
81    /// Show streaming quality metrics at the end of agent output
82    /// Default is false for backward compatibility.
83    #[serde(default)]
84    pub show_streaming_metrics: bool,
85    /// JSON parser override for the reviewer agent (claude, codex, gemini, opencode, generic)
86    #[serde(default)]
87    pub reviewer_json_parser: Option<String>,
88}
89
90/// Default value for isolation_mode (true = isolation enabled).
91fn default_isolation_mode() -> bool {
92    true
93}
94
95/// Default value for verbosity (2 = Verbose).
96fn default_verbosity() -> u8 {
97    2
98}
99
100/// Builder for creating [`CliArgsSnapshot`] instances.
101///
102/// Provides a fluent interface for constructing CLI argument snapshots
103/// without exceeding function argument limits.
104pub struct CliArgsSnapshotBuilder {
105    developer_iters: u32,
106    reviewer_reviews: u32,
107    review_depth: Option<String>,
108    skip_rebase: bool,
109    isolation_mode: bool,
110    verbosity: u8,
111    show_streaming_metrics: bool,
112    reviewer_json_parser: Option<String>,
113}
114
115impl CliArgsSnapshotBuilder {
116    /// Create a new builder with required fields.
117    pub fn new(
118        developer_iters: u32,
119        reviewer_reviews: u32,
120        review_depth: Option<String>,
121        skip_rebase: bool,
122        isolation_mode: bool,
123    ) -> Self {
124        Self {
125            developer_iters,
126            reviewer_reviews,
127            review_depth,
128            skip_rebase,
129            isolation_mode,
130            verbosity: 2,
131            show_streaming_metrics: false,
132            reviewer_json_parser: None,
133        }
134    }
135
136    /// Set the verbosity level.
137    pub fn verbosity(mut self, verbosity: u8) -> Self {
138        self.verbosity = verbosity;
139        self
140    }
141
142    /// Set whether to show streaming metrics.
143    pub fn show_streaming_metrics(mut self, show: bool) -> Self {
144        self.show_streaming_metrics = show;
145        self
146    }
147
148    /// Set the reviewer JSON parser override.
149    pub fn reviewer_json_parser(mut self, parser: Option<String>) -> Self {
150        self.reviewer_json_parser = parser;
151        self
152    }
153
154    /// Build the snapshot.
155    pub fn build(self) -> CliArgsSnapshot {
156        CliArgsSnapshot {
157            developer_iters: self.developer_iters,
158            reviewer_reviews: self.reviewer_reviews,
159            review_depth: self.review_depth,
160            skip_rebase: self.skip_rebase,
161            isolation_mode: self.isolation_mode,
162            verbosity: self.verbosity,
163            show_streaming_metrics: self.show_streaming_metrics,
164            reviewer_json_parser: self.reviewer_json_parser,
165        }
166    }
167}
168
169impl CliArgsSnapshot {
170    /// Create a snapshot from CLI argument values.
171    ///
172    /// This is a convenience method for test code.
173    /// For production code, use [`CliArgsSnapshotBuilder`] for better readability.
174    #[cfg(test)]
175    pub fn new(
176        developer_iters: u32,
177        reviewer_reviews: u32,
178        review_depth: Option<String>,
179        skip_rebase: bool,
180        isolation_mode: bool,
181        verbosity: u8,
182        show_streaming_metrics: bool,
183        reviewer_json_parser: Option<String>,
184    ) -> Self {
185        CliArgsSnapshotBuilder::new(
186            developer_iters,
187            reviewer_reviews,
188            review_depth,
189            skip_rebase,
190            isolation_mode,
191        )
192        .verbosity(verbosity)
193        .show_streaming_metrics(show_streaming_metrics)
194        .reviewer_json_parser(reviewer_json_parser)
195        .build()
196    }
197}
198
199/// Snapshot of agent configuration.
200///
201/// Captures the complete agent configuration to ensure
202/// the exact same agent behavior is used when resuming.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct AgentConfigSnapshot {
205    /// Agent name
206    pub name: String,
207    /// Agent command
208    pub cmd: String,
209    /// Output flag for JSON extraction
210    pub output_flag: String,
211    /// YOLO flag (if any)
212    pub yolo_flag: Option<String>,
213    /// Whether this agent can commit
214    pub can_commit: bool,
215    /// Model override (e.g., "-m opencode/glm-4.7-free")
216    /// Default is None for backward compatibility with v1/v2 checkpoints.
217    #[serde(default)]
218    pub model_override: Option<String>,
219    /// Provider override (e.g., "opencode", "anthropic")
220    /// Default is None for backward compatibility with v1/v2 checkpoints.
221    #[serde(default)]
222    pub provider_override: Option<String>,
223    /// Context level (0=minimal, 1=normal)
224    /// Default is 1 (normal context) for backward compatibility with v1/v2 checkpoints.
225    #[serde(default = "default_context_level")]
226    pub context_level: u8,
227}
228
229/// Default value for context_level (1 = normal context).
230fn default_context_level() -> u8 {
231    1
232}
233
234impl AgentConfigSnapshot {
235    /// Create a snapshot from agent configuration.
236    pub fn new(
237        name: String,
238        cmd: String,
239        output_flag: String,
240        yolo_flag: Option<String>,
241        can_commit: bool,
242    ) -> Self {
243        Self {
244            name,
245            cmd,
246            output_flag,
247            yolo_flag,
248            can_commit,
249            model_override: None,
250            provider_override: None,
251            context_level: default_context_level(),
252        }
253    }
254
255    /// Set model override.
256    pub fn with_model_override(mut self, model: Option<String>) -> Self {
257        self.model_override = model;
258        self
259    }
260
261    /// Set provider override.
262    pub fn with_provider_override(mut self, provider: Option<String>) -> Self {
263        self.provider_override = provider;
264        self
265    }
266
267    /// Set context level.
268    pub fn with_context_level(mut self, level: u8) -> Self {
269        self.context_level = level;
270        self
271    }
272}
273
274/// Snapshot of environment variables for idempotent recovery.
275///
276/// Captures environment variables that affect pipeline execution,
277/// particularly RALPH_* variables, to ensure the same configuration
278/// when resuming.
279#[derive(Debug, Clone, Serialize, Deserialize, Default)]
280pub struct EnvironmentSnapshot {
281    /// All RALPH_* environment variables at checkpoint time
282    #[serde(default)]
283    pub ralph_vars: HashMap<String, String>,
284    /// Other relevant environment variables
285    #[serde(default)]
286    pub other_vars: HashMap<String, String>,
287}
288
289impl EnvironmentSnapshot {
290    /// Capture the current environment variables relevant to Ralph.
291    pub fn capture_current() -> Self {
292        let mut ralph_vars = HashMap::new();
293        let mut other_vars = HashMap::new();
294
295        // Capture all RALPH_* environment variables
296        for (key, value) in std::env::vars() {
297            if key.starts_with("RALPH_") {
298                ralph_vars.insert(key, value);
299            }
300        }
301
302        // Capture other relevant variables
303        let relevant_keys = [
304            "EDITOR",
305            "VISUAL",
306            "GIT_AUTHOR_NAME",
307            "GIT_AUTHOR_EMAIL",
308            "GIT_COMMITTER_NAME",
309            "GIT_COMMITTER_EMAIL",
310        ];
311        for key in &relevant_keys {
312            if let Ok(value) = std::env::var(key) {
313                other_vars.insert(key.to_string(), value);
314            }
315        }
316
317        Self {
318            ralph_vars,
319            other_vars,
320        }
321    }
322}
323
324/// Parameters for creating a new checkpoint.
325///
326/// Groups all the parameters needed to create a checkpoint, avoiding
327/// functions with too many individual parameters.
328pub struct CheckpointParams<'a> {
329    /// Current pipeline phase
330    pub phase: PipelinePhase,
331    /// Current developer iteration number
332    pub iteration: u32,
333    /// Total developer iterations configured
334    pub total_iterations: u32,
335    /// Current reviewer pass number
336    pub reviewer_pass: u32,
337    /// Total reviewer passes configured
338    pub total_reviewer_passes: u32,
339    /// Display name of the developer agent
340    pub developer_agent: &'a str,
341    /// Display name of the reviewer agent
342    pub reviewer_agent: &'a str,
343    /// Snapshot of CLI arguments
344    pub cli_args: CliArgsSnapshot,
345    /// Snapshot of developer agent configuration
346    pub developer_agent_config: AgentConfigSnapshot,
347    /// Snapshot of reviewer agent configuration
348    pub reviewer_agent_config: AgentConfigSnapshot,
349    /// Current rebase state
350    pub rebase_state: RebaseState,
351    /// Git user name for commits (if overridden)
352    pub git_user_name: Option<&'a str>,
353    /// Git user email for commits (if overridden)
354    pub git_user_email: Option<&'a str>,
355    /// Unique identifier for this run (UUID v4)
356    pub run_id: &'a str,
357    /// Parent run ID if this is a resumed session
358    pub parent_run_id: Option<&'a str>,
359    /// Number of times this session has been resumed
360    pub resume_count: u32,
361    /// Actual completed developer iterations
362    pub actual_developer_runs: u32,
363    /// Actual completed reviewer passes
364    pub actual_reviewer_runs: u32,
365}
366
367/// Rebase state tracking.
368///
369/// Tracks the state of rebase operations to enable
370/// proper recovery from interruptions during rebase.
371#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
372pub enum RebaseState {
373    /// Rebase not started yet
374    #[default]
375    NotStarted,
376    /// Pre-development rebase in progress
377    PreRebaseInProgress { upstream_branch: String },
378    /// Pre-development rebase completed
379    PreRebaseCompleted { commit_oid: String },
380    /// Post-review rebase in progress
381    PostRebaseInProgress { upstream_branch: String },
382    /// Post-review rebase completed
383    PostRebaseCompleted { commit_oid: String },
384    /// Rebase has conflicts that need resolution
385    HasConflicts { files: Vec<String> },
386    /// Rebase failed
387    Failed { error: String },
388}
389
390/// Pipeline phases for checkpoint tracking.
391///
392/// These phases represent the major stages of the Ralph pipeline.
393/// Checkpoints are saved at phase boundaries to enable resume functionality.
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
395pub enum PipelinePhase {
396    /// Rebase phase (synchronizing with upstream branch)
397    Rebase,
398    /// Planning phase (creating PLAN.md)
399    Planning,
400    /// Development/implementation phase
401    Development,
402    /// Review-fix cycles phase (N iterations of review + fix)
403    Review,
404    /// Fix phase (deprecated: kept for backward compatibility with old checkpoints)
405    Fix,
406    /// Verification review phase (deprecated: kept for backward compatibility with old checkpoints)
407    ReviewAgain,
408    /// Commit message generation
409    CommitMessage,
410    /// Final validation phase
411    FinalValidation,
412    /// Pipeline complete
413    Complete,
414    /// Before initial rebase
415    PreRebase,
416    /// During pre-rebase conflict resolution
417    PreRebaseConflict,
418    /// Before post-review rebase
419    PostRebase,
420    /// During post-review conflict resolution
421    PostRebaseConflict,
422    /// Pipeline was interrupted (e.g., by Ctrl+C)
423    Interrupted,
424}
425
426impl std::fmt::Display for PipelinePhase {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        match self {
429            Self::Rebase => write!(f, "Rebase"),
430            Self::Planning => write!(f, "Planning"),
431            Self::Development => write!(f, "Development"),
432            Self::Review => write!(f, "Review"),
433            Self::Fix => write!(f, "Fix"),
434            Self::ReviewAgain => write!(f, "Verification Review"),
435            Self::CommitMessage => write!(f, "Commit Message Generation"),
436            Self::FinalValidation => write!(f, "Final Validation"),
437            Self::Complete => write!(f, "Complete"),
438            Self::PreRebase => write!(f, "Pre-Rebase"),
439            Self::PreRebaseConflict => write!(f, "Pre-Rebase Conflict"),
440            Self::PostRebase => write!(f, "Post-Rebase"),
441            Self::PostRebaseConflict => write!(f, "Post-Rebase Conflict"),
442            Self::Interrupted => write!(f, "Interrupted"),
443        }
444    }
445}
446
447/// Enhanced pipeline checkpoint for resume functionality.
448///
449/// Contains comprehensive state needed to resume an interrupted pipeline
450/// exactly where it left off, including CLI arguments, agent configurations,
451/// rebase state, and file checksums for validation.
452///
453/// This is inspired by video game save states - capturing the complete
454/// execution context to enable seamless recovery.
455#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct PipelineCheckpoint {
457    /// Checkpoint format version (for future compatibility)
458    pub version: u32,
459
460    // === Core pipeline state ===
461    /// Current pipeline phase
462    pub phase: PipelinePhase,
463    /// Current iteration number (for developer iterations)
464    pub iteration: u32,
465    /// Total iterations configured
466    pub total_iterations: u32,
467    /// Current reviewer pass number
468    pub reviewer_pass: u32,
469    /// Total reviewer passes configured
470    pub total_reviewer_passes: u32,
471
472    // === Metadata ===
473    /// Timestamp when checkpoint was saved
474    pub timestamp: String,
475    /// Developer agent display name
476    pub developer_agent: String,
477    /// Reviewer agent display name
478    pub reviewer_agent: String,
479
480    // === Enhanced state capture ===
481    /// CLI argument snapshot
482    pub cli_args: CliArgsSnapshot,
483    /// Developer agent configuration snapshot
484    pub developer_agent_config: AgentConfigSnapshot,
485    /// Reviewer agent configuration snapshot
486    pub reviewer_agent_config: AgentConfigSnapshot,
487    /// Rebase state tracking
488    pub rebase_state: RebaseState,
489
490    // === Validation data ===
491    /// Path to config file used for this run (if any)
492    pub config_path: Option<String>,
493    /// Checksum of config file (for validation on resume)
494    pub config_checksum: Option<String>,
495    /// Working directory when checkpoint was created
496    pub working_dir: String,
497    /// Checksum of PROMPT.md (for validation on resume)
498    pub prompt_md_checksum: Option<String>,
499
500    // === Additional state for exact restoration ===
501    /// Git user name for commits (if overridden)
502    pub git_user_name: Option<String>,
503    /// Git user email for commits (if overridden)
504    pub git_user_email: Option<String>,
505
506    // === Run identification and lineage (v2+) ===
507    /// Unique identifier for this run (UUID v4)
508    pub run_id: String,
509    /// Parent run ID if this is a resumed session
510    pub parent_run_id: Option<String>,
511    /// Number of times this session has been resumed
512    pub resume_count: u32,
513
514    // === Actual execution state (v2+) ===
515    /// Actual number of developer iterations that completed
516    pub actual_developer_runs: u32,
517    /// Actual number of reviewer passes that completed
518    pub actual_reviewer_runs: u32,
519
520    // === Hardened resume state (v3+) ===
521    /// Execution history tracking for idempotent recovery
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub execution_history: Option<crate::checkpoint::ExecutionHistory>,
524    /// File system state for validation on resume
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub file_system_state: Option<crate::checkpoint::FileSystemState>,
527    /// Stored prompts used during this run
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub prompt_history: Option<std::collections::HashMap<String, String>>,
530    /// Environment snapshot for idempotent recovery
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub env_snapshot: Option<EnvironmentSnapshot>,
533}
534
535impl PipelineCheckpoint {
536    /// Create a new checkpoint with comprehensive state capture.
537    ///
538    /// This is the main constructor for creating checkpoints during pipeline execution.
539    /// It captures all necessary state to enable exact restoration of the pipeline.
540    ///
541    /// # Arguments
542    ///
543    /// * `params` - All checkpoint parameters bundled in a struct
544    pub fn from_params(params: CheckpointParams<'_>) -> Self {
545        // Get current working directory
546        let working_dir = std::env::current_dir()
547            .map(|p| p.to_string_lossy().to_string())
548            .unwrap_or_default();
549
550        // Calculate PROMPT.md checksum if it exists
551        let prompt_md_checksum = calculate_file_checksum(Path::new("PROMPT.md"));
552
553        Self {
554            version: CHECKPOINT_VERSION,
555            phase: params.phase,
556            iteration: params.iteration,
557            total_iterations: params.total_iterations,
558            reviewer_pass: params.reviewer_pass,
559            total_reviewer_passes: params.total_reviewer_passes,
560            timestamp: timestamp(),
561            developer_agent: params.developer_agent.to_string(),
562            reviewer_agent: params.reviewer_agent.to_string(),
563            cli_args: params.cli_args,
564            developer_agent_config: params.developer_agent_config,
565            reviewer_agent_config: params.reviewer_agent_config,
566            rebase_state: params.rebase_state,
567            config_path: None,     // Will be set by caller if needed
568            config_checksum: None, // Will be set by caller if needed
569            working_dir,
570            prompt_md_checksum,
571            git_user_name: params.git_user_name.map(String::from),
572            git_user_email: params.git_user_email.map(String::from),
573            // New v2 fields
574            run_id: params.run_id.to_string(),
575            parent_run_id: params.parent_run_id.map(String::from),
576            resume_count: params.resume_count,
577            actual_developer_runs: params.actual_developer_runs,
578            actual_reviewer_runs: params.actual_reviewer_runs,
579            // New v3 fields - initialize as None, will be populated by caller
580            execution_history: None,
581            file_system_state: None,
582            prompt_history: None,
583            env_snapshot: None,
584        }
585    }
586
587    /// Get a human-readable description of the checkpoint.
588    ///
589    /// Returns a string describing the current phase and progress,
590    /// suitable for display to the user when resuming.
591    pub fn description(&self) -> String {
592        match self.phase {
593            PipelinePhase::Rebase => "Rebase in progress".to_string(),
594            PipelinePhase::Planning => {
595                format!(
596                    "Planning phase, iteration {}/{}",
597                    self.iteration, self.total_iterations
598                )
599            }
600            PipelinePhase::Development => {
601                format!(
602                    "Development iteration {}/{}",
603                    self.iteration, self.total_iterations
604                )
605            }
606            PipelinePhase::Review => "Initial review".to_string(),
607            PipelinePhase::Fix => "Applying fixes".to_string(),
608            PipelinePhase::ReviewAgain => {
609                format!(
610                    "Verification review {}/{}",
611                    self.reviewer_pass, self.total_reviewer_passes
612                )
613            }
614            PipelinePhase::CommitMessage => "Commit message generation".to_string(),
615            PipelinePhase::FinalValidation => "Final validation".to_string(),
616            PipelinePhase::Complete => "Pipeline complete".to_string(),
617            PipelinePhase::PreRebase => "Pre-development rebase".to_string(),
618            PipelinePhase::PreRebaseConflict => "Pre-rebase conflict resolution".to_string(),
619            PipelinePhase::PostRebase => "Post-review rebase".to_string(),
620            PipelinePhase::PostRebaseConflict => "Post-rebase conflict resolution".to_string(),
621            PipelinePhase::Interrupted => {
622                // Provide more detailed information for interrupted state
623                let mut parts = vec!["Interrupted".to_string()];
624
625                // Add context about what phase was interrupted
626                if self.iteration > 0 && self.iteration < self.total_iterations {
627                    parts.push(format!(
628                        "during development (iteration {}/{})",
629                        self.iteration, self.total_iterations
630                    ));
631                } else if self.iteration >= self.total_iterations {
632                    if self.reviewer_pass > 0 {
633                        parts.push(format!(
634                            "during review (pass {}/{})",
635                            self.reviewer_pass, self.total_reviewer_passes
636                        ));
637                    } else {
638                        parts.push("after development phase".to_string());
639                    }
640                } else {
641                    parts.push("during pipeline initialization".to_string());
642                }
643
644                parts.join(" ")
645            }
646        }
647    }
648
649    /// Set the config path and calculate its checksum.
650    pub fn with_config(mut self, path: Option<std::path::PathBuf>) -> Self {
651        if let Some(p) = path {
652            self.config_path = Some(p.to_string_lossy().to_string());
653            self.config_checksum = calculate_file_checksum(&p);
654        }
655        self
656    }
657}
658
659/// Try to load a checkpoint, handling v1, v2, and v3 formats.
660fn load_checkpoint_with_fallback(
661    content: &str,
662) -> Result<PipelineCheckpoint, Box<dyn std::error::Error>> {
663    // Try v3 format first (current)
664    match serde_json::from_str::<PipelineCheckpoint>(content) {
665        Ok(mut checkpoint) => {
666            // Accept v3 (current) or higher
667            if checkpoint.version >= 3 {
668                return Ok(checkpoint);
669            }
670            // v2 or v1 checkpoint parsed successfully as v3 struct - upgrade version
671            // and add v3 defaults
672            checkpoint.version = CHECKPOINT_VERSION;
673            return Ok(checkpoint);
674        }
675        Err(_e) => {
676            // First parse attempt failed, try v2 format below
677        }
678    }
679
680    // If v3 struct parsing failed, try v2 format and migrate to v3
681    #[derive(Debug, Clone, Serialize, Deserialize)]
682    struct V2Checkpoint {
683        version: u32,
684        phase: PipelinePhase,
685        iteration: u32,
686        total_iterations: u32,
687        reviewer_pass: u32,
688        total_reviewer_passes: u32,
689        timestamp: String,
690        developer_agent: String,
691        reviewer_agent: String,
692        cli_args: CliArgsSnapshot,
693        developer_agent_config: AgentConfigSnapshot,
694        reviewer_agent_config: AgentConfigSnapshot,
695        rebase_state: RebaseState,
696        config_path: Option<String>,
697        config_checksum: Option<String>,
698        working_dir: String,
699        prompt_md_checksum: Option<String>,
700        git_user_name: Option<String>,
701        git_user_email: Option<String>,
702        run_id: String,
703        parent_run_id: Option<String>,
704        resume_count: u32,
705        actual_developer_runs: u32,
706        actual_reviewer_runs: u32,
707    }
708
709    if let Ok(v2) = serde_json::from_str::<V2Checkpoint>(content) {
710        // Migrate v2 to v3: add new hardened resume fields (empty defaults)
711        // Note: working_dir from v2 checkpoint is preserved
712        return Ok(PipelineCheckpoint {
713            version: CHECKPOINT_VERSION,
714            phase: v2.phase,
715            iteration: v2.iteration,
716            total_iterations: v2.total_iterations,
717            reviewer_pass: v2.reviewer_pass,
718            total_reviewer_passes: v2.total_reviewer_passes,
719            timestamp: v2.timestamp,
720            developer_agent: v2.developer_agent,
721            reviewer_agent: v2.reviewer_agent,
722            cli_args: v2.cli_args,
723            developer_agent_config: v2.developer_agent_config,
724            reviewer_agent_config: v2.reviewer_agent_config,
725            rebase_state: v2.rebase_state,
726            config_path: v2.config_path,
727            config_checksum: v2.config_checksum,
728            working_dir: v2.working_dir,
729            prompt_md_checksum: v2.prompt_md_checksum,
730            git_user_name: v2.git_user_name,
731            git_user_email: v2.git_user_email,
732            run_id: v2.run_id,
733            parent_run_id: v2.parent_run_id,
734            resume_count: v2.resume_count,
735            actual_developer_runs: v2.actual_developer_runs,
736            actual_reviewer_runs: v2.actual_reviewer_runs,
737            // New v3 fields - use empty defaults for migrated checkpoints
738            execution_history: None,
739            file_system_state: None,
740            prompt_history: None,
741            env_snapshot: None,
742        });
743    }
744
745    // Try v1 format and migrate to v3
746    #[derive(Debug, Clone, Serialize, Deserialize)]
747    struct V1Checkpoint {
748        version: u32,
749        phase: PipelinePhase,
750        iteration: u32,
751        total_iterations: u32,
752        reviewer_pass: u32,
753        total_reviewer_passes: u32,
754        timestamp: String,
755        developer_agent: String,
756        reviewer_agent: String,
757        cli_args: CliArgsSnapshot,
758        developer_agent_config: AgentConfigSnapshot,
759        reviewer_agent_config: AgentConfigSnapshot,
760        rebase_state: RebaseState,
761        config_path: Option<String>,
762        config_checksum: Option<String>,
763        working_dir: String,
764        prompt_md_checksum: Option<String>,
765        git_user_name: Option<String>,
766        git_user_email: Option<String>,
767    }
768
769    if let Ok(v1) = serde_json::from_str::<V1Checkpoint>(content) {
770        // Migrate v1 to v3: generate new run_id, set defaults for new fields
771        let new_run_id = uuid::Uuid::new_v4().to_string();
772        return Ok(PipelineCheckpoint {
773            version: CHECKPOINT_VERSION,
774            phase: v1.phase,
775            iteration: v1.iteration,
776            total_iterations: v1.total_iterations,
777            reviewer_pass: v1.reviewer_pass,
778            total_reviewer_passes: v1.total_reviewer_passes,
779            timestamp: v1.timestamp,
780            developer_agent: v1.developer_agent,
781            reviewer_agent: v1.reviewer_agent,
782            cli_args: v1.cli_args,
783            developer_agent_config: v1.developer_agent_config,
784            reviewer_agent_config: v1.reviewer_agent_config,
785            rebase_state: v1.rebase_state,
786            config_path: v1.config_path,
787            config_checksum: v1.config_checksum,
788            working_dir: v1.working_dir,
789            prompt_md_checksum: v1.prompt_md_checksum,
790            git_user_name: v1.git_user_name,
791            git_user_email: v1.git_user_email,
792            // New v2 fields - use defaults for migrated checkpoints
793            run_id: new_run_id,
794            parent_run_id: None,
795            resume_count: 0,
796            actual_developer_runs: v1.iteration,
797            actual_reviewer_runs: v1.reviewer_pass,
798            // New v3 fields - use empty defaults for migrated checkpoints
799            execution_history: None,
800            file_system_state: None,
801            prompt_history: None,
802            env_snapshot: None,
803        });
804    }
805
806    // Try truly legacy format (pre-v1)
807    #[derive(Debug, Clone, Serialize, Deserialize)]
808    struct LegacyCheckpoint {
809        phase: PipelinePhase,
810        iteration: u32,
811        total_iterations: u32,
812        reviewer_pass: u32,
813        total_reviewer_passes: u32,
814        timestamp: String,
815        developer_agent: String,
816        reviewer_agent: String,
817    }
818
819    if let Ok(legacy) = serde_json::from_str::<LegacyCheckpoint>(content) {
820        let new_run_id = uuid::Uuid::new_v4().to_string();
821        return Ok(PipelineCheckpoint {
822            version: CHECKPOINT_VERSION,
823            phase: legacy.phase,
824            iteration: legacy.iteration,
825            total_iterations: legacy.total_iterations,
826            reviewer_pass: legacy.reviewer_pass,
827            total_reviewer_passes: legacy.total_reviewer_passes,
828            timestamp: legacy.timestamp,
829            developer_agent: legacy.developer_agent.clone(),
830            reviewer_agent: legacy.reviewer_agent.clone(),
831            cli_args: CliArgsSnapshotBuilder::new(0, 0, None, false, true).build(),
832            developer_agent_config: AgentConfigSnapshot::new(
833                legacy.developer_agent.clone(),
834                String::new(),
835                String::new(),
836                None,
837                false,
838            ),
839            reviewer_agent_config: AgentConfigSnapshot::new(
840                legacy.reviewer_agent.clone(),
841                String::new(),
842                String::new(),
843                None,
844                false,
845            ),
846            rebase_state: RebaseState::default(),
847            config_path: None,
848            config_checksum: None,
849            working_dir: String::new(),
850            prompt_md_checksum: None,
851            git_user_name: None,
852            git_user_email: None,
853            run_id: new_run_id,
854            parent_run_id: None,
855            resume_count: 0,
856            actual_developer_runs: legacy.iteration,
857            actual_reviewer_runs: legacy.reviewer_pass,
858            // New v3 fields - use empty defaults for migrated checkpoints
859            execution_history: None,
860            file_system_state: None,
861            prompt_history: None,
862            env_snapshot: None,
863        });
864    }
865
866    Err("Invalid checkpoint format".into())
867}
868
869/// Get current timestamp in "YYYY-MM-DD HH:MM:SS" format.
870pub fn timestamp() -> String {
871    Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
872}
873
874/// Save a pipeline checkpoint to disk.
875///
876/// Writes the checkpoint atomically by writing to a temp file first,
877/// then renaming to the final path. This prevents corruption if the
878/// process is interrupted during the write.
879///
880/// # Errors
881///
882/// Returns an error if serialization fails or the file cannot be written.
883pub fn save_checkpoint(checkpoint: &PipelineCheckpoint) -> io::Result<()> {
884    let json = serde_json::to_string_pretty(checkpoint).map_err(|e| {
885        io::Error::new(
886            io::ErrorKind::InvalidData,
887            format!("Failed to serialize checkpoint: {e}"),
888        )
889    })?;
890
891    // Ensure the .agent directory exists before attempting to write
892    fs::create_dir_all(AGENT_DIR)?;
893
894    // Write atomically by writing to temp file then renaming
895    let checkpoint_path_str = checkpoint_path();
896    let temp_path = format!("{checkpoint_path_str}.tmp");
897
898    // Ensure temp file is cleaned up even if write or rename fails
899    let write_result = fs::write(&temp_path, &json);
900    if write_result.is_err() {
901        let _ = fs::remove_file(&temp_path);
902        return write_result;
903    }
904
905    let rename_result = fs::rename(&temp_path, &checkpoint_path_str);
906    if rename_result.is_err() {
907        let _ = fs::remove_file(&temp_path);
908        return rename_result;
909    }
910
911    Ok(())
912}
913
914/// Load an existing checkpoint if one exists.
915///
916/// Returns `Ok(Some(checkpoint))` if a valid checkpoint was loaded,
917/// `Ok(None)` if no checkpoint file exists, or an error if the file
918/// exists but cannot be parsed.
919///
920/// # Errors
921///
922/// Returns an error if the checkpoint file exists but cannot be read
923/// or contains invalid JSON.
924///
925/// # Note
926///
927/// This function handles both new format (v1) and legacy checkpoints
928/// for backward compatibility. Legacy checkpoints are migrated to the
929/// new format automatically.
930pub fn load_checkpoint() -> io::Result<Option<PipelineCheckpoint>> {
931    let checkpoint = checkpoint_path();
932    let path = Path::new(&checkpoint);
933    if !path.exists() {
934        return Ok(None);
935    }
936
937    let content = fs::read_to_string(path)?;
938    let loaded_checkpoint = load_checkpoint_with_fallback(&content).map_err(|e| {
939        io::Error::new(
940            io::ErrorKind::InvalidData,
941            format!("Failed to parse checkpoint: {e}"),
942        )
943    })?;
944
945    Ok(Some(loaded_checkpoint))
946}
947
948/// Delete the checkpoint file.
949///
950/// Called on successful pipeline completion to clean up the checkpoint.
951/// Does nothing if the checkpoint file doesn't exist.
952///
953/// # Errors
954///
955/// Returns an error if the file exists but cannot be deleted.
956pub fn clear_checkpoint() -> io::Result<()> {
957    let checkpoint = checkpoint_path();
958    let path = Path::new(&checkpoint);
959    if path.exists() {
960        fs::remove_file(path)?;
961    }
962    Ok(())
963}
964
965/// Check if a checkpoint exists.
966///
967/// Returns `true` if a checkpoint file exists, `false` otherwise.
968pub fn checkpoint_exists() -> bool {
969    Path::new(&checkpoint_path()).exists()
970}
971
972// ============================================================================
973// Workspace-based checkpoint functions (for testability with MemoryWorkspace)
974// ============================================================================
975
976/// Calculate SHA-256 checksum of a file using the workspace.
977///
978/// This is the workspace-based version of `calculate_file_checksum`.
979///
980/// # Arguments
981///
982/// * `workspace` - The workspace for file operations
983/// * `path` - Relative path within the workspace
984///
985/// Returns `None` if the file doesn't exist or cannot be read.
986pub fn calculate_file_checksum_with_workspace(
987    workspace: &dyn Workspace,
988    path: &Path,
989) -> Option<String> {
990    let content = workspace.read_bytes(path).ok()?;
991    Some(calculate_checksum_from_bytes(&content))
992}
993
994/// Save a pipeline checkpoint using the workspace.
995///
996/// This is the workspace-based version of `save_checkpoint`.
997///
998/// # Arguments
999///
1000/// * `workspace` - The workspace for file operations
1001/// * `checkpoint` - The checkpoint to save
1002///
1003/// # Note
1004///
1005/// Unlike the original `save_checkpoint`, this version does NOT use atomic
1006/// writes (temp file + rename) since the Workspace trait doesn't support
1007/// rename operations. For production code requiring atomicity, use the
1008/// original `save_checkpoint()`.
1009pub fn save_checkpoint_with_workspace(
1010    workspace: &dyn Workspace,
1011    checkpoint: &PipelineCheckpoint,
1012) -> io::Result<()> {
1013    let json = serde_json::to_string_pretty(checkpoint).map_err(|e| {
1014        io::Error::new(
1015            io::ErrorKind::InvalidData,
1016            format!("Failed to serialize checkpoint: {e}"),
1017        )
1018    })?;
1019
1020    // Ensure the .agent directory exists
1021    workspace.create_dir_all(Path::new(AGENT_DIR))?;
1022
1023    // Write checkpoint file
1024    workspace.write(Path::new(&checkpoint_path()), &json)
1025}
1026
1027/// Load an existing checkpoint using the workspace.
1028///
1029/// This is the workspace-based version of `load_checkpoint`.
1030///
1031/// Returns `Ok(Some(checkpoint))` if a valid checkpoint was loaded,
1032/// `Ok(None)` if no checkpoint file exists, or an error if the file
1033/// exists but cannot be parsed.
1034pub fn load_checkpoint_with_workspace(
1035    workspace: &dyn Workspace,
1036) -> io::Result<Option<PipelineCheckpoint>> {
1037    let checkpoint_path_str = checkpoint_path();
1038    let checkpoint_file = Path::new(&checkpoint_path_str);
1039
1040    if !workspace.exists(checkpoint_file) {
1041        return Ok(None);
1042    }
1043
1044    let content = workspace.read(checkpoint_file)?;
1045    let loaded_checkpoint = load_checkpoint_with_fallback(&content).map_err(|e| {
1046        io::Error::new(
1047            io::ErrorKind::InvalidData,
1048            format!("Failed to parse checkpoint: {e}"),
1049        )
1050    })?;
1051
1052    Ok(Some(loaded_checkpoint))
1053}
1054
1055/// Delete the checkpoint file using the workspace.
1056///
1057/// This is the workspace-based version of `clear_checkpoint`.
1058///
1059/// Does nothing if the checkpoint file doesn't exist.
1060pub fn clear_checkpoint_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
1061    let checkpoint_path_str = checkpoint_path();
1062    let checkpoint_file = Path::new(&checkpoint_path_str);
1063
1064    if workspace.exists(checkpoint_file) {
1065        workspace.remove(checkpoint_file)?;
1066    }
1067    Ok(())
1068}
1069
1070/// Check if a checkpoint exists using the workspace.
1071///
1072/// This is the workspace-based version of `checkpoint_exists`.
1073pub fn checkpoint_exists_with_workspace(workspace: &dyn Workspace) -> bool {
1074    workspace.exists(Path::new(&checkpoint_path()))
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080
1081    // =========================================================================
1082    // Workspace-based tests (for testability without real filesystem)
1083    // =========================================================================
1084
1085    #[cfg(feature = "test-utils")]
1086    mod workspace_tests {
1087        use super::*;
1088        use crate::workspace::MemoryWorkspace;
1089        use std::path::Path;
1090
1091        /// Helper function to create a checkpoint for workspace tests.
1092        fn make_test_checkpoint_for_workspace(
1093            phase: PipelinePhase,
1094            iteration: u32,
1095        ) -> PipelineCheckpoint {
1096            let cli_args = CliArgsSnapshot::new(5, 2, None, false, true, 2, false, None);
1097            let dev_config =
1098                AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
1099            let rev_config =
1100                AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
1101            let run_id = uuid::Uuid::new_v4().to_string();
1102            PipelineCheckpoint::from_params(CheckpointParams {
1103                phase,
1104                iteration,
1105                total_iterations: 5,
1106                reviewer_pass: 0,
1107                total_reviewer_passes: 2,
1108                developer_agent: "claude",
1109                reviewer_agent: "codex",
1110                cli_args,
1111                developer_agent_config: dev_config,
1112                reviewer_agent_config: rev_config,
1113                rebase_state: RebaseState::default(),
1114                git_user_name: None,
1115                git_user_email: None,
1116                run_id: &run_id,
1117                parent_run_id: None,
1118                resume_count: 0,
1119                actual_developer_runs: iteration,
1120                actual_reviewer_runs: 0,
1121            })
1122        }
1123
1124        #[test]
1125        fn test_calculate_file_checksum_with_workspace() {
1126            let workspace = MemoryWorkspace::new_test().with_file("test.txt", "test content");
1127
1128            let checksum =
1129                calculate_file_checksum_with_workspace(&workspace, Path::new("test.txt"));
1130            assert!(checksum.is_some());
1131
1132            // Same content should give same checksum
1133            let workspace2 = MemoryWorkspace::new_test().with_file("other.txt", "test content");
1134            let checksum2 =
1135                calculate_file_checksum_with_workspace(&workspace2, Path::new("other.txt"));
1136            assert_eq!(checksum, checksum2);
1137        }
1138
1139        #[test]
1140        fn test_calculate_file_checksum_with_workspace_different_content() {
1141            let workspace1 = MemoryWorkspace::new_test().with_file("test.txt", "content A");
1142            let workspace2 = MemoryWorkspace::new_test().with_file("test.txt", "content B");
1143
1144            let checksum1 =
1145                calculate_file_checksum_with_workspace(&workspace1, Path::new("test.txt"));
1146            let checksum2 =
1147                calculate_file_checksum_with_workspace(&workspace2, Path::new("test.txt"));
1148
1149            assert!(checksum1.is_some());
1150            assert!(checksum2.is_some());
1151            assert_ne!(checksum1, checksum2);
1152        }
1153
1154        #[test]
1155        fn test_calculate_file_checksum_with_workspace_nonexistent() {
1156            let workspace = MemoryWorkspace::new_test();
1157
1158            let checksum =
1159                calculate_file_checksum_with_workspace(&workspace, Path::new("nonexistent.txt"));
1160            assert!(checksum.is_none());
1161        }
1162
1163        #[test]
1164        fn test_save_checkpoint_with_workspace() {
1165            let workspace = MemoryWorkspace::new_test();
1166            let checkpoint = make_test_checkpoint_for_workspace(PipelinePhase::Development, 2);
1167
1168            save_checkpoint_with_workspace(&workspace, &checkpoint).unwrap();
1169
1170            assert!(workspace.exists(Path::new(".agent/checkpoint.json")));
1171        }
1172
1173        #[test]
1174        fn test_checkpoint_exists_with_workspace() {
1175            let workspace = MemoryWorkspace::new_test();
1176
1177            assert!(!checkpoint_exists_with_workspace(&workspace));
1178
1179            let checkpoint = make_test_checkpoint_for_workspace(PipelinePhase::Development, 1);
1180            save_checkpoint_with_workspace(&workspace, &checkpoint).unwrap();
1181
1182            assert!(checkpoint_exists_with_workspace(&workspace));
1183        }
1184
1185        #[test]
1186        fn test_load_checkpoint_with_workspace_nonexistent() {
1187            let workspace = MemoryWorkspace::new_test();
1188
1189            let result = load_checkpoint_with_workspace(&workspace).unwrap();
1190            assert!(result.is_none());
1191        }
1192
1193        #[test]
1194        fn test_save_and_load_checkpoint_with_workspace() {
1195            let workspace = MemoryWorkspace::new_test();
1196            let checkpoint = make_test_checkpoint_for_workspace(PipelinePhase::Review, 5);
1197
1198            save_checkpoint_with_workspace(&workspace, &checkpoint).unwrap();
1199
1200            let loaded = load_checkpoint_with_workspace(&workspace)
1201                .unwrap()
1202                .expect("checkpoint should exist");
1203
1204            assert_eq!(loaded.phase, PipelinePhase::Review);
1205            assert_eq!(loaded.iteration, 5);
1206            assert_eq!(loaded.developer_agent, "claude");
1207            assert_eq!(loaded.reviewer_agent, "codex");
1208        }
1209
1210        #[test]
1211        fn test_clear_checkpoint_with_workspace() {
1212            let workspace = MemoryWorkspace::new_test();
1213            let checkpoint = make_test_checkpoint_for_workspace(PipelinePhase::Development, 1);
1214
1215            save_checkpoint_with_workspace(&workspace, &checkpoint).unwrap();
1216            assert!(checkpoint_exists_with_workspace(&workspace));
1217
1218            clear_checkpoint_with_workspace(&workspace).unwrap();
1219            assert!(!checkpoint_exists_with_workspace(&workspace));
1220        }
1221
1222        #[test]
1223        fn test_clear_checkpoint_with_workspace_nonexistent() {
1224            let workspace = MemoryWorkspace::new_test();
1225
1226            // Should not error when checkpoint doesn't exist
1227            clear_checkpoint_with_workspace(&workspace).unwrap();
1228        }
1229
1230        #[test]
1231        fn test_load_checkpoint_with_workspace_preserves_working_dir() {
1232            // Test that loading a v1 checkpoint preserves the working_dir field from JSON
1233            let json = r#"{
1234                "version": 1,
1235                "phase": "Development",
1236                "iteration": 1,
1237                "total_iterations": 1,
1238                "reviewer_pass": 0,
1239                "total_reviewer_passes": 0,
1240                "timestamp": "2024-01-01 12:00:00",
1241                "developer_agent": "test-agent",
1242                "reviewer_agent": "test-agent",
1243                "cli_args": {
1244                    "developer_iters": 1,
1245                    "reviewer_reviews": 0,
1246                    "commit_msg": "",
1247                    "review_depth": null,
1248                    "skip_rebase": false
1249                },
1250                "developer_agent_config": {
1251                    "name": "test-agent",
1252                    "cmd": "echo",
1253                    "output_flag": "",
1254                    "yolo_flag": null,
1255                    "can_commit": false,
1256                    "model_override": null,
1257                    "provider_override": null,
1258                    "context_level": 1
1259                },
1260                "reviewer_agent_config": {
1261                    "name": "test-agent",
1262                    "cmd": "echo",
1263                    "output_flag": "",
1264                    "yolo_flag": null,
1265                    "can_commit": false,
1266                    "model_override": null,
1267                    "provider_override": null,
1268                    "context_level": 1
1269                },
1270                "rebase_state": "NotStarted",
1271                "config_path": null,
1272                "config_checksum": null,
1273                "working_dir": "/some/other/directory",
1274                "prompt_md_checksum": null,
1275                "git_user_name": null,
1276                "git_user_email": null
1277            }"#;
1278
1279            let workspace = MemoryWorkspace::new_test().with_file(".agent/checkpoint.json", json);
1280
1281            let loaded = load_checkpoint_with_workspace(&workspace)
1282                .unwrap()
1283                .expect("should load checkpoint");
1284            assert_eq!(
1285                loaded.working_dir, "/some/other/directory",
1286                "working_dir should be preserved from JSON"
1287            );
1288        }
1289    }
1290
1291    // =========================================================================
1292    // Original tests using real filesystem (kept for backward compatibility)
1293    // =========================================================================
1294
1295    /// Helper function to create a checkpoint for testing.
1296    fn make_test_checkpoint(phase: PipelinePhase, iteration: u32) -> PipelineCheckpoint {
1297        let cli_args = CliArgsSnapshot::new(5, 2, None, false, true, 2, false, None);
1298        let dev_config =
1299            AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
1300        let rev_config =
1301            AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
1302        let run_id = uuid::Uuid::new_v4().to_string();
1303        PipelineCheckpoint::from_params(CheckpointParams {
1304            phase,
1305            iteration,
1306            total_iterations: 5,
1307            reviewer_pass: 0,
1308            total_reviewer_passes: 2,
1309            developer_agent: "claude",
1310            reviewer_agent: "codex",
1311            cli_args,
1312            developer_agent_config: dev_config,
1313            reviewer_agent_config: rev_config,
1314            rebase_state: RebaseState::default(),
1315            git_user_name: None,
1316            git_user_email: None,
1317            run_id: &run_id,
1318            parent_run_id: None,
1319            resume_count: 0,
1320            actual_developer_runs: iteration,
1321            actual_reviewer_runs: 0,
1322        })
1323    }
1324
1325    #[test]
1326    fn test_timestamp_format() {
1327        let ts = timestamp();
1328        assert!(ts.contains('-'));
1329        assert!(ts.contains(':'));
1330        assert_eq!(ts.len(), 19);
1331    }
1332
1333    #[test]
1334    fn test_pipeline_phase_display() {
1335        assert_eq!(format!("{}", PipelinePhase::Rebase), "Rebase");
1336        assert_eq!(format!("{}", PipelinePhase::Planning), "Planning");
1337        assert_eq!(format!("{}", PipelinePhase::Development), "Development");
1338        assert_eq!(format!("{}", PipelinePhase::Review), "Review");
1339        assert_eq!(format!("{}", PipelinePhase::Fix), "Fix");
1340        assert_eq!(
1341            format!("{}", PipelinePhase::ReviewAgain),
1342            "Verification Review"
1343        );
1344        assert_eq!(
1345            format!("{}", PipelinePhase::CommitMessage),
1346            "Commit Message Generation"
1347        );
1348        assert_eq!(
1349            format!("{}", PipelinePhase::FinalValidation),
1350            "Final Validation"
1351        );
1352        assert_eq!(format!("{}", PipelinePhase::Complete), "Complete");
1353        assert_eq!(format!("{}", PipelinePhase::PreRebase), "Pre-Rebase");
1354        assert_eq!(
1355            format!("{}", PipelinePhase::PreRebaseConflict),
1356            "Pre-Rebase Conflict"
1357        );
1358        assert_eq!(format!("{}", PipelinePhase::PostRebase), "Post-Rebase");
1359        assert_eq!(
1360            format!("{}", PipelinePhase::PostRebaseConflict),
1361            "Post-Rebase Conflict"
1362        );
1363        assert_eq!(format!("{}", PipelinePhase::Interrupted), "Interrupted");
1364    }
1365
1366    #[test]
1367    fn test_checkpoint_from_params() {
1368        let cli_args = CliArgsSnapshot::new(5, 2, None, false, true, 2, false, None);
1369        let dev_config =
1370            AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
1371        let rev_config =
1372            AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
1373        let run_id = uuid::Uuid::new_v4().to_string();
1374        let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1375            phase: PipelinePhase::Development,
1376            iteration: 2,
1377            total_iterations: 5,
1378            reviewer_pass: 0,
1379            total_reviewer_passes: 2,
1380            developer_agent: "claude",
1381            reviewer_agent: "codex",
1382            cli_args,
1383            developer_agent_config: dev_config,
1384            reviewer_agent_config: rev_config,
1385            rebase_state: RebaseState::default(),
1386            git_user_name: None,
1387            git_user_email: None,
1388            run_id: &run_id,
1389            parent_run_id: None,
1390            resume_count: 0,
1391            actual_developer_runs: 2,
1392            actual_reviewer_runs: 0,
1393        });
1394
1395        assert_eq!(checkpoint.phase, PipelinePhase::Development);
1396        assert_eq!(checkpoint.iteration, 2);
1397        assert_eq!(checkpoint.total_iterations, 5);
1398        assert_eq!(checkpoint.reviewer_pass, 0);
1399        assert_eq!(checkpoint.total_reviewer_passes, 2);
1400        assert_eq!(checkpoint.developer_agent, "claude");
1401        assert_eq!(checkpoint.reviewer_agent, "codex");
1402        assert_eq!(checkpoint.version, CHECKPOINT_VERSION);
1403        assert!(!checkpoint.timestamp.is_empty());
1404        assert_eq!(checkpoint.run_id, run_id);
1405        assert_eq!(checkpoint.resume_count, 0);
1406        assert_eq!(checkpoint.actual_developer_runs, 2);
1407        assert!(checkpoint.parent_run_id.is_none());
1408    }
1409
1410    #[test]
1411    fn test_checkpoint_description() {
1412        let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3);
1413        assert_eq!(checkpoint.description(), "Development iteration 3/5");
1414
1415        let run_id = uuid::Uuid::new_v4().to_string();
1416        let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1417            phase: PipelinePhase::ReviewAgain,
1418            iteration: 5,
1419            total_iterations: 5,
1420            reviewer_pass: 2,
1421            total_reviewer_passes: 3,
1422            developer_agent: "claude",
1423            reviewer_agent: "codex",
1424            cli_args: CliArgsSnapshot::new(5, 3, None, false, true, 2, false, None),
1425            developer_agent_config: AgentConfigSnapshot::new(
1426                "claude".into(),
1427                "cmd".into(),
1428                "-o".into(),
1429                None,
1430                true,
1431            ),
1432            reviewer_agent_config: AgentConfigSnapshot::new(
1433                "codex".into(),
1434                "cmd".into(),
1435                "-o".into(),
1436                None,
1437                true,
1438            ),
1439            rebase_state: RebaseState::default(),
1440            git_user_name: None,
1441            git_user_email: None,
1442            run_id: &run_id,
1443            parent_run_id: None,
1444            resume_count: 0,
1445            actual_developer_runs: 5,
1446            actual_reviewer_runs: 2,
1447        });
1448        assert_eq!(checkpoint.description(), "Verification review 2/3");
1449    }
1450
1451    #[test]
1452    fn test_checkpoint_serialization() {
1453        let run_id = uuid::Uuid::new_v4().to_string();
1454        let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1455            phase: PipelinePhase::Fix,
1456            iteration: 3,
1457            total_iterations: 5,
1458            reviewer_pass: 1,
1459            total_reviewer_passes: 2,
1460            developer_agent: "aider",
1461            reviewer_agent: "opencode",
1462            cli_args: CliArgsSnapshot::new(
1463                5,
1464                2,
1465                Some("standard".into()),
1466                false,
1467                true,
1468                2,
1469                false,
1470                None,
1471            ),
1472            developer_agent_config: AgentConfigSnapshot::new(
1473                "aider".into(),
1474                "aider".into(),
1475                "-o".into(),
1476                Some("--yes".into()),
1477                true,
1478            ),
1479            reviewer_agent_config: AgentConfigSnapshot::new(
1480                "opencode".into(),
1481                "opencode".into(),
1482                "-o".into(),
1483                None,
1484                false,
1485            ),
1486            rebase_state: RebaseState::PreRebaseCompleted {
1487                commit_oid: "abc123".into(),
1488            },
1489            git_user_name: None,
1490            git_user_email: None,
1491            run_id: &run_id,
1492            parent_run_id: None,
1493            resume_count: 0,
1494            actual_developer_runs: 3,
1495            actual_reviewer_runs: 1,
1496        });
1497
1498        let json = serde_json::to_string(&checkpoint).unwrap();
1499        assert!(json.contains("Fix"));
1500        assert!(json.contains("aider"));
1501        assert!(json.contains("opencode"));
1502        assert!(json.contains("\"version\":"));
1503
1504        let deserialized: PipelineCheckpoint = serde_json::from_str(&json).unwrap();
1505        assert_eq!(deserialized.phase, checkpoint.phase);
1506        assert_eq!(deserialized.iteration, checkpoint.iteration);
1507        assert_eq!(deserialized.cli_args.developer_iters, 5);
1508        assert!(matches!(
1509            deserialized.rebase_state,
1510            RebaseState::PreRebaseCompleted { .. }
1511        ));
1512        assert_eq!(deserialized.run_id, run_id);
1513        assert_eq!(deserialized.actual_developer_runs, 3);
1514        assert_eq!(deserialized.actual_reviewer_runs, 1);
1515    }
1516
1517    #[test]
1518    fn test_cli_args_snapshot() {
1519        let snapshot = CliArgsSnapshot::new(
1520            10,
1521            3,
1522            Some("comprehensive".into()),
1523            true,
1524            true,
1525            3,
1526            true,
1527            Some("claude".to_string()),
1528        );
1529
1530        assert_eq!(snapshot.developer_iters, 10);
1531        assert_eq!(snapshot.reviewer_reviews, 3);
1532        assert_eq!(snapshot.review_depth, Some("comprehensive".to_string()));
1533        assert!(snapshot.skip_rebase);
1534        assert!(snapshot.isolation_mode);
1535        assert_eq!(snapshot.verbosity, 3);
1536        assert!(snapshot.show_streaming_metrics);
1537        assert_eq!(snapshot.reviewer_json_parser, Some("claude".to_string()));
1538    }
1539
1540    #[test]
1541    fn test_agent_config_snapshot() {
1542        let config = AgentConfigSnapshot::new(
1543            "test-agent".into(),
1544            "/usr/bin/test".into(),
1545            "--output".into(),
1546            Some("--yolo".into()),
1547            false,
1548        );
1549
1550        assert_eq!(config.name, "test-agent");
1551        assert_eq!(config.cmd, "/usr/bin/test");
1552        assert_eq!(config.output_flag, "--output");
1553        assert_eq!(config.yolo_flag, Some("--yolo".to_string()));
1554        assert!(!config.can_commit);
1555    }
1556
1557    #[test]
1558    fn test_rebase_state() {
1559        let state = RebaseState::PreRebaseInProgress {
1560            upstream_branch: "main".into(),
1561        };
1562        assert!(matches!(state, RebaseState::PreRebaseInProgress { .. }));
1563
1564        let state = RebaseState::Failed {
1565            error: "conflict".into(),
1566        };
1567        assert!(matches!(state, RebaseState::Failed { .. }));
1568    }
1569}