Skip to main content

ralph_workflow/files/io/
recovery.rs

1//! Minimal recovery mechanisms for `.agent/` state.
2//!
3//! Ralph uses `.agent/` as a working directory. If it contains corrupted
4//! artifacts (e.g. non-UTF8 files from interrupted writes), we attempt a small
5//! set of best-effort repairs so the pipeline can proceed.
6
7use std::io;
8use std::path::Path;
9
10use crate::workspace::Workspace;
11
12/// Status of a recovery operation.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum RecoveryStatus {
15    /// No recovery needed - state is valid.
16    Valid,
17    /// Recovery was performed successfully.
18    Recovered,
19    /// Recovery failed - state is unrecoverable.
20    Unrecoverable(String),
21}
22
23#[derive(Debug, Clone)]
24struct StateValidation {
25    pub is_valid: bool,
26    pub issues: Vec<String>,
27}
28
29// =============================================================================
30// Workspace-based implementation (primary, for pipeline layer)
31// =============================================================================
32
33fn validate_agent_state_with_workspace(
34    workspace: &dyn Workspace,
35    agent_dir: &Path,
36) -> StateValidation {
37    let mut issues = Vec::new();
38
39    if !workspace.exists(agent_dir) {
40        return StateValidation {
41            is_valid: false,
42            issues: vec![".agent/ directory does not exist".to_string()],
43        };
44    }
45
46    // Detect unreadable files in the `.agent/` directory.
47    if let Ok(entries) = workspace.read_dir(agent_dir) {
48        for entry in entries {
49            let path = entry.path();
50            if !entry.is_file() {
51                continue;
52            }
53            if workspace.read(path).is_err() {
54                issues.push(format!("Corrupted file: {}", path.display()));
55            }
56        }
57    }
58
59    // Zero-length files are a common interruption artifact.
60    for filename in [
61        "PLAN.md",
62        "ISSUES.md",
63        "STATUS.md",
64        "NOTES.md",
65        "commit-message.txt",
66    ] {
67        let file_path = agent_dir.join(filename);
68        if !workspace.exists(&file_path) {
69            continue;
70        }
71        if let Ok(content) = workspace.read(&file_path) {
72            if content.is_empty() {
73                issues.push(format!("Zero-length file: {filename}"));
74            }
75        }
76    }
77
78    StateValidation {
79        is_valid: issues.is_empty(),
80        issues,
81    }
82}
83
84fn remove_zero_length_files_with_workspace(
85    workspace: &dyn Workspace,
86    agent_dir: &Path,
87) -> io::Result<usize> {
88    let mut removed = 0;
89
90    for filename in [
91        "PLAN.md",
92        "ISSUES.md",
93        "STATUS.md",
94        "NOTES.md",
95        "commit-message.txt",
96    ] {
97        let file_path = agent_dir.join(filename);
98        if !workspace.exists(&file_path) {
99            continue;
100        }
101        if let Ok(content) = workspace.read(&file_path) {
102            if content.is_empty() {
103                workspace.remove(&file_path)?;
104                removed += 1;
105            }
106        }
107    }
108
109    Ok(removed)
110}
111
112/// Best-effort repair of common `.agent/` state issues using workspace.
113///
114/// This is the workspace-based version for pipeline layer usage.
115///
116/// # Errors
117///
118/// Returns error if the operation fails.
119pub fn auto_repair_with_workspace(
120    workspace: &dyn Workspace,
121    agent_dir: &Path,
122) -> io::Result<RecoveryStatus> {
123    if !workspace.exists(agent_dir) {
124        workspace.create_dir_all(&agent_dir.join("logs"))?;
125        return Ok(RecoveryStatus::Recovered);
126    }
127
128    let validation = validate_agent_state_with_workspace(workspace, agent_dir);
129    if validation.is_valid {
130        workspace.create_dir_all(&agent_dir.join("logs"))?;
131        return Ok(RecoveryStatus::Valid);
132    }
133
134    // Attempt repairs.
135    remove_zero_length_files_with_workspace(workspace, agent_dir)?;
136    workspace.create_dir_all(&agent_dir.join("logs"))?;
137
138    let post_validation = validate_agent_state_with_workspace(workspace, agent_dir);
139    if post_validation.is_valid {
140        Ok(RecoveryStatus::Recovered)
141    } else {
142        Ok(RecoveryStatus::Unrecoverable(format!(
143            "Unresolved .agent issues: {}",
144            post_validation.issues.join(", ")
145        )))
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::workspace::MemoryWorkspace;
153    use std::path::Path;
154
155    #[test]
156    fn auto_repair_with_workspace_creates_missing_directory() {
157        let workspace = MemoryWorkspace::new_test();
158        let agent_dir = Path::new(".agent");
159
160        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
161
162        assert_eq!(status, RecoveryStatus::Recovered);
163        assert!(workspace.exists(&agent_dir.join("logs")));
164    }
165
166    #[test]
167    fn auto_repair_with_workspace_removes_zero_length_files() {
168        let workspace = MemoryWorkspace::new_test()
169            .with_file(".agent/logs/.keep", "")
170            .with_file(".agent/PLAN.md", ""); // Empty file
171
172        let agent_dir = Path::new(".agent");
173        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
174
175        assert_eq!(status, RecoveryStatus::Recovered);
176        assert!(!workspace.exists(&agent_dir.join("PLAN.md")));
177    }
178
179    #[test]
180    fn auto_repair_with_workspace_valid_state() {
181        let workspace = MemoryWorkspace::new_test()
182            .with_file(".agent/logs/.keep", "")
183            .with_file(".agent/PLAN.md", "# Plan\nSome content");
184
185        let agent_dir = Path::new(".agent");
186        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
187
188        assert_eq!(status, RecoveryStatus::Valid);
189        assert!(workspace.exists(&agent_dir.join("PLAN.md")));
190    }
191
192    #[test]
193    fn auto_repair_with_workspace_multiple_zero_length_files() {
194        let workspace = MemoryWorkspace::new_test()
195            .with_file(".agent/logs/.keep", "")
196            .with_file(".agent/PLAN.md", "")
197            .with_file(".agent/ISSUES.md", "")
198            .with_file(".agent/STATUS.md", "valid content");
199
200        let agent_dir = Path::new(".agent");
201        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
202
203        assert_eq!(status, RecoveryStatus::Recovered);
204        assert!(!workspace.exists(&agent_dir.join("PLAN.md")));
205        assert!(!workspace.exists(&agent_dir.join("ISSUES.md")));
206        assert!(workspace.exists(&agent_dir.join("STATUS.md")));
207    }
208}