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 review_depth: Option<String>,
71 pub skip_rebase: bool,
73 #[serde(default = "default_isolation_mode")]
76 pub isolation_mode: bool,
77 #[serde(default = "default_verbosity")]
80 pub verbosity: u8,
81 #[serde(default)]
84 pub show_streaming_metrics: bool,
85 #[serde(default)]
87 pub reviewer_json_parser: Option<String>,
88}
89
90fn default_isolation_mode() -> bool {
92 true
93}
94
95fn default_verbosity() -> u8 {
97 2
98}
99
100pub struct CliArgsSnapshotBuilder {
105 developer_iters: u32,
106 reviewer_reviews: u32,
107 review_depth: Option<String>,
108 skip_rebase: bool,
109 isolation_mode: bool,
110 verbosity: u8,
111 show_streaming_metrics: bool,
112 reviewer_json_parser: Option<String>,
113}
114
115impl CliArgsSnapshotBuilder {
116 pub fn new(
118 developer_iters: u32,
119 reviewer_reviews: u32,
120 review_depth: Option<String>,
121 skip_rebase: bool,
122 isolation_mode: bool,
123 ) -> Self {
124 Self {
125 developer_iters,
126 reviewer_reviews,
127 review_depth,
128 skip_rebase,
129 isolation_mode,
130 verbosity: 2,
131 show_streaming_metrics: false,
132 reviewer_json_parser: None,
133 }
134 }
135
136 pub fn verbosity(mut self, verbosity: u8) -> Self {
138 self.verbosity = verbosity;
139 self
140 }
141
142 pub fn show_streaming_metrics(mut self, show: bool) -> Self {
144 self.show_streaming_metrics = show;
145 self
146 }
147
148 pub fn reviewer_json_parser(mut self, parser: Option<String>) -> Self {
150 self.reviewer_json_parser = parser;
151 self
152 }
153
154 pub fn build(self) -> CliArgsSnapshot {
156 CliArgsSnapshot {
157 developer_iters: self.developer_iters,
158 reviewer_reviews: self.reviewer_reviews,
159 review_depth: self.review_depth,
160 skip_rebase: self.skip_rebase,
161 isolation_mode: self.isolation_mode,
162 verbosity: self.verbosity,
163 show_streaming_metrics: self.show_streaming_metrics,
164 reviewer_json_parser: self.reviewer_json_parser,
165 }
166 }
167}
168
169impl CliArgsSnapshot {
170 #[cfg(test)]
175 pub fn new(
176 developer_iters: u32,
177 reviewer_reviews: u32,
178 review_depth: Option<String>,
179 skip_rebase: bool,
180 isolation_mode: bool,
181 verbosity: u8,
182 show_streaming_metrics: bool,
183 reviewer_json_parser: Option<String>,
184 ) -> Self {
185 CliArgsSnapshotBuilder::new(
186 developer_iters,
187 reviewer_reviews,
188 review_depth,
189 skip_rebase,
190 isolation_mode,
191 )
192 .verbosity(verbosity)
193 .show_streaming_metrics(show_streaming_metrics)
194 .reviewer_json_parser(reviewer_json_parser)
195 .build()
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct AgentConfigSnapshot {
205 pub name: String,
207 pub cmd: String,
209 pub output_flag: String,
211 pub yolo_flag: Option<String>,
213 pub can_commit: bool,
215 #[serde(default)]
218 pub model_override: Option<String>,
219 #[serde(default)]
222 pub provider_override: Option<String>,
223 #[serde(default = "default_context_level")]
226 pub context_level: u8,
227}
228
229fn default_context_level() -> u8 {
231 1
232}
233
234impl AgentConfigSnapshot {
235 pub fn new(
237 name: String,
238 cmd: String,
239 output_flag: String,
240 yolo_flag: Option<String>,
241 can_commit: bool,
242 ) -> Self {
243 Self {
244 name,
245 cmd,
246 output_flag,
247 yolo_flag,
248 can_commit,
249 model_override: None,
250 provider_override: None,
251 context_level: default_context_level(),
252 }
253 }
254
255 pub fn with_model_override(mut self, model: Option<String>) -> Self {
257 self.model_override = model;
258 self
259 }
260
261 pub fn with_provider_override(mut self, provider: Option<String>) -> Self {
263 self.provider_override = provider;
264 self
265 }
266
267 pub fn with_context_level(mut self, level: u8) -> Self {
269 self.context_level = level;
270 self
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, Default)]
280pub struct EnvironmentSnapshot {
281 #[serde(default)]
283 pub ralph_vars: HashMap<String, String>,
284 #[serde(default)]
286 pub other_vars: HashMap<String, String>,
287}
288
289impl EnvironmentSnapshot {
290 pub fn capture_current() -> Self {
292 let mut ralph_vars = HashMap::new();
293 let mut other_vars = HashMap::new();
294
295 for (key, value) in std::env::vars() {
297 if key.starts_with("RALPH_") {
298 ralph_vars.insert(key, value);
299 }
300 }
301
302 let relevant_keys = [
304 "EDITOR",
305 "VISUAL",
306 "GIT_AUTHOR_NAME",
307 "GIT_AUTHOR_EMAIL",
308 "GIT_COMMITTER_NAME",
309 "GIT_COMMITTER_EMAIL",
310 ];
311 for key in &relevant_keys {
312 if let Ok(value) = std::env::var(key) {
313 other_vars.insert(key.to_string(), value);
314 }
315 }
316
317 Self {
318 ralph_vars,
319 other_vars,
320 }
321 }
322}
323
324pub struct CheckpointParams<'a> {
329 pub phase: PipelinePhase,
331 pub iteration: u32,
333 pub total_iterations: u32,
335 pub reviewer_pass: u32,
337 pub total_reviewer_passes: u32,
339 pub developer_agent: &'a str,
341 pub reviewer_agent: &'a str,
343 pub cli_args: CliArgsSnapshot,
345 pub developer_agent_config: AgentConfigSnapshot,
347 pub reviewer_agent_config: AgentConfigSnapshot,
349 pub rebase_state: RebaseState,
351 pub git_user_name: Option<&'a str>,
353 pub git_user_email: Option<&'a str>,
355 pub run_id: &'a str,
357 pub parent_run_id: Option<&'a str>,
359 pub resume_count: u32,
361 pub actual_developer_runs: u32,
363 pub actual_reviewer_runs: u32,
365}
366
367#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
372pub enum RebaseState {
373 #[default]
375 NotStarted,
376 PreRebaseInProgress { upstream_branch: String },
378 PreRebaseCompleted { commit_oid: String },
380 PostRebaseInProgress { upstream_branch: String },
382 PostRebaseCompleted { commit_oid: String },
384 HasConflicts { files: Vec<String> },
386 Failed { error: String },
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
395pub enum PipelinePhase {
396 Rebase,
398 Planning,
400 Development,
402 Review,
404 Fix,
406 ReviewAgain,
408 CommitMessage,
410 FinalValidation,
412 Complete,
414 PreRebase,
416 PreRebaseConflict,
418 PostRebase,
420 PostRebaseConflict,
422 Interrupted,
424}
425
426impl std::fmt::Display for PipelinePhase {
427 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428 match self {
429 Self::Rebase => write!(f, "Rebase"),
430 Self::Planning => write!(f, "Planning"),
431 Self::Development => write!(f, "Development"),
432 Self::Review => write!(f, "Review"),
433 Self::Fix => write!(f, "Fix"),
434 Self::ReviewAgain => write!(f, "Verification Review"),
435 Self::CommitMessage => write!(f, "Commit Message Generation"),
436 Self::FinalValidation => write!(f, "Final Validation"),
437 Self::Complete => write!(f, "Complete"),
438 Self::PreRebase => write!(f, "Pre-Rebase"),
439 Self::PreRebaseConflict => write!(f, "Pre-Rebase Conflict"),
440 Self::PostRebase => write!(f, "Post-Rebase"),
441 Self::PostRebaseConflict => write!(f, "Post-Rebase Conflict"),
442 Self::Interrupted => write!(f, "Interrupted"),
443 }
444 }
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct PipelineCheckpoint {
457 pub version: u32,
459
460 pub phase: PipelinePhase,
463 pub iteration: u32,
465 pub total_iterations: u32,
467 pub reviewer_pass: u32,
469 pub total_reviewer_passes: u32,
471
472 pub timestamp: String,
475 pub developer_agent: String,
477 pub reviewer_agent: String,
479
480 pub cli_args: CliArgsSnapshot,
483 pub developer_agent_config: AgentConfigSnapshot,
485 pub reviewer_agent_config: AgentConfigSnapshot,
487 pub rebase_state: RebaseState,
489
490 pub config_path: Option<String>,
493 pub config_checksum: Option<String>,
495 pub working_dir: String,
497 pub prompt_md_checksum: Option<String>,
499
500 pub git_user_name: Option<String>,
503 pub git_user_email: Option<String>,
505
506 pub run_id: String,
509 pub parent_run_id: Option<String>,
511 pub resume_count: u32,
513
514 pub actual_developer_runs: u32,
517 pub actual_reviewer_runs: u32,
519
520 #[serde(skip_serializing_if = "Option::is_none")]
523 pub execution_history: Option<crate::checkpoint::ExecutionHistory>,
524 #[serde(skip_serializing_if = "Option::is_none")]
526 pub file_system_state: Option<crate::checkpoint::FileSystemState>,
527 #[serde(skip_serializing_if = "Option::is_none")]
529 pub prompt_history: Option<std::collections::HashMap<String, String>>,
530 #[serde(skip_serializing_if = "Option::is_none")]
532 pub env_snapshot: Option<EnvironmentSnapshot>,
533}
534
535impl PipelineCheckpoint {
536 pub fn from_params(params: CheckpointParams<'_>) -> Self {
545 let working_dir = std::env::current_dir()
547 .map(|p| p.to_string_lossy().to_string())
548 .unwrap_or_default();
549
550 let prompt_md_checksum = calculate_file_checksum(Path::new("PROMPT.md"));
552
553 Self {
554 version: CHECKPOINT_VERSION,
555 phase: params.phase,
556 iteration: params.iteration,
557 total_iterations: params.total_iterations,
558 reviewer_pass: params.reviewer_pass,
559 total_reviewer_passes: params.total_reviewer_passes,
560 timestamp: timestamp(),
561 developer_agent: params.developer_agent.to_string(),
562 reviewer_agent: params.reviewer_agent.to_string(),
563 cli_args: params.cli_args,
564 developer_agent_config: params.developer_agent_config,
565 reviewer_agent_config: params.reviewer_agent_config,
566 rebase_state: params.rebase_state,
567 config_path: None, config_checksum: None, working_dir,
570 prompt_md_checksum,
571 git_user_name: params.git_user_name.map(String::from),
572 git_user_email: params.git_user_email.map(String::from),
573 run_id: params.run_id.to_string(),
575 parent_run_id: params.parent_run_id.map(String::from),
576 resume_count: params.resume_count,
577 actual_developer_runs: params.actual_developer_runs,
578 actual_reviewer_runs: params.actual_reviewer_runs,
579 execution_history: None,
581 file_system_state: None,
582 prompt_history: None,
583 env_snapshot: None,
584 }
585 }
586
587 pub fn description(&self) -> String {
592 match self.phase {
593 PipelinePhase::Rebase => "Rebase in progress".to_string(),
594 PipelinePhase::Planning => {
595 format!(
596 "Planning phase, iteration {}/{}",
597 self.iteration, self.total_iterations
598 )
599 }
600 PipelinePhase::Development => {
601 format!(
602 "Development iteration {}/{}",
603 self.iteration, self.total_iterations
604 )
605 }
606 PipelinePhase::Review => "Initial review".to_string(),
607 PipelinePhase::Fix => "Applying fixes".to_string(),
608 PipelinePhase::ReviewAgain => {
609 format!(
610 "Verification review {}/{}",
611 self.reviewer_pass, self.total_reviewer_passes
612 )
613 }
614 PipelinePhase::CommitMessage => "Commit message generation".to_string(),
615 PipelinePhase::FinalValidation => "Final validation".to_string(),
616 PipelinePhase::Complete => "Pipeline complete".to_string(),
617 PipelinePhase::PreRebase => "Pre-development rebase".to_string(),
618 PipelinePhase::PreRebaseConflict => "Pre-rebase conflict resolution".to_string(),
619 PipelinePhase::PostRebase => "Post-review rebase".to_string(),
620 PipelinePhase::PostRebaseConflict => "Post-rebase conflict resolution".to_string(),
621 PipelinePhase::Interrupted => {
622 let mut parts = vec!["Interrupted".to_string()];
624
625 if self.iteration > 0 && self.iteration < self.total_iterations {
627 parts.push(format!(
628 "during development (iteration {}/{})",
629 self.iteration, self.total_iterations
630 ));
631 } else if self.iteration >= self.total_iterations {
632 if self.reviewer_pass > 0 {
633 parts.push(format!(
634 "during review (pass {}/{})",
635 self.reviewer_pass, self.total_reviewer_passes
636 ));
637 } else {
638 parts.push("after development phase".to_string());
639 }
640 } else {
641 parts.push("during pipeline initialization".to_string());
642 }
643
644 parts.join(" ")
645 }
646 }
647 }
648
649 pub fn with_config(mut self, path: Option<std::path::PathBuf>) -> Self {
651 if let Some(p) = path {
652 self.config_path = Some(p.to_string_lossy().to_string());
653 self.config_checksum = calculate_file_checksum(&p);
654 }
655 self
656 }
657}
658
659fn load_checkpoint_with_fallback(
661 content: &str,
662) -> Result<PipelineCheckpoint, Box<dyn std::error::Error>> {
663 match serde_json::from_str::<PipelineCheckpoint>(content) {
665 Ok(mut checkpoint) => {
666 if checkpoint.version >= 3 {
668 return Ok(checkpoint);
669 }
670 checkpoint.version = CHECKPOINT_VERSION;
673 return Ok(checkpoint);
674 }
675 Err(_e) => {
676 }
678 }
679
680 #[derive(Debug, Clone, Serialize, Deserialize)]
682 struct V2Checkpoint {
683 version: u32,
684 phase: PipelinePhase,
685 iteration: u32,
686 total_iterations: u32,
687 reviewer_pass: u32,
688 total_reviewer_passes: u32,
689 timestamp: String,
690 developer_agent: String,
691 reviewer_agent: String,
692 cli_args: CliArgsSnapshot,
693 developer_agent_config: AgentConfigSnapshot,
694 reviewer_agent_config: AgentConfigSnapshot,
695 rebase_state: RebaseState,
696 config_path: Option<String>,
697 config_checksum: Option<String>,
698 working_dir: String,
699 prompt_md_checksum: Option<String>,
700 git_user_name: Option<String>,
701 git_user_email: Option<String>,
702 run_id: String,
703 parent_run_id: Option<String>,
704 resume_count: u32,
705 actual_developer_runs: u32,
706 actual_reviewer_runs: u32,
707 }
708
709 if let Ok(v2) = serde_json::from_str::<V2Checkpoint>(content) {
710 return Ok(PipelineCheckpoint {
713 version: CHECKPOINT_VERSION,
714 phase: v2.phase,
715 iteration: v2.iteration,
716 total_iterations: v2.total_iterations,
717 reviewer_pass: v2.reviewer_pass,
718 total_reviewer_passes: v2.total_reviewer_passes,
719 timestamp: v2.timestamp,
720 developer_agent: v2.developer_agent,
721 reviewer_agent: v2.reviewer_agent,
722 cli_args: v2.cli_args,
723 developer_agent_config: v2.developer_agent_config,
724 reviewer_agent_config: v2.reviewer_agent_config,
725 rebase_state: v2.rebase_state,
726 config_path: v2.config_path,
727 config_checksum: v2.config_checksum,
728 working_dir: v2.working_dir,
729 prompt_md_checksum: v2.prompt_md_checksum,
730 git_user_name: v2.git_user_name,
731 git_user_email: v2.git_user_email,
732 run_id: v2.run_id,
733 parent_run_id: v2.parent_run_id,
734 resume_count: v2.resume_count,
735 actual_developer_runs: v2.actual_developer_runs,
736 actual_reviewer_runs: v2.actual_reviewer_runs,
737 execution_history: None,
739 file_system_state: None,
740 prompt_history: None,
741 env_snapshot: None,
742 });
743 }
744
745 #[derive(Debug, Clone, Serialize, Deserialize)]
747 struct V1Checkpoint {
748 version: u32,
749 phase: PipelinePhase,
750 iteration: u32,
751 total_iterations: u32,
752 reviewer_pass: u32,
753 total_reviewer_passes: u32,
754 timestamp: String,
755 developer_agent: String,
756 reviewer_agent: String,
757 cli_args: CliArgsSnapshot,
758 developer_agent_config: AgentConfigSnapshot,
759 reviewer_agent_config: AgentConfigSnapshot,
760 rebase_state: RebaseState,
761 config_path: Option<String>,
762 config_checksum: Option<String>,
763 working_dir: String,
764 prompt_md_checksum: Option<String>,
765 git_user_name: Option<String>,
766 git_user_email: Option<String>,
767 }
768
769 if let Ok(v1) = serde_json::from_str::<V1Checkpoint>(content) {
770 let new_run_id = uuid::Uuid::new_v4().to_string();
772 return Ok(PipelineCheckpoint {
773 version: CHECKPOINT_VERSION,
774 phase: v1.phase,
775 iteration: v1.iteration,
776 total_iterations: v1.total_iterations,
777 reviewer_pass: v1.reviewer_pass,
778 total_reviewer_passes: v1.total_reviewer_passes,
779 timestamp: v1.timestamp,
780 developer_agent: v1.developer_agent,
781 reviewer_agent: v1.reviewer_agent,
782 cli_args: v1.cli_args,
783 developer_agent_config: v1.developer_agent_config,
784 reviewer_agent_config: v1.reviewer_agent_config,
785 rebase_state: v1.rebase_state,
786 config_path: v1.config_path,
787 config_checksum: v1.config_checksum,
788 working_dir: v1.working_dir,
789 prompt_md_checksum: v1.prompt_md_checksum,
790 git_user_name: v1.git_user_name,
791 git_user_email: v1.git_user_email,
792 run_id: new_run_id,
794 parent_run_id: None,
795 resume_count: 0,
796 actual_developer_runs: v1.iteration,
797 actual_reviewer_runs: v1.reviewer_pass,
798 execution_history: None,
800 file_system_state: None,
801 prompt_history: None,
802 env_snapshot: None,
803 });
804 }
805
806 #[derive(Debug, Clone, Serialize, Deserialize)]
808 struct LegacyCheckpoint {
809 phase: PipelinePhase,
810 iteration: u32,
811 total_iterations: u32,
812 reviewer_pass: u32,
813 total_reviewer_passes: u32,
814 timestamp: String,
815 developer_agent: String,
816 reviewer_agent: String,
817 }
818
819 if let Ok(legacy) = serde_json::from_str::<LegacyCheckpoint>(content) {
820 let new_run_id = uuid::Uuid::new_v4().to_string();
821 return Ok(PipelineCheckpoint {
822 version: CHECKPOINT_VERSION,
823 phase: legacy.phase,
824 iteration: legacy.iteration,
825 total_iterations: legacy.total_iterations,
826 reviewer_pass: legacy.reviewer_pass,
827 total_reviewer_passes: legacy.total_reviewer_passes,
828 timestamp: legacy.timestamp,
829 developer_agent: legacy.developer_agent.clone(),
830 reviewer_agent: legacy.reviewer_agent.clone(),
831 cli_args: CliArgsSnapshotBuilder::new(0, 0, None, false, true).build(),
832 developer_agent_config: AgentConfigSnapshot::new(
833 legacy.developer_agent.clone(),
834 String::new(),
835 String::new(),
836 None,
837 false,
838 ),
839 reviewer_agent_config: AgentConfigSnapshot::new(
840 legacy.reviewer_agent.clone(),
841 String::new(),
842 String::new(),
843 None,
844 false,
845 ),
846 rebase_state: RebaseState::default(),
847 config_path: None,
848 config_checksum: None,
849 working_dir: String::new(),
850 prompt_md_checksum: None,
851 git_user_name: None,
852 git_user_email: None,
853 run_id: new_run_id,
854 parent_run_id: None,
855 resume_count: 0,
856 actual_developer_runs: legacy.iteration,
857 actual_reviewer_runs: legacy.reviewer_pass,
858 execution_history: None,
860 file_system_state: None,
861 prompt_history: None,
862 env_snapshot: None,
863 });
864 }
865
866 Err("Invalid checkpoint format".into())
867}
868
869pub fn timestamp() -> String {
871 Local::now().format("%Y-%m-%d %H:%M:%S").to_string()
872}
873
874pub fn save_checkpoint(checkpoint: &PipelineCheckpoint) -> io::Result<()> {
884 let json = serde_json::to_string_pretty(checkpoint).map_err(|e| {
885 io::Error::new(
886 io::ErrorKind::InvalidData,
887 format!("Failed to serialize checkpoint: {e}"),
888 )
889 })?;
890
891 fs::create_dir_all(AGENT_DIR)?;
893
894 let checkpoint_path_str = checkpoint_path();
896 let temp_path = format!("{checkpoint_path_str}.tmp");
897
898 let write_result = fs::write(&temp_path, &json);
900 if write_result.is_err() {
901 let _ = fs::remove_file(&temp_path);
902 return write_result;
903 }
904
905 let rename_result = fs::rename(&temp_path, &checkpoint_path_str);
906 if rename_result.is_err() {
907 let _ = fs::remove_file(&temp_path);
908 return rename_result;
909 }
910
911 Ok(())
912}
913
914pub fn load_checkpoint() -> io::Result<Option<PipelineCheckpoint>> {
931 let checkpoint = checkpoint_path();
932 let path = Path::new(&checkpoint);
933 if !path.exists() {
934 return Ok(None);
935 }
936
937 let content = fs::read_to_string(path)?;
938 let loaded_checkpoint = load_checkpoint_with_fallback(&content).map_err(|e| {
939 io::Error::new(
940 io::ErrorKind::InvalidData,
941 format!("Failed to parse checkpoint: {e}"),
942 )
943 })?;
944
945 Ok(Some(loaded_checkpoint))
946}
947
948pub fn clear_checkpoint() -> io::Result<()> {
957 let checkpoint = checkpoint_path();
958 let path = Path::new(&checkpoint);
959 if path.exists() {
960 fs::remove_file(path)?;
961 }
962 Ok(())
963}
964
965pub fn checkpoint_exists() -> bool {
969 Path::new(&checkpoint_path()).exists()
970}
971
972pub fn calculate_file_checksum_with_workspace(
987 workspace: &dyn Workspace,
988 path: &Path,
989) -> Option<String> {
990 let content = workspace.read_bytes(path).ok()?;
991 Some(calculate_checksum_from_bytes(&content))
992}
993
994pub fn save_checkpoint_with_workspace(
1010 workspace: &dyn Workspace,
1011 checkpoint: &PipelineCheckpoint,
1012) -> io::Result<()> {
1013 let json = serde_json::to_string_pretty(checkpoint).map_err(|e| {
1014 io::Error::new(
1015 io::ErrorKind::InvalidData,
1016 format!("Failed to serialize checkpoint: {e}"),
1017 )
1018 })?;
1019
1020 workspace.create_dir_all(Path::new(AGENT_DIR))?;
1022
1023 workspace.write(Path::new(&checkpoint_path()), &json)
1025}
1026
1027pub fn load_checkpoint_with_workspace(
1035 workspace: &dyn Workspace,
1036) -> io::Result<Option<PipelineCheckpoint>> {
1037 let checkpoint_path_str = checkpoint_path();
1038 let checkpoint_file = Path::new(&checkpoint_path_str);
1039
1040 if !workspace.exists(checkpoint_file) {
1041 return Ok(None);
1042 }
1043
1044 let content = workspace.read(checkpoint_file)?;
1045 let loaded_checkpoint = load_checkpoint_with_fallback(&content).map_err(|e| {
1046 io::Error::new(
1047 io::ErrorKind::InvalidData,
1048 format!("Failed to parse checkpoint: {e}"),
1049 )
1050 })?;
1051
1052 Ok(Some(loaded_checkpoint))
1053}
1054
1055pub fn clear_checkpoint_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
1061 let checkpoint_path_str = checkpoint_path();
1062 let checkpoint_file = Path::new(&checkpoint_path_str);
1063
1064 if workspace.exists(checkpoint_file) {
1065 workspace.remove(checkpoint_file)?;
1066 }
1067 Ok(())
1068}
1069
1070pub fn checkpoint_exists_with_workspace(workspace: &dyn Workspace) -> bool {
1074 workspace.exists(Path::new(&checkpoint_path()))
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080
1081 #[cfg(feature = "test-utils")]
1086 mod workspace_tests {
1087 use super::*;
1088 use crate::workspace::MemoryWorkspace;
1089 use std::path::Path;
1090
1091 fn make_test_checkpoint_for_workspace(
1093 phase: PipelinePhase,
1094 iteration: u32,
1095 ) -> PipelineCheckpoint {
1096 let cli_args = CliArgsSnapshot::new(5, 2, None, false, true, 2, false, None);
1097 let dev_config =
1098 AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
1099 let rev_config =
1100 AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
1101 let run_id = uuid::Uuid::new_v4().to_string();
1102 PipelineCheckpoint::from_params(CheckpointParams {
1103 phase,
1104 iteration,
1105 total_iterations: 5,
1106 reviewer_pass: 0,
1107 total_reviewer_passes: 2,
1108 developer_agent: "claude",
1109 reviewer_agent: "codex",
1110 cli_args,
1111 developer_agent_config: dev_config,
1112 reviewer_agent_config: rev_config,
1113 rebase_state: RebaseState::default(),
1114 git_user_name: None,
1115 git_user_email: None,
1116 run_id: &run_id,
1117 parent_run_id: None,
1118 resume_count: 0,
1119 actual_developer_runs: iteration,
1120 actual_reviewer_runs: 0,
1121 })
1122 }
1123
1124 #[test]
1125 fn test_calculate_file_checksum_with_workspace() {
1126 let workspace = MemoryWorkspace::new_test().with_file("test.txt", "test content");
1127
1128 let checksum =
1129 calculate_file_checksum_with_workspace(&workspace, Path::new("test.txt"));
1130 assert!(checksum.is_some());
1131
1132 let workspace2 = MemoryWorkspace::new_test().with_file("other.txt", "test content");
1134 let checksum2 =
1135 calculate_file_checksum_with_workspace(&workspace2, Path::new("other.txt"));
1136 assert_eq!(checksum, checksum2);
1137 }
1138
1139 #[test]
1140 fn test_calculate_file_checksum_with_workspace_different_content() {
1141 let workspace1 = MemoryWorkspace::new_test().with_file("test.txt", "content A");
1142 let workspace2 = MemoryWorkspace::new_test().with_file("test.txt", "content B");
1143
1144 let checksum1 =
1145 calculate_file_checksum_with_workspace(&workspace1, Path::new("test.txt"));
1146 let checksum2 =
1147 calculate_file_checksum_with_workspace(&workspace2, Path::new("test.txt"));
1148
1149 assert!(checksum1.is_some());
1150 assert!(checksum2.is_some());
1151 assert_ne!(checksum1, checksum2);
1152 }
1153
1154 #[test]
1155 fn test_calculate_file_checksum_with_workspace_nonexistent() {
1156 let workspace = MemoryWorkspace::new_test();
1157
1158 let checksum =
1159 calculate_file_checksum_with_workspace(&workspace, Path::new("nonexistent.txt"));
1160 assert!(checksum.is_none());
1161 }
1162
1163 #[test]
1164 fn test_save_checkpoint_with_workspace() {
1165 let workspace = MemoryWorkspace::new_test();
1166 let checkpoint = make_test_checkpoint_for_workspace(PipelinePhase::Development, 2);
1167
1168 save_checkpoint_with_workspace(&workspace, &checkpoint).unwrap();
1169
1170 assert!(workspace.exists(Path::new(".agent/checkpoint.json")));
1171 }
1172
1173 #[test]
1174 fn test_checkpoint_exists_with_workspace() {
1175 let workspace = MemoryWorkspace::new_test();
1176
1177 assert!(!checkpoint_exists_with_workspace(&workspace));
1178
1179 let checkpoint = make_test_checkpoint_for_workspace(PipelinePhase::Development, 1);
1180 save_checkpoint_with_workspace(&workspace, &checkpoint).unwrap();
1181
1182 assert!(checkpoint_exists_with_workspace(&workspace));
1183 }
1184
1185 #[test]
1186 fn test_load_checkpoint_with_workspace_nonexistent() {
1187 let workspace = MemoryWorkspace::new_test();
1188
1189 let result = load_checkpoint_with_workspace(&workspace).unwrap();
1190 assert!(result.is_none());
1191 }
1192
1193 #[test]
1194 fn test_save_and_load_checkpoint_with_workspace() {
1195 let workspace = MemoryWorkspace::new_test();
1196 let checkpoint = make_test_checkpoint_for_workspace(PipelinePhase::Review, 5);
1197
1198 save_checkpoint_with_workspace(&workspace, &checkpoint).unwrap();
1199
1200 let loaded = load_checkpoint_with_workspace(&workspace)
1201 .unwrap()
1202 .expect("checkpoint should exist");
1203
1204 assert_eq!(loaded.phase, PipelinePhase::Review);
1205 assert_eq!(loaded.iteration, 5);
1206 assert_eq!(loaded.developer_agent, "claude");
1207 assert_eq!(loaded.reviewer_agent, "codex");
1208 }
1209
1210 #[test]
1211 fn test_clear_checkpoint_with_workspace() {
1212 let workspace = MemoryWorkspace::new_test();
1213 let checkpoint = make_test_checkpoint_for_workspace(PipelinePhase::Development, 1);
1214
1215 save_checkpoint_with_workspace(&workspace, &checkpoint).unwrap();
1216 assert!(checkpoint_exists_with_workspace(&workspace));
1217
1218 clear_checkpoint_with_workspace(&workspace).unwrap();
1219 assert!(!checkpoint_exists_with_workspace(&workspace));
1220 }
1221
1222 #[test]
1223 fn test_clear_checkpoint_with_workspace_nonexistent() {
1224 let workspace = MemoryWorkspace::new_test();
1225
1226 clear_checkpoint_with_workspace(&workspace).unwrap();
1228 }
1229
1230 #[test]
1231 fn test_load_checkpoint_with_workspace_preserves_working_dir() {
1232 let json = r#"{
1234 "version": 1,
1235 "phase": "Development",
1236 "iteration": 1,
1237 "total_iterations": 1,
1238 "reviewer_pass": 0,
1239 "total_reviewer_passes": 0,
1240 "timestamp": "2024-01-01 12:00:00",
1241 "developer_agent": "test-agent",
1242 "reviewer_agent": "test-agent",
1243 "cli_args": {
1244 "developer_iters": 1,
1245 "reviewer_reviews": 0,
1246 "commit_msg": "",
1247 "review_depth": null,
1248 "skip_rebase": false
1249 },
1250 "developer_agent_config": {
1251 "name": "test-agent",
1252 "cmd": "echo",
1253 "output_flag": "",
1254 "yolo_flag": null,
1255 "can_commit": false,
1256 "model_override": null,
1257 "provider_override": null,
1258 "context_level": 1
1259 },
1260 "reviewer_agent_config": {
1261 "name": "test-agent",
1262 "cmd": "echo",
1263 "output_flag": "",
1264 "yolo_flag": null,
1265 "can_commit": false,
1266 "model_override": null,
1267 "provider_override": null,
1268 "context_level": 1
1269 },
1270 "rebase_state": "NotStarted",
1271 "config_path": null,
1272 "config_checksum": null,
1273 "working_dir": "/some/other/directory",
1274 "prompt_md_checksum": null,
1275 "git_user_name": null,
1276 "git_user_email": null
1277 }"#;
1278
1279 let workspace = MemoryWorkspace::new_test().with_file(".agent/checkpoint.json", json);
1280
1281 let loaded = load_checkpoint_with_workspace(&workspace)
1282 .unwrap()
1283 .expect("should load checkpoint");
1284 assert_eq!(
1285 loaded.working_dir, "/some/other/directory",
1286 "working_dir should be preserved from JSON"
1287 );
1288 }
1289 }
1290
1291 fn make_test_checkpoint(phase: PipelinePhase, iteration: u32) -> PipelineCheckpoint {
1297 let cli_args = CliArgsSnapshot::new(5, 2, None, false, true, 2, false, None);
1298 let dev_config =
1299 AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
1300 let rev_config =
1301 AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
1302 let run_id = uuid::Uuid::new_v4().to_string();
1303 PipelineCheckpoint::from_params(CheckpointParams {
1304 phase,
1305 iteration,
1306 total_iterations: 5,
1307 reviewer_pass: 0,
1308 total_reviewer_passes: 2,
1309 developer_agent: "claude",
1310 reviewer_agent: "codex",
1311 cli_args,
1312 developer_agent_config: dev_config,
1313 reviewer_agent_config: rev_config,
1314 rebase_state: RebaseState::default(),
1315 git_user_name: None,
1316 git_user_email: None,
1317 run_id: &run_id,
1318 parent_run_id: None,
1319 resume_count: 0,
1320 actual_developer_runs: iteration,
1321 actual_reviewer_runs: 0,
1322 })
1323 }
1324
1325 #[test]
1326 fn test_timestamp_format() {
1327 let ts = timestamp();
1328 assert!(ts.contains('-'));
1329 assert!(ts.contains(':'));
1330 assert_eq!(ts.len(), 19);
1331 }
1332
1333 #[test]
1334 fn test_pipeline_phase_display() {
1335 assert_eq!(format!("{}", PipelinePhase::Rebase), "Rebase");
1336 assert_eq!(format!("{}", PipelinePhase::Planning), "Planning");
1337 assert_eq!(format!("{}", PipelinePhase::Development), "Development");
1338 assert_eq!(format!("{}", PipelinePhase::Review), "Review");
1339 assert_eq!(format!("{}", PipelinePhase::Fix), "Fix");
1340 assert_eq!(
1341 format!("{}", PipelinePhase::ReviewAgain),
1342 "Verification Review"
1343 );
1344 assert_eq!(
1345 format!("{}", PipelinePhase::CommitMessage),
1346 "Commit Message Generation"
1347 );
1348 assert_eq!(
1349 format!("{}", PipelinePhase::FinalValidation),
1350 "Final Validation"
1351 );
1352 assert_eq!(format!("{}", PipelinePhase::Complete), "Complete");
1353 assert_eq!(format!("{}", PipelinePhase::PreRebase), "Pre-Rebase");
1354 assert_eq!(
1355 format!("{}", PipelinePhase::PreRebaseConflict),
1356 "Pre-Rebase Conflict"
1357 );
1358 assert_eq!(format!("{}", PipelinePhase::PostRebase), "Post-Rebase");
1359 assert_eq!(
1360 format!("{}", PipelinePhase::PostRebaseConflict),
1361 "Post-Rebase Conflict"
1362 );
1363 assert_eq!(format!("{}", PipelinePhase::Interrupted), "Interrupted");
1364 }
1365
1366 #[test]
1367 fn test_checkpoint_from_params() {
1368 let cli_args = CliArgsSnapshot::new(5, 2, None, false, true, 2, false, None);
1369 let dev_config =
1370 AgentConfigSnapshot::new("claude".into(), "cmd".into(), "-o".into(), None, true);
1371 let rev_config =
1372 AgentConfigSnapshot::new("codex".into(), "cmd".into(), "-o".into(), None, true);
1373 let run_id = uuid::Uuid::new_v4().to_string();
1374 let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1375 phase: PipelinePhase::Development,
1376 iteration: 2,
1377 total_iterations: 5,
1378 reviewer_pass: 0,
1379 total_reviewer_passes: 2,
1380 developer_agent: "claude",
1381 reviewer_agent: "codex",
1382 cli_args,
1383 developer_agent_config: dev_config,
1384 reviewer_agent_config: rev_config,
1385 rebase_state: RebaseState::default(),
1386 git_user_name: None,
1387 git_user_email: None,
1388 run_id: &run_id,
1389 parent_run_id: None,
1390 resume_count: 0,
1391 actual_developer_runs: 2,
1392 actual_reviewer_runs: 0,
1393 });
1394
1395 assert_eq!(checkpoint.phase, PipelinePhase::Development);
1396 assert_eq!(checkpoint.iteration, 2);
1397 assert_eq!(checkpoint.total_iterations, 5);
1398 assert_eq!(checkpoint.reviewer_pass, 0);
1399 assert_eq!(checkpoint.total_reviewer_passes, 2);
1400 assert_eq!(checkpoint.developer_agent, "claude");
1401 assert_eq!(checkpoint.reviewer_agent, "codex");
1402 assert_eq!(checkpoint.version, CHECKPOINT_VERSION);
1403 assert!(!checkpoint.timestamp.is_empty());
1404 assert_eq!(checkpoint.run_id, run_id);
1405 assert_eq!(checkpoint.resume_count, 0);
1406 assert_eq!(checkpoint.actual_developer_runs, 2);
1407 assert!(checkpoint.parent_run_id.is_none());
1408 }
1409
1410 #[test]
1411 fn test_checkpoint_description() {
1412 let checkpoint = make_test_checkpoint(PipelinePhase::Development, 3);
1413 assert_eq!(checkpoint.description(), "Development iteration 3/5");
1414
1415 let run_id = uuid::Uuid::new_v4().to_string();
1416 let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1417 phase: PipelinePhase::ReviewAgain,
1418 iteration: 5,
1419 total_iterations: 5,
1420 reviewer_pass: 2,
1421 total_reviewer_passes: 3,
1422 developer_agent: "claude",
1423 reviewer_agent: "codex",
1424 cli_args: CliArgsSnapshot::new(5, 3, None, false, true, 2, false, None),
1425 developer_agent_config: AgentConfigSnapshot::new(
1426 "claude".into(),
1427 "cmd".into(),
1428 "-o".into(),
1429 None,
1430 true,
1431 ),
1432 reviewer_agent_config: AgentConfigSnapshot::new(
1433 "codex".into(),
1434 "cmd".into(),
1435 "-o".into(),
1436 None,
1437 true,
1438 ),
1439 rebase_state: RebaseState::default(),
1440 git_user_name: None,
1441 git_user_email: None,
1442 run_id: &run_id,
1443 parent_run_id: None,
1444 resume_count: 0,
1445 actual_developer_runs: 5,
1446 actual_reviewer_runs: 2,
1447 });
1448 assert_eq!(checkpoint.description(), "Verification review 2/3");
1449 }
1450
1451 #[test]
1452 fn test_checkpoint_serialization() {
1453 let run_id = uuid::Uuid::new_v4().to_string();
1454 let checkpoint = PipelineCheckpoint::from_params(CheckpointParams {
1455 phase: PipelinePhase::Fix,
1456 iteration: 3,
1457 total_iterations: 5,
1458 reviewer_pass: 1,
1459 total_reviewer_passes: 2,
1460 developer_agent: "aider",
1461 reviewer_agent: "opencode",
1462 cli_args: CliArgsSnapshot::new(
1463 5,
1464 2,
1465 Some("standard".into()),
1466 false,
1467 true,
1468 2,
1469 false,
1470 None,
1471 ),
1472 developer_agent_config: AgentConfigSnapshot::new(
1473 "aider".into(),
1474 "aider".into(),
1475 "-o".into(),
1476 Some("--yes".into()),
1477 true,
1478 ),
1479 reviewer_agent_config: AgentConfigSnapshot::new(
1480 "opencode".into(),
1481 "opencode".into(),
1482 "-o".into(),
1483 None,
1484 false,
1485 ),
1486 rebase_state: RebaseState::PreRebaseCompleted {
1487 commit_oid: "abc123".into(),
1488 },
1489 git_user_name: None,
1490 git_user_email: None,
1491 run_id: &run_id,
1492 parent_run_id: None,
1493 resume_count: 0,
1494 actual_developer_runs: 3,
1495 actual_reviewer_runs: 1,
1496 });
1497
1498 let json = serde_json::to_string(&checkpoint).unwrap();
1499 assert!(json.contains("Fix"));
1500 assert!(json.contains("aider"));
1501 assert!(json.contains("opencode"));
1502 assert!(json.contains("\"version\":"));
1503
1504 let deserialized: PipelineCheckpoint = serde_json::from_str(&json).unwrap();
1505 assert_eq!(deserialized.phase, checkpoint.phase);
1506 assert_eq!(deserialized.iteration, checkpoint.iteration);
1507 assert_eq!(deserialized.cli_args.developer_iters, 5);
1508 assert!(matches!(
1509 deserialized.rebase_state,
1510 RebaseState::PreRebaseCompleted { .. }
1511 ));
1512 assert_eq!(deserialized.run_id, run_id);
1513 assert_eq!(deserialized.actual_developer_runs, 3);
1514 assert_eq!(deserialized.actual_reviewer_runs, 1);
1515 }
1516
1517 #[test]
1518 fn test_cli_args_snapshot() {
1519 let snapshot = CliArgsSnapshot::new(
1520 10,
1521 3,
1522 Some("comprehensive".into()),
1523 true,
1524 true,
1525 3,
1526 true,
1527 Some("claude".to_string()),
1528 );
1529
1530 assert_eq!(snapshot.developer_iters, 10);
1531 assert_eq!(snapshot.reviewer_reviews, 3);
1532 assert_eq!(snapshot.review_depth, Some("comprehensive".to_string()));
1533 assert!(snapshot.skip_rebase);
1534 assert!(snapshot.isolation_mode);
1535 assert_eq!(snapshot.verbosity, 3);
1536 assert!(snapshot.show_streaming_metrics);
1537 assert_eq!(snapshot.reviewer_json_parser, Some("claude".to_string()));
1538 }
1539
1540 #[test]
1541 fn test_agent_config_snapshot() {
1542 let config = AgentConfigSnapshot::new(
1543 "test-agent".into(),
1544 "/usr/bin/test".into(),
1545 "--output".into(),
1546 Some("--yolo".into()),
1547 false,
1548 );
1549
1550 assert_eq!(config.name, "test-agent");
1551 assert_eq!(config.cmd, "/usr/bin/test");
1552 assert_eq!(config.output_flag, "--output");
1553 assert_eq!(config.yolo_flag, Some("--yolo".to_string()));
1554 assert!(!config.can_commit);
1555 }
1556
1557 #[test]
1558 fn test_rebase_state() {
1559 let state = RebaseState::PreRebaseInProgress {
1560 upstream_branch: "main".into(),
1561 };
1562 assert!(matches!(state, RebaseState::PreRebaseInProgress { .. }));
1563
1564 let state = RebaseState::Failed {
1565 error: "conflict".into(),
1566 };
1567 assert!(matches!(state, RebaseState::Failed { .. }));
1568 }
1569}