1use 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
14const AGENT_DIR: &str = ".agent";
16
17const CHECKPOINT_FILE: &str = "checkpoint.json";
19
20const CHECKPOINT_VERSION: u32 = 3;
28
29fn checkpoint_path() -> String {
36 format!("{AGENT_DIR}/{CHECKPOINT_FILE}")
37}
38
39pub(crate) fn calculate_file_checksum(path: &Path) -> Option<String> {
43 let content = fs::read(path).ok()?;
44 let mut hasher = Sha256::new();
45 hasher.update(&content);
46 Some(format!("{:x}", hasher.finalize()))
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CliArgsSnapshot {
55 pub developer_iters: u32,
57 pub reviewer_reviews: u32,
59 pub commit_msg: String,
61 pub review_depth: Option<String>,
63 pub skip_rebase: bool,
65 #[serde(default = "default_isolation_mode")]
68 pub isolation_mode: bool,
69 #[serde(default = "default_verbosity")]
72 pub verbosity: u8,
73 #[serde(default)]
76 pub show_streaming_metrics: bool,
77 #[serde(default)]
79 pub reviewer_json_parser: Option<String>,
80}
81
82fn default_isolation_mode() -> bool {
84 true
85}
86
87fn default_verbosity() -> u8 {
89 2
90}
91
92pub struct CliArgsSnapshotBuilder {
97 developer_iters: u32,
98 reviewer_reviews: u32,
99 commit_msg: String,
100 review_depth: Option<String>,
101 skip_rebase: bool,
102 isolation_mode: bool,
103 verbosity: u8,
104 show_streaming_metrics: bool,
105 reviewer_json_parser: Option<String>,
106}
107
108impl CliArgsSnapshotBuilder {
109 pub fn new(
111 developer_iters: u32,
112 reviewer_reviews: u32,
113 commit_msg: String,
114 review_depth: Option<String>,
115 skip_rebase: bool,
116 isolation_mode: bool,
117 ) -> Self {
118 Self {
119 developer_iters,
120 reviewer_reviews,
121 commit_msg,
122 review_depth,
123 skip_rebase,
124 isolation_mode,
125 verbosity: 2,
126 show_streaming_metrics: false,
127 reviewer_json_parser: None,
128 }
129 }
130
131 pub fn verbosity(mut self, verbosity: u8) -> Self {
133 self.verbosity = verbosity;
134 self
135 }
136
137 pub fn show_streaming_metrics(mut self, show: bool) -> Self {
139 self.show_streaming_metrics = show;
140 self
141 }
142
143 pub fn reviewer_json_parser(mut self, parser: Option<String>) -> Self {
145 self.reviewer_json_parser = parser;
146 self
147 }
148
149 pub fn build(self) -> CliArgsSnapshot {
151 CliArgsSnapshot {
152 developer_iters: self.developer_iters,
153 reviewer_reviews: self.reviewer_reviews,
154 commit_msg: self.commit_msg,
155 review_depth: self.review_depth,
156 skip_rebase: self.skip_rebase,
157 isolation_mode: self.isolation_mode,
158 verbosity: self.verbosity,
159 show_streaming_metrics: self.show_streaming_metrics,
160 reviewer_json_parser: self.reviewer_json_parser,
161 }
162 }
163}
164
165impl CliArgsSnapshot {
166 #[cfg(test)]
171 pub fn new(
172 developer_iters: u32,
173 reviewer_reviews: u32,
174 commit_msg: String,
175 review_depth: Option<String>,
176 skip_rebase: bool,
177 isolation_mode: bool,
178 verbosity: u8,
179 show_streaming_metrics: bool,
180 reviewer_json_parser: Option<String>,
181 ) -> Self {
182 CliArgsSnapshotBuilder::new(
183 developer_iters,
184 reviewer_reviews,
185 commit_msg,
186 review_depth,
187 skip_rebase,
188 isolation_mode,
189 )
190 .verbosity(verbosity)
191 .show_streaming_metrics(show_streaming_metrics)
192 .reviewer_json_parser(reviewer_json_parser)
193 .build()
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct AgentConfigSnapshot {
203 pub name: String,
205 pub cmd: String,
207 pub output_flag: String,
209 pub yolo_flag: Option<String>,
211 pub can_commit: bool,
213 #[serde(default)]
216 pub model_override: Option<String>,
217 #[serde(default)]
220 pub provider_override: Option<String>,
221 #[serde(default = "default_context_level")]
224 pub context_level: u8,
225}
226
227fn default_context_level() -> u8 {
229 1
230}
231
232impl AgentConfigSnapshot {
233 pub fn new(
235 name: String,
236 cmd: String,
237 output_flag: String,
238 yolo_flag: Option<String>,
239 can_commit: bool,
240 ) -> Self {
241 Self {
242 name,
243 cmd,
244 output_flag,
245 yolo_flag,
246 can_commit,
247 model_override: None,
248 provider_override: None,
249 context_level: default_context_level(),
250 }
251 }
252
253 pub fn with_model_override(mut self, model: Option<String>) -> Self {
255 self.model_override = model;
256 self
257 }
258
259 pub fn with_provider_override(mut self, provider: Option<String>) -> Self {
261 self.provider_override = provider;
262 self
263 }
264
265 pub fn with_context_level(mut self, level: u8) -> Self {
267 self.context_level = level;
268 self
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, Default)]
278pub struct EnvironmentSnapshot {
279 #[serde(default)]
281 pub ralph_vars: HashMap<String, String>,
282 #[serde(default)]
284 pub other_vars: HashMap<String, String>,
285}
286
287impl EnvironmentSnapshot {
288 pub fn capture_current() -> Self {
290 let mut ralph_vars = HashMap::new();
291 let mut other_vars = HashMap::new();
292
293 for (key, value) in std::env::vars() {
295 if key.starts_with("RALPH_") {
296 ralph_vars.insert(key, value);
297 }
298 }
299
300 let relevant_keys = [
302 "EDITOR",
303 "VISUAL",
304 "GIT_AUTHOR_NAME",
305 "GIT_AUTHOR_EMAIL",
306 "GIT_COMMITTER_NAME",
307 "GIT_COMMITTER_EMAIL",
308 ];
309 for key in &relevant_keys {
310 if let Ok(value) = std::env::var(key) {
311 other_vars.insert(key.to_string(), value);
312 }
313 }
314
315 Self {
316 ralph_vars,
317 other_vars,
318 }
319 }
320}
321
322pub struct CheckpointParams<'a> {
327 pub phase: PipelinePhase,
329 pub iteration: u32,
331 pub total_iterations: u32,
333 pub reviewer_pass: u32,
335 pub total_reviewer_passes: u32,
337 pub developer_agent: &'a str,
339 pub reviewer_agent: &'a str,
341 pub cli_args: CliArgsSnapshot,
343 pub developer_agent_config: AgentConfigSnapshot,
345 pub reviewer_agent_config: AgentConfigSnapshot,
347 pub rebase_state: RebaseState,
349 pub git_user_name: Option<&'a str>,
351 pub git_user_email: Option<&'a str>,
353 pub run_id: &'a str,
355 pub parent_run_id: Option<&'a str>,
357 pub resume_count: u32,
359 pub actual_developer_runs: u32,
361 pub actual_reviewer_runs: u32,
363}
364
365#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
370pub enum RebaseState {
371 #[default]
373 NotStarted,
374 PreRebaseInProgress { upstream_branch: String },
376 PreRebaseCompleted { commit_oid: String },
378 PostRebaseInProgress { upstream_branch: String },
380 PostRebaseCompleted { commit_oid: String },
382 HasConflicts { files: Vec<String> },
384 Failed { error: String },
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393pub enum PipelinePhase {
394 Rebase,
396 Planning,
398 Development,
400 Review,
402 Fix,
404 ReviewAgain,
406 CommitMessage,
408 FinalValidation,
410 Complete,
412 PreRebase,
414 PreRebaseConflict,
416 PostRebase,
418 PostRebaseConflict,
420 Interrupted,
422}
423
424impl std::fmt::Display for PipelinePhase {
425 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426 match self {
427 Self::Rebase => write!(f, "Rebase"),
428 Self::Planning => write!(f, "Planning"),
429 Self::Development => write!(f, "Development"),
430 Self::Review => write!(f, "Review"),
431 Self::Fix => write!(f, "Fix"),
432 Self::ReviewAgain => write!(f, "Verification Review"),
433 Self::CommitMessage => write!(f, "Commit Message Generation"),
434 Self::FinalValidation => write!(f, "Final Validation"),
435 Self::Complete => write!(f, "Complete"),
436 Self::PreRebase => write!(f, "Pre-Rebase"),
437 Self::PreRebaseConflict => write!(f, "Pre-Rebase Conflict"),
438 Self::PostRebase => write!(f, "Post-Rebase"),
439 Self::PostRebaseConflict => write!(f, "Post-Rebase Conflict"),
440 Self::Interrupted => write!(f, "Interrupted"),
441 }
442 }
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct PipelineCheckpoint {
455 pub version: u32,
457
458 pub phase: PipelinePhase,
461 pub iteration: u32,
463 pub total_iterations: u32,
465 pub reviewer_pass: u32,
467 pub total_reviewer_passes: u32,
469
470 pub timestamp: String,
473 pub developer_agent: String,
475 pub reviewer_agent: String,
477
478 pub cli_args: CliArgsSnapshot,
481 pub developer_agent_config: AgentConfigSnapshot,
483 pub reviewer_agent_config: AgentConfigSnapshot,
485 pub rebase_state: RebaseState,
487
488 pub config_path: Option<String>,
491 pub config_checksum: Option<String>,
493 pub working_dir: String,
495 pub prompt_md_checksum: Option<String>,
497
498 pub git_user_name: Option<String>,
501 pub git_user_email: Option<String>,
503
504 pub run_id: String,
507 pub parent_run_id: Option<String>,
509 pub resume_count: u32,
511
512 pub actual_developer_runs: u32,
515 pub actual_reviewer_runs: u32,
517
518 #[serde(skip_serializing_if = "Option::is_none")]
521 pub execution_history: Option<crate::checkpoint::ExecutionHistory>,
522 #[serde(skip_serializing_if = "Option::is_none")]
524 pub file_system_state: Option<crate::checkpoint::FileSystemState>,
525 #[serde(skip_serializing_if = "Option::is_none")]
527 pub prompt_history: Option<std::collections::HashMap<String, String>>,
528 #[serde(skip_serializing_if = "Option::is_none")]
530 pub env_snapshot: Option<EnvironmentSnapshot>,
531}
532
533impl PipelineCheckpoint {
534 pub fn from_params(params: CheckpointParams<'_>) -> Self {
543 let working_dir = std::env::current_dir()
545 .map(|p| p.to_string_lossy().to_string())
546 .unwrap_or_default();
547
548 let prompt_md_checksum = calculate_file_checksum(Path::new("PROMPT.md"));
550
551 Self {
552 version: CHECKPOINT_VERSION,
553 phase: params.phase,
554 iteration: params.iteration,
555 total_iterations: params.total_iterations,
556 reviewer_pass: params.reviewer_pass,
557 total_reviewer_passes: params.total_reviewer_passes,
558 timestamp: timestamp(),
559 developer_agent: params.developer_agent.to_string(),
560 reviewer_agent: params.reviewer_agent.to_string(),
561 cli_args: params.cli_args,
562 developer_agent_config: params.developer_agent_config,
563 reviewer_agent_config: params.reviewer_agent_config,
564 rebase_state: params.rebase_state,
565 config_path: None, config_checksum: None, working_dir,
568 prompt_md_checksum,
569 git_user_name: params.git_user_name.map(String::from),
570 git_user_email: params.git_user_email.map(String::from),
571 run_id: params.run_id.to_string(),
573 parent_run_id: params.parent_run_id.map(String::from),
574 resume_count: params.resume_count,
575 actual_developer_runs: params.actual_developer_runs,
576 actual_reviewer_runs: params.actual_reviewer_runs,
577 execution_history: None,
579 file_system_state: None,
580 prompt_history: None,
581 env_snapshot: None,
582 }
583 }
584
585 pub fn description(&self) -> String {
590 match self.phase {
591 PipelinePhase::Rebase => "Rebase in progress".to_string(),
592 PipelinePhase::Planning => {
593 format!(
594 "Planning phase, iteration {}/{}",
595 self.iteration, self.total_iterations
596 )
597 }
598 PipelinePhase::Development => {
599 format!(
600 "Development iteration {}/{}",
601 self.iteration, self.total_iterations
602 )
603 }
604 PipelinePhase::Review => "Initial review".to_string(),
605 PipelinePhase::Fix => "Applying fixes".to_string(),
606 PipelinePhase::ReviewAgain => {
607 format!(
608 "Verification review {}/{}",
609 self.reviewer_pass, self.total_reviewer_passes
610 )
611 }
612 PipelinePhase::CommitMessage => "Commit message generation".to_string(),
613 PipelinePhase::FinalValidation => "Final validation".to_string(),
614 PipelinePhase::Complete => "Pipeline complete".to_string(),
615 PipelinePhase::PreRebase => "Pre-development rebase".to_string(),
616 PipelinePhase::PreRebaseConflict => "Pre-rebase conflict resolution".to_string(),
617 PipelinePhase::PostRebase => "Post-review rebase".to_string(),
618 PipelinePhase::PostRebaseConflict => "Post-rebase conflict resolution".to_string(),
619 PipelinePhase::Interrupted => {
620 let mut parts = vec!["Interrupted".to_string()];
622
623 if self.iteration > 0 && self.iteration < self.total_iterations {
625 parts.push(format!(
626 "during development (iteration {}/{})",
627 self.iteration, self.total_iterations
628 ));
629 } else if self.iteration >= self.total_iterations {
630 if self.reviewer_pass > 0 {
631 parts.push(format!(
632 "during review (pass {}/{})",
633 self.reviewer_pass, self.total_reviewer_passes
634 ));
635 } else {
636 parts.push("after development phase".to_string());
637 }
638 } else {
639 parts.push("during pipeline initialization".to_string());
640 }
641
642 parts.join(" ")
643 }
644 }
645 }
646
647 pub fn with_config(mut self, path: Option<std::path::PathBuf>) -> Self {
649 if let Some(p) = path {
650 self.config_path = Some(p.to_string_lossy().to_string());
651 self.config_checksum = calculate_file_checksum(&p);
652 }
653 self
654 }
655}
656
657fn load_checkpoint_with_fallback(
659 content: &str,
660) -> Result<PipelineCheckpoint, Box<dyn std::error::Error>> {
661 match serde_json::from_str::<PipelineCheckpoint>(content) {
663 Ok(mut checkpoint) => {
664 if checkpoint.version >= 3 {
666 return Ok(checkpoint);
667 }
668 checkpoint.version = CHECKPOINT_VERSION;
671 return Ok(checkpoint);
672 }
673 Err(_e) => {
674 }
676 }
677
678 #[derive(Debug, Clone, Serialize, Deserialize)]
680 struct V2Checkpoint {
681 version: u32,
682 phase: PipelinePhase,
683 iteration: u32,
684 total_iterations: u32,
685 reviewer_pass: u32,
686 total_reviewer_passes: u32,
687 timestamp: String,
688 developer_agent: String,
689 reviewer_agent: String,
690 cli_args: CliArgsSnapshot,
691 developer_agent_config: AgentConfigSnapshot,
692 reviewer_agent_config: AgentConfigSnapshot,
693 rebase_state: RebaseState,
694 config_path: Option<String>,
695 config_checksum: Option<String>,
696 working_dir: String,
697 prompt_md_checksum: Option<String>,
698 git_user_name: Option<String>,
699 git_user_email: Option<String>,
700 run_id: String,
701 parent_run_id: Option<String>,
702 resume_count: u32,
703 actual_developer_runs: u32,
704 actual_reviewer_runs: u32,
705 }
706
707 if let Ok(v2) = serde_json::from_str::<V2Checkpoint>(content) {
708 return Ok(PipelineCheckpoint {
711 version: CHECKPOINT_VERSION,
712 phase: v2.phase,
713 iteration: v2.iteration,
714 total_iterations: v2.total_iterations,
715 reviewer_pass: v2.reviewer_pass,
716 total_reviewer_passes: v2.total_reviewer_passes,
717 timestamp: v2.timestamp,
718 developer_agent: v2.developer_agent,
719 reviewer_agent: v2.reviewer_agent,
720 cli_args: v2.cli_args,
721 developer_agent_config: v2.developer_agent_config,
722 reviewer_agent_config: v2.reviewer_agent_config,
723 rebase_state: v2.rebase_state,
724 config_path: v2.config_path,
725 config_checksum: v2.config_checksum,
726 working_dir: v2.working_dir,
727 prompt_md_checksum: v2.prompt_md_checksum,
728 git_user_name: v2.git_user_name,
729 git_user_email: v2.git_user_email,
730 run_id: v2.run_id,
731 parent_run_id: v2.parent_run_id,
732 resume_count: v2.resume_count,
733 actual_developer_runs: v2.actual_developer_runs,
734 actual_reviewer_runs: v2.actual_reviewer_runs,
735 execution_history: None,
737 file_system_state: None,
738 prompt_history: None,
739 env_snapshot: None,
740 });
741 }
742
743 #[derive(Debug, Clone, Serialize, Deserialize)]
745 struct V1Checkpoint {
746 version: u32,
747 phase: PipelinePhase,
748 iteration: u32,
749 total_iterations: u32,
750 reviewer_pass: u32,
751 total_reviewer_passes: u32,
752 timestamp: String,
753 developer_agent: String,
754 reviewer_agent: String,
755 cli_args: CliArgsSnapshot,
756 developer_agent_config: AgentConfigSnapshot,
757 reviewer_agent_config: AgentConfigSnapshot,
758 rebase_state: RebaseState,
759 config_path: Option<String>,
760 config_checksum: Option<String>,
761 working_dir: String,
762 prompt_md_checksum: Option<String>,
763 git_user_name: Option<String>,
764 git_user_email: Option<String>,
765 }
766
767 if let Ok(v1) = serde_json::from_str::<V1Checkpoint>(content) {
768 let new_run_id = uuid::Uuid::new_v4().to_string();
770 return Ok(PipelineCheckpoint {
771 version: CHECKPOINT_VERSION,
772 phase: v1.phase,
773 iteration: v1.iteration,
774 total_iterations: v1.total_iterations,
775 reviewer_pass: v1.reviewer_pass,
776 total_reviewer_passes: v1.total_reviewer_passes,
777 timestamp: v1.timestamp,
778 developer_agent: v1.developer_agent,
779 reviewer_agent: v1.reviewer_agent,
780 cli_args: v1.cli_args,
781 developer_agent_config: v1.developer_agent_config,
782 reviewer_agent_config: v1.reviewer_agent_config,
783 rebase_state: v1.rebase_state,
784 config_path: v1.config_path,
785 config_checksum: v1.config_checksum,
786 working_dir: v1.working_dir,
787 prompt_md_checksum: v1.prompt_md_checksum,
788 git_user_name: v1.git_user_name,
789 git_user_email: v1.git_user_email,
790 run_id: new_run_id,
792 parent_run_id: None,
793 resume_count: 0,
794 actual_developer_runs: v1.iteration,
795 actual_reviewer_runs: v1.reviewer_pass,
796 execution_history: None,
798 file_system_state: None,
799 prompt_history: None,
800 env_snapshot: None,
801 });
802 }
803
804 #[derive(Debug, Clone, Serialize, Deserialize)]
806 struct LegacyCheckpoint {
807 phase: PipelinePhase,
808 iteration: u32,
809 total_iterations: u32,
810 reviewer_pass: u32,
811 total_reviewer_passes: u32,
812 timestamp: String,
813 developer_agent: String,
814 reviewer_agent: String,
815 }
816
817 if let Ok(legacy) = serde_json::from_str::<LegacyCheckpoint>(content) {
818 let new_run_id = uuid::Uuid::new_v4().to_string();
819 return Ok(PipelineCheckpoint {
820 version: CHECKPOINT_VERSION,
821 phase: legacy.phase,
822 iteration: legacy.iteration,
823 total_iterations: legacy.total_iterations,
824 reviewer_pass: legacy.reviewer_pass,
825 total_reviewer_passes: legacy.total_reviewer_passes,
826 timestamp: legacy.timestamp,
827 developer_agent: legacy.developer_agent.clone(),
828 reviewer_agent: legacy.reviewer_agent.clone(),
829 cli_args: CliArgsSnapshotBuilder::new(0, 0, String::new(), None, false, true).build(),
830 developer_agent_config: AgentConfigSnapshot::new(
831 legacy.developer_agent.clone(),
832 String::new(),
833 String::new(),
834 None,
835 false,
836 ),
837 reviewer_agent_config: AgentConfigSnapshot::new(
838 legacy.reviewer_agent.clone(),
839 String::new(),
840 String::new(),
841 None,
842 false,
843 ),
844 rebase_state: RebaseState::default(),
845 config_path: None,
846 config_checksum: None,
847 working_dir: String::new(),
848 prompt_md_checksum: None,
849 git_user_name: None,
850 git_user_email: None,
851 run_id: new_run_id,
852 parent_run_id: None,
853 resume_count: 0,
854 actual_developer_runs: legacy.iteration,
855 actual_reviewer_runs: legacy.reviewer_pass,
856 execution_history: None,
858 file_system_state: None,
859 prompt_history: None,
860 env_snapshot: None,
861 });
862 }
863
864 Err("Invalid checkpoint format".into())
865}
866
867pub fn timestamp() -> String {
869 Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
870}
871
872pub fn save_checkpoint(checkpoint: &PipelineCheckpoint) -> io::Result<()> {
882 let json = serde_json::to_string_pretty(checkpoint).map_err(|e| {
883 io::Error::new(
884 io::ErrorKind::InvalidData,
885 format!("Failed to serialize checkpoint: {e}"),
886 )
887 })?;
888
889 fs::create_dir_all(AGENT_DIR)?;
891
892 let checkpoint_path_str = checkpoint_path();
894 let temp_path = format!("{checkpoint_path_str}.tmp");
895
896 let write_result = fs::write(&temp_path, &json);
898 if write_result.is_err() {
899 let _ = fs::remove_file(&temp_path);
900 return write_result;
901 }
902
903 let rename_result = fs::rename(&temp_path, &checkpoint_path_str);
904 if rename_result.is_err() {
905 let _ = fs::remove_file(&temp_path);
906 return rename_result;
907 }
908
909 Ok(())
910}
911
912pub fn load_checkpoint() -> io::Result<Option<PipelineCheckpoint>> {
929 let checkpoint = checkpoint_path();
930 let path = Path::new(&checkpoint);
931 if !path.exists() {
932 return Ok(None);
933 }
934
935 let content = fs::read_to_string(path)?;
936 let loaded_checkpoint = load_checkpoint_with_fallback(&content).map_err(|e| {
937 io::Error::new(
938 io::ErrorKind::InvalidData,
939 format!("Failed to parse checkpoint: {e}"),
940 )
941 })?;
942
943 Ok(Some(loaded_checkpoint))
944}
945
946pub fn clear_checkpoint() -> io::Result<()> {
955 let checkpoint = checkpoint_path();
956 let path = Path::new(&checkpoint);
957 if path.exists() {
958 fs::remove_file(path)?;
959 }
960 Ok(())
961}
962
963pub fn checkpoint_exists() -> bool {
967 Path::new(&checkpoint_path()).exists()
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973 use test_helpers::with_temp_cwd;
974
975 fn make_test_checkpoint(phase: PipelinePhase, iteration: u32) -> PipelineCheckpoint {
977 let cli_args = CliArgsSnapshot::new(
978 5,
979 2,
980 "test commit".to_string(),
981 None,
982 false,
983 true,
984 2,
985 false,
986 None,
987 );
988 let dev_config =
989 AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
990 let rev_config =
991 AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
992 let run_id = uuid::Uuid::new_v4().to_string();
993 PipelineCheckpoint::from_params(CheckpointParams {
994 phase,
995 iteration,
996 total_iterations: 5,
997 reviewer_pass: 0,
998 total_reviewer_passes: 2,
999 developer_agent: "claude",
1000 reviewer_agent: "codex",
1001 cli_args,
1002 developer_agent_config: dev_config,
1003 reviewer_agent_config: rev_config,
1004 rebase_state: RebaseState::default(),
1005 git_user_name: None,
1006 git_user_email: None,
1007 run_id: &run_id,
1008 parent_run_id: None,
1009 resume_count: 0,
1010 actual_developer_runs: iteration,
1011 actual_reviewer_runs: 0,
1012 })
1013 }
1014
1015 #[test]
1016 fn test_timestamp_format() {
1017 let ts = timestamp();
1018 assert!(ts.contains('-'));
1019 assert!(ts.contains(':'));
1020 assert_eq!(ts.len(), 19);
1021 }
1022
1023 #[test]
1024 fn test_pipeline_phase_display() {
1025 assert_eq!(format!("{}", PipelinePhase::Rebase), "Rebase");
1026 assert_eq!(format!("{}", PipelinePhase::Planning), "Planning");
1027 assert_eq!(format!("{}", PipelinePhase::Development), "Development");
1028 assert_eq!(format!("{}", PipelinePhase::Review), "Review");
1029 assert_eq!(format!("{}", PipelinePhase::Fix), "Fix");
1030 assert_eq!(
1031 format!("{}", PipelinePhase::ReviewAgain),
1032 "Verification Review"
1033 );
1034 assert_eq!(
1035 format!("{}", PipelinePhase::CommitMessage),
1036 "Commit Message Generation"
1037 );
1038 assert_eq!(
1039 format!("{}", PipelinePhase::FinalValidation),
1040 "Final Validation"
1041 );
1042 assert_eq!(format!("{}", PipelinePhase::Complete), "Complete");
1043 assert_eq!(format!("{}", PipelinePhase::PreRebase), "Pre-Rebase");
1044 assert_eq!(
1045 format!("{}", PipelinePhase::PreRebaseConflict),
1046 "Pre-Rebase Conflict"
1047 );
1048 assert_eq!(format!("{}", PipelinePhase::PostRebase), "Post-Rebase");
1049 assert_eq!(
1050 format!("{}", PipelinePhase::PostRebaseConflict),
1051 "Post-Rebase Conflict"
1052 );
1053 assert_eq!(format!("{}", PipelinePhase::Interrupted), "Interrupted");
1054 }
1055
1056 #[test]
1057 fn test_checkpoint_from_params() {
1058 let cli_args =
1059 CliArgsSnapshot::new(5, 2, "test".to_string(), None, false, true, 2, false, None);
1060 let dev_config =
1061 AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
1062 let rev_config =
1063 AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
1064 let run_id = uuid::Uuid::new_v4().to_string();
1065 let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1066 phase: PipelinePhase::Development,
1067 iteration: 2,
1068 total_iterations: 5,
1069 reviewer_pass: 0,
1070 total_reviewer_passes: 2,
1071 developer_agent: "claude",
1072 reviewer_agent: "codex",
1073 cli_args,
1074 developer_agent_config: dev_config,
1075 reviewer_agent_config: rev_config,
1076 rebase_state: RebaseState::default(),
1077 git_user_name: None,
1078 git_user_email: None,
1079 run_id: &run_id,
1080 parent_run_id: None,
1081 resume_count: 0,
1082 actual_developer_runs: 2,
1083 actual_reviewer_runs: 0,
1084 });
1085
1086 assert_eq!(checkpoint.phase, PipelinePhase::Development);
1087 assert_eq!(checkpoint.iteration, 2);
1088 assert_eq!(checkpoint.total_iterations, 5);
1089 assert_eq!(checkpoint.reviewer_pass, 0);
1090 assert_eq!(checkpoint.total_reviewer_passes, 2);
1091 assert_eq!(checkpoint.developer_agent, "claude");
1092 assert_eq!(checkpoint.reviewer_agent, "codex");
1093 assert_eq!(checkpoint.version, CHECKPOINT_VERSION);
1094 assert!(!checkpoint.timestamp.is_empty());
1095 assert_eq!(checkpoint.run_id, run_id);
1096 assert_eq!(checkpoint.resume_count, 0);
1097 assert_eq!(checkpoint.actual_developer_runs, 2);
1098 assert!(checkpoint.parent_run_id.is_none());
1099 }
1100
1101 #[test]
1102 fn test_checkpoint_description() {
1103 let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3);
1104 assert_eq!(checkpoint.description(), "Development iteration 3/5");
1105
1106 let run_id = uuid::Uuid::new_v4().to_string();
1107 let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1108 phase: PipelinePhase::ReviewAgain,
1109 iteration: 5,
1110 total_iterations: 5,
1111 reviewer_pass: 2,
1112 total_reviewer_passes: 3,
1113 developer_agent: "claude",
1114 reviewer_agent: "codex",
1115 cli_args: CliArgsSnapshot::new(
1116 5,
1117 3,
1118 "test".to_string(),
1119 None,
1120 false,
1121 true,
1122 2,
1123 false,
1124 None,
1125 ),
1126 developer_agent_config: AgentConfigSnapshot::new(
1127 "claude".into(),
1128 "cmd".into(),
1129 "-o".into(),
1130 None,
1131 true,
1132 ),
1133 reviewer_agent_config: AgentConfigSnapshot::new(
1134 "codex".into(),
1135 "cmd".into(),
1136 "-o".into(),
1137 None,
1138 true,
1139 ),
1140 rebase_state: RebaseState::default(),
1141 git_user_name: None,
1142 git_user_email: None,
1143 run_id: &run_id,
1144 parent_run_id: None,
1145 resume_count: 0,
1146 actual_developer_runs: 5,
1147 actual_reviewer_runs: 2,
1148 });
1149 assert_eq!(checkpoint.description(), "Verification review 2/3");
1150 }
1151
1152 #[test]
1153 fn test_checkpoint_save_load() {
1154 with_temp_cwd(|_dir| {
1155 fs::create_dir_all(".agent").unwrap();
1156
1157 let checkpoint = make_test_checkpoint(PipelinePhase::Review, 5);
1158
1159 save_checkpoint(&checkpoint).unwrap();
1160 assert!(checkpoint_exists());
1161
1162 let loaded = load_checkpoint()
1163 .unwrap()
1164 .expect("checkpoint should exist after save_checkpoint");
1165 assert_eq!(loaded.phase, PipelinePhase::Review);
1166 assert_eq!(loaded.iteration, 5);
1167 assert_eq!(loaded.developer_agent, "claude");
1168 assert_eq!(loaded.reviewer_agent, "codex");
1169 assert_eq!(loaded.version, CHECKPOINT_VERSION);
1170 });
1171 }
1172
1173 #[test]
1174 fn test_checkpoint_clear() {
1175 with_temp_cwd(|_dir| {
1176 fs::create_dir_all(".agent").unwrap();
1177
1178 let checkpoint = make_test_checkpoint(PipelinePhase::Development, 1);
1179
1180 save_checkpoint(&checkpoint).unwrap();
1181 assert!(checkpoint_exists());
1182
1183 clear_checkpoint().unwrap();
1184 assert!(!checkpoint_exists());
1185 });
1186 }
1187
1188 #[test]
1189 fn test_load_checkpoint_nonexistent() {
1190 with_temp_cwd(|_dir| {
1191 fs::create_dir_all(".agent").unwrap();
1192
1193 let result = load_checkpoint().unwrap();
1194 assert!(result.is_none());
1195 });
1196 }
1197
1198 #[test]
1199 fn test_checkpoint_serialization() {
1200 let run_id = uuid::Uuid::new_v4().to_string();
1201 let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1202 phase: PipelinePhase::Fix,
1203 iteration: 3,
1204 total_iterations: 5,
1205 reviewer_pass: 1,
1206 total_reviewer_passes: 2,
1207 developer_agent: "aider",
1208 reviewer_agent: "opencode",
1209 cli_args: CliArgsSnapshot::new(
1210 5,
1211 2,
1212 "fix".to_string(),
1213 Some("standard".into()),
1214 false,
1215 true,
1216 2,
1217 false,
1218 None,
1219 ),
1220 developer_agent_config: AgentConfigSnapshot::new(
1221 "aider".into(),
1222 "aider".into(),
1223 "-o".into(),
1224 Some("--yes".into()),
1225 true,
1226 ),
1227 reviewer_agent_config: AgentConfigSnapshot::new(
1228 "opencode".into(),
1229 "opencode".into(),
1230 "-o".into(),
1231 None,
1232 false,
1233 ),
1234 rebase_state: RebaseState::PreRebaseCompleted {
1235 commit_oid: "abc123".into(),
1236 },
1237 git_user_name: None,
1238 git_user_email: None,
1239 run_id: &run_id,
1240 parent_run_id: None,
1241 resume_count: 0,
1242 actual_developer_runs: 3,
1243 actual_reviewer_runs: 1,
1244 });
1245
1246 let json = serde_json::to_string(&checkpoint).unwrap();
1247 assert!(json.contains("Fix"));
1248 assert!(json.contains("aider"));
1249 assert!(json.contains("opencode"));
1250 assert!(json.contains("\"version\":"));
1251
1252 let deserialized: PipelineCheckpoint = serde_json::from_str(&json).unwrap();
1253 assert_eq!(deserialized.phase, checkpoint.phase);
1254 assert_eq!(deserialized.iteration, checkpoint.iteration);
1255 assert_eq!(deserialized.cli_args.developer_iters, 5);
1256 assert_eq!(deserialized.cli_args.commit_msg, "fix");
1257 assert!(matches!(
1258 deserialized.rebase_state,
1259 RebaseState::PreRebaseCompleted { .. }
1260 ));
1261 assert_eq!(deserialized.run_id, run_id);
1262 assert_eq!(deserialized.actual_developer_runs, 3);
1263 assert_eq!(deserialized.actual_reviewer_runs, 1);
1264 }
1265
1266 #[test]
1267 fn test_cli_args_snapshot() {
1268 let snapshot = CliArgsSnapshot::new(
1269 10,
1270 3,
1271 "feat: new feature".to_string(),
1272 Some("comprehensive".into()),
1273 true,
1274 true,
1275 3,
1276 true,
1277 Some("claude".to_string()),
1278 );
1279
1280 assert_eq!(snapshot.developer_iters, 10);
1281 assert_eq!(snapshot.reviewer_reviews, 3);
1282 assert_eq!(snapshot.commit_msg, "feat: new feature");
1283 assert_eq!(snapshot.review_depth, Some("comprehensive".to_string()));
1284 assert!(snapshot.skip_rebase);
1285 assert!(snapshot.isolation_mode);
1286 assert_eq!(snapshot.verbosity, 3);
1287 assert!(snapshot.show_streaming_metrics);
1288 assert_eq!(snapshot.reviewer_json_parser, Some("claude".to_string()));
1289 }
1290
1291 #[test]
1292 fn test_agent_config_snapshot() {
1293 let config = AgentConfigSnapshot::new(
1294 "test-agent".into(),
1295 "/usr/bin/test".into(),
1296 "--output".into(),
1297 Some("--yolo".into()),
1298 false,
1299 );
1300
1301 assert_eq!(config.name, "test-agent");
1302 assert_eq!(config.cmd, "/usr/bin/test");
1303 assert_eq!(config.output_flag, "--output");
1304 assert_eq!(config.yolo_flag, Some("--yolo".to_string()));
1305 assert!(!config.can_commit);
1306 }
1307
1308 #[test]
1309 fn test_rebase_state() {
1310 let state = RebaseState::PreRebaseInProgress {
1311 upstream_branch: "main".into(),
1312 };
1313 assert!(matches!(state, RebaseState::PreRebaseInProgress { .. }));
1314
1315 let state = RebaseState::Failed {
1316 error: "conflict".into(),
1317 };
1318 assert!(matches!(state, RebaseState::Failed { .. }));
1319 }
1320
1321 #[test]
1322 fn test_calculate_file_checksum() {
1323 with_temp_cwd(|_dir| {
1324 fs::create_dir_all(".agent").unwrap();
1325
1326 let test_file = Path::new("test.txt");
1328 fs::write(&test_file, "test content").unwrap();
1329
1330 let checksum1 = calculate_file_checksum(test_file);
1331 assert!(checksum1.is_some());
1332
1333 let checksum2 = calculate_file_checksum(test_file);
1335 assert_eq!(checksum1, checksum2);
1336
1337 fs::write(&test_file, "different content").unwrap();
1339 let checksum3 = calculate_file_checksum(test_file);
1340 assert_ne!(checksum1, checksum3);
1341
1342 let nonexistent = Path::new("nonexistent.txt");
1344 assert!(calculate_file_checksum(nonexistent).is_none());
1345 });
1346 }
1347
1348 #[test]
1349 fn test_load_checkpoint_preserves_working_dir() {
1350 with_temp_cwd(|_dir| {
1351 fs::create_dir_all(".agent").unwrap();
1352
1353 let json = r#"{
1354 "version": 1,
1355 "phase": "Development",
1356 "iteration": 1,
1357 "total_iterations": 1,
1358 "reviewer_pass": 0,
1359 "total_reviewer_passes": 0,
1360 "timestamp": "2024-01-01 12:00:00",
1361 "developer_agent": "test-agent",
1362 "reviewer_agent": "test-agent",
1363 "cli_args": {
1364 "developer_iters": 1,
1365 "reviewer_reviews": 0,
1366 "commit_msg": "",
1367 "review_depth": null,
1368 "skip_rebase": false
1369 },
1370 "developer_agent_config": {
1371 "name": "test-agent",
1372 "cmd": "echo",
1373 "output_flag": "",
1374 "yolo_flag": null,
1375 "can_commit": false,
1376 "model_override": null,
1377 "provider_override": null,
1378 "context_level": 1
1379 },
1380 "reviewer_agent_config": {
1381 "name": "test-agent",
1382 "cmd": "echo",
1383 "output_flag": "",
1384 "yolo_flag": null,
1385 "can_commit": false,
1386 "model_override": null,
1387 "provider_override": null,
1388 "context_level": 1
1389 },
1390 "rebase_state": "NotStarted",
1391 "config_path": null,
1392 "config_checksum": null,
1393 "working_dir": "/some/other/directory",
1394 "prompt_md_checksum": null,
1395 "git_user_name": null,
1396 "git_user_email": null
1397 }"#;
1398
1399 fs::write(".agent/checkpoint.json", json).unwrap();
1400
1401 let loaded = load_checkpoint().unwrap().expect("should load checkpoint");
1402 assert_eq!(
1403 loaded.working_dir, "/some/other/directory",
1404 "working_dir should be preserved from JSON"
1405 );
1406 });
1407 }
1408}