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