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
14use crate::workspace::Workspace;
15
16const AGENT_DIR: &str = ".agent";
18
19const CHECKPOINT_FILE: &str = "checkpoint.json";
21
22const CHECKPOINT_VERSION: u32 = 3;
30
31fn checkpoint_path() -> String {
38 format!("{AGENT_DIR}/{CHECKPOINT_FILE}")
39}
40
41pub(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
49pub(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#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CliArgsSnapshot {
65 pub developer_iters: u32,
67 pub reviewer_reviews: u32,
69 pub commit_msg: String,
71 pub review_depth: Option<String>,
73 pub skip_rebase: bool,
75 #[serde(default = "default_isolation_mode")]
78 pub isolation_mode: bool,
79 #[serde(default = "default_verbosity")]
82 pub verbosity: u8,
83 #[serde(default)]
86 pub show_streaming_metrics: bool,
87 #[serde(default)]
89 pub reviewer_json_parser: Option<String>,
90}
91
92fn default_isolation_mode() -> bool {
94 true
95}
96
97fn default_verbosity() -> u8 {
99 2
100}
101
102pub 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 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 pub fn verbosity(mut self, verbosity: u8) -> Self {
143 self.verbosity = verbosity;
144 self
145 }
146
147 pub fn show_streaming_metrics(mut self, show: bool) -> Self {
149 self.show_streaming_metrics = show;
150 self
151 }
152
153 pub fn reviewer_json_parser(mut self, parser: Option<String>) -> Self {
155 self.reviewer_json_parser = parser;
156 self
157 }
158
159 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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct AgentConfigSnapshot {
213 pub name: String,
215 pub cmd: String,
217 pub output_flag: String,
219 pub yolo_flag: Option<String>,
221 pub can_commit: bool,
223 #[serde(default)]
226 pub model_override: Option<String>,
227 #[serde(default)]
230 pub provider_override: Option<String>,
231 #[serde(default = "default_context_level")]
234 pub context_level: u8,
235}
236
237fn default_context_level() -> u8 {
239 1
240}
241
242impl AgentConfigSnapshot {
243 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 pub fn with_model_override(mut self, model: Option<String>) -> Self {
265 self.model_override = model;
266 self
267 }
268
269 pub fn with_provider_override(mut self, provider: Option<String>) -> Self {
271 self.provider_override = provider;
272 self
273 }
274
275 pub fn with_context_level(mut self, level: u8) -> Self {
277 self.context_level = level;
278 self
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, Default)]
288pub struct EnvironmentSnapshot {
289 #[serde(default)]
291 pub ralph_vars: HashMap<String, String>,
292 #[serde(default)]
294 pub other_vars: HashMap<String, String>,
295}
296
297impl EnvironmentSnapshot {
298 pub fn capture_current() -> Self {
300 let mut ralph_vars = HashMap::new();
301 let mut other_vars = HashMap::new();
302
303 for (key, value) in std::env::vars() {
305 if key.starts_with("RALPH_") {
306 ralph_vars.insert(key, value);
307 }
308 }
309
310 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
332pub struct CheckpointParams<'a> {
337 pub phase: PipelinePhase,
339 pub iteration: u32,
341 pub total_iterations: u32,
343 pub reviewer_pass: u32,
345 pub total_reviewer_passes: u32,
347 pub developer_agent: &'a str,
349 pub reviewer_agent: &'a str,
351 pub cli_args: CliArgsSnapshot,
353 pub developer_agent_config: AgentConfigSnapshot,
355 pub reviewer_agent_config: AgentConfigSnapshot,
357 pub rebase_state: RebaseState,
359 pub git_user_name: Option<&'a str>,
361 pub git_user_email: Option<&'a str>,
363 pub run_id: &'a str,
365 pub parent_run_id: Option<&'a str>,
367 pub resume_count: u32,
369 pub actual_developer_runs: u32,
371 pub actual_reviewer_runs: u32,
373}
374
375#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
380pub enum RebaseState {
381 #[default]
383 NotStarted,
384 PreRebaseInProgress { upstream_branch: String },
386 PreRebaseCompleted { commit_oid: String },
388 PostRebaseInProgress { upstream_branch: String },
390 PostRebaseCompleted { commit_oid: String },
392 HasConflicts { files: Vec<String> },
394 Failed { error: String },
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
403pub enum PipelinePhase {
404 Rebase,
406 Planning,
408 Development,
410 Review,
412 Fix,
414 ReviewAgain,
416 CommitMessage,
418 FinalValidation,
420 Complete,
422 PreRebase,
424 PreRebaseConflict,
426 PostRebase,
428 PostRebaseConflict,
430 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#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct PipelineCheckpoint {
465 pub version: u32,
467
468 pub phase: PipelinePhase,
471 pub iteration: u32,
473 pub total_iterations: u32,
475 pub reviewer_pass: u32,
477 pub total_reviewer_passes: u32,
479
480 pub timestamp: String,
483 pub developer_agent: String,
485 pub reviewer_agent: String,
487
488 pub cli_args: CliArgsSnapshot,
491 pub developer_agent_config: AgentConfigSnapshot,
493 pub reviewer_agent_config: AgentConfigSnapshot,
495 pub rebase_state: RebaseState,
497
498 pub config_path: Option<String>,
501 pub config_checksum: Option<String>,
503 pub working_dir: String,
505 pub prompt_md_checksum: Option<String>,
507
508 pub git_user_name: Option<String>,
511 pub git_user_email: Option<String>,
513
514 pub run_id: String,
517 pub parent_run_id: Option<String>,
519 pub resume_count: u32,
521
522 pub actual_developer_runs: u32,
525 pub actual_reviewer_runs: u32,
527
528 #[serde(skip_serializing_if = "Option::is_none")]
531 pub execution_history: Option<crate::checkpoint::ExecutionHistory>,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub file_system_state: Option<crate::checkpoint::FileSystemState>,
535 #[serde(skip_serializing_if = "Option::is_none")]
537 pub prompt_history: Option<std::collections::HashMap<String, String>>,
538 #[serde(skip_serializing_if = "Option::is_none")]
540 pub env_snapshot: Option<EnvironmentSnapshot>,
541}
542
543impl PipelineCheckpoint {
544 pub fn from_params(params: CheckpointParams<'_>) -> Self {
553 let working_dir = std::env::current_dir()
555 .map(|p| p.to_string_lossy().to_string())
556 .unwrap_or_default();
557
558 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, config_checksum: None, 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 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 execution_history: None,
589 file_system_state: None,
590 prompt_history: None,
591 env_snapshot: None,
592 }
593 }
594
595 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 let mut parts = vec!["Interrupted".to_string()];
632
633 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 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
667fn load_checkpoint_with_fallback(
669 content: &str,
670) -> Result<PipelineCheckpoint, Box<dyn std::error::Error>> {
671 match serde_json::from_str::<PipelineCheckpoint>(content) {
673 Ok(mut checkpoint) => {
674 if checkpoint.version >= 3 {
676 return Ok(checkpoint);
677 }
678 checkpoint.version = CHECKPOINT_VERSION;
681 return Ok(checkpoint);
682 }
683 Err(_e) => {
684 }
686 }
687
688 #[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 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 execution_history: None,
747 file_system_state: None,
748 prompt_history: None,
749 env_snapshot: None,
750 });
751 }
752
753 #[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 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 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 execution_history: None,
808 file_system_state: None,
809 prompt_history: None,
810 env_snapshot: None,
811 });
812 }
813
814 #[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 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
877pub fn timestamp() -> String {
879 Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
880}
881
882pub 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 fs::create_dir_all(AGENT_DIR)?;
901
902 let checkpoint_path_str = checkpoint_path();
904 let temp_path = format!("{checkpoint_path_str}.tmp");
905
906 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
922pub 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
956pub 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
973pub fn checkpoint_exists() -> bool {
977 Path::new(&checkpoint_path()).exists()
978}
979
980pub 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
1002pub 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 workspace.create_dir_all(Path::new(AGENT_DIR))?;
1030
1031 workspace.write(Path::new(&checkpoint_path()), &json)
1033}
1034
1035pub 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
1063pub 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
1078pub 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 #[cfg(feature = "test-utils")]
1094 mod workspace_tests {
1095 use super::*;
1096 use crate::workspace::MemoryWorkspace;
1097 use std::path::Path;
1098
1099 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 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 clear_checkpoint_with_workspace(&workspace).unwrap();
1246 }
1247
1248 #[test]
1249 fn test_load_checkpoint_with_workspace_preserves_working_dir() {
1250 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 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}