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::fs;
8use std::io;
9use std::path::Path;
10
11use crate::workspace::Workspace;
12
13/// Status of a recovery operation.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum RecoveryStatus {
16    /// No recovery needed - state is valid.
17    Valid,
18    /// Recovery was performed successfully.
19    Recovered,
20    /// Recovery failed - state is unrecoverable.
21    Unrecoverable(String),
22}
23
24#[derive(Debug, Clone)]
25pub struct StateValidation {
26    pub is_valid: bool,
27    pub issues: Vec<String>,
28}
29
30// =============================================================================
31// Workspace-based implementation (primary, for pipeline layer)
32// =============================================================================
33
34fn validate_agent_state_with_workspace(
35    workspace: &dyn Workspace,
36    agent_dir: &Path,
37) -> io::Result<StateValidation> {
38    let mut issues = Vec::new();
39
40    if !workspace.exists(agent_dir) {
41        return Ok(StateValidation {
42            is_valid: false,
43            issues: vec![".agent/ directory does not exist".to_string()],
44        });
45    }
46
47    // Detect unreadable files in the `.agent/` directory.
48    if let Ok(entries) = workspace.read_dir(agent_dir) {
49        for entry in entries {
50            let path = entry.path();
51            if !entry.is_file() {
52                continue;
53            }
54            if workspace.read(path).is_err() {
55                issues.push(format!("Corrupted file: {}", path.display()));
56            }
57        }
58    }
59
60    // Zero-length files are a common interruption artifact.
61    for filename in [
62        "PLAN.md",
63        "ISSUES.md",
64        "STATUS.md",
65        "NOTES.md",
66        "commit-message.txt",
67    ] {
68        let file_path = agent_dir.join(filename);
69        if !workspace.exists(&file_path) {
70            continue;
71        }
72        if let Ok(content) = workspace.read(&file_path) {
73            if content.is_empty() {
74                issues.push(format!("Zero-length file: {filename}"));
75            }
76        }
77    }
78
79    Ok(StateValidation {
80        is_valid: issues.is_empty(),
81        issues,
82    })
83}
84
85fn remove_zero_length_files_with_workspace(
86    workspace: &dyn Workspace,
87    agent_dir: &Path,
88) -> io::Result<usize> {
89    let mut removed = 0;
90
91    for filename in [
92        "PLAN.md",
93        "ISSUES.md",
94        "STATUS.md",
95        "NOTES.md",
96        "commit-message.txt",
97    ] {
98        let file_path = agent_dir.join(filename);
99        if !workspace.exists(&file_path) {
100            continue;
101        }
102        if let Ok(content) = workspace.read(&file_path) {
103            if content.is_empty() {
104                workspace.remove(&file_path)?;
105                removed += 1;
106            }
107        }
108    }
109
110    Ok(removed)
111}
112
113/// Best-effort repair of common `.agent/` state issues using workspace.
114///
115/// This is the workspace-based version for pipeline layer usage.
116pub fn auto_repair_with_workspace(
117    workspace: &dyn Workspace,
118    agent_dir: &Path,
119) -> io::Result<RecoveryStatus> {
120    if !workspace.exists(agent_dir) {
121        workspace.create_dir_all(&agent_dir.join("logs"))?;
122        return Ok(RecoveryStatus::Recovered);
123    }
124
125    let validation = validate_agent_state_with_workspace(workspace, agent_dir)?;
126    if validation.is_valid {
127        workspace.create_dir_all(&agent_dir.join("logs"))?;
128        return Ok(RecoveryStatus::Valid);
129    }
130
131    // Attempt repairs.
132    remove_zero_length_files_with_workspace(workspace, agent_dir)?;
133    workspace.create_dir_all(&agent_dir.join("logs"))?;
134
135    let post_validation = validate_agent_state_with_workspace(workspace, agent_dir)?;
136    if post_validation.is_valid {
137        Ok(RecoveryStatus::Recovered)
138    } else {
139        Ok(RecoveryStatus::Unrecoverable(format!(
140            "Unresolved .agent issues: {}",
141            post_validation.issues.join(", ")
142        )))
143    }
144}
145
146// =============================================================================
147// std::fs wrapper (for CLI/AppEffect layer only)
148// =============================================================================
149
150fn validate_agent_state(agent_dir: &Path) -> io::Result<StateValidation> {
151    let mut issues = Vec::new();
152
153    if !agent_dir.exists() {
154        return Ok(StateValidation {
155            is_valid: false,
156            issues: vec![".agent/ directory does not exist".to_string()],
157        });
158    }
159
160    if let Ok(entries) = fs::read_dir(agent_dir) {
161        for entry in entries.flatten() {
162            let path = entry.path();
163            if !path.is_file() {
164                continue;
165            }
166            if fs::read_to_string(&path).is_err() {
167                issues.push(format!("Corrupted file: {}", path.display()));
168            }
169        }
170    }
171
172    for filename in [
173        "PLAN.md",
174        "ISSUES.md",
175        "STATUS.md",
176        "NOTES.md",
177        "commit-message.txt",
178    ] {
179        let file_path = agent_dir.join(filename);
180        if !file_path.exists() {
181            continue;
182        }
183        let metadata = fs::metadata(&file_path)?;
184        if metadata.len() == 0 {
185            issues.push(format!("Zero-length file: {filename}"));
186        }
187    }
188
189    Ok(StateValidation {
190        is_valid: issues.is_empty(),
191        issues,
192    })
193}
194
195fn remove_corrupted_files(agent_dir: &Path) -> io::Result<usize> {
196    let mut removed = 0;
197
198    let Ok(entries) = fs::read_dir(agent_dir) else {
199        return Ok(0);
200    };
201
202    for entry in entries.flatten() {
203        let path = entry.path();
204        if !path.is_file() {
205            continue;
206        }
207
208        if fs::read_to_string(&path).is_err() {
209            fs::remove_file(&path)?;
210            removed += 1;
211        }
212    }
213
214    Ok(removed)
215}
216
217fn remove_zero_length_files(agent_dir: &Path) -> io::Result<usize> {
218    let mut removed = 0;
219
220    for filename in [
221        "PLAN.md",
222        "ISSUES.md",
223        "STATUS.md",
224        "NOTES.md",
225        "commit-message.txt",
226    ] {
227        let file_path = agent_dir.join(filename);
228        if !file_path.exists() {
229            continue;
230        }
231        let metadata = fs::metadata(&file_path)?;
232        if metadata.len() == 0 {
233            fs::remove_file(&file_path)?;
234            removed += 1;
235        }
236    }
237
238    Ok(removed)
239}
240
241/// Best-effort repair of common `.agent/` state issues.
242///
243/// # Security
244///
245/// This function canonicalizes the input path to prevent path traversal attacks.
246pub fn auto_repair(agent_dir: &Path) -> io::Result<RecoveryStatus> {
247    let agent_dir = agent_dir
248        .canonicalize()
249        .unwrap_or_else(|_| agent_dir.to_path_buf());
250
251    if let Ok(cwd) = std::env::current_dir() {
252        if let Ok(rel_path) = agent_dir.strip_prefix(&cwd) {
253            let rel_str = rel_path.to_string_lossy();
254            if rel_str.starts_with("..") || rel_str.contains("/..") || rel_str.contains("\\..") {
255                return Ok(RecoveryStatus::Unrecoverable(
256                    "Invalid agent directory: path escapes current directory".to_string(),
257                ));
258            }
259        }
260    }
261
262    if !agent_dir.exists() {
263        fs::create_dir_all(agent_dir.join("logs"))?;
264        return Ok(RecoveryStatus::Recovered);
265    }
266
267    let validation = validate_agent_state(&agent_dir)?;
268    if validation.is_valid {
269        fs::create_dir_all(agent_dir.join("logs"))?;
270        return Ok(RecoveryStatus::Valid);
271    }
272
273    remove_corrupted_files(&agent_dir)?;
274    remove_zero_length_files(&agent_dir)?;
275    fs::create_dir_all(agent_dir.join("logs"))?;
276
277    let post_validation = validate_agent_state(&agent_dir)?;
278    if post_validation.is_valid {
279        Ok(RecoveryStatus::Recovered)
280    } else {
281        Ok(RecoveryStatus::Unrecoverable(format!(
282            "Unresolved .agent issues: {}",
283            post_validation.issues.join(", ")
284        )))
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::workspace::MemoryWorkspace;
292    use std::path::Path;
293
294    #[test]
295    fn auto_repair_with_workspace_creates_missing_directory() {
296        let workspace = MemoryWorkspace::new_test();
297        let agent_dir = Path::new(".agent");
298
299        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
300
301        assert_eq!(status, RecoveryStatus::Recovered);
302        assert!(workspace.exists(&agent_dir.join("logs")));
303    }
304
305    #[test]
306    fn auto_repair_with_workspace_removes_zero_length_files() {
307        let workspace = MemoryWorkspace::new_test()
308            .with_file(".agent/logs/.keep", "")
309            .with_file(".agent/PLAN.md", ""); // Empty file
310
311        let agent_dir = Path::new(".agent");
312        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
313
314        assert_eq!(status, RecoveryStatus::Recovered);
315        assert!(!workspace.exists(&agent_dir.join("PLAN.md")));
316    }
317
318    #[test]
319    fn auto_repair_with_workspace_valid_state() {
320        let workspace = MemoryWorkspace::new_test()
321            .with_file(".agent/logs/.keep", "")
322            .with_file(".agent/PLAN.md", "# Plan\nSome content");
323
324        let agent_dir = Path::new(".agent");
325        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
326
327        assert_eq!(status, RecoveryStatus::Valid);
328        assert!(workspace.exists(&agent_dir.join("PLAN.md")));
329    }
330
331    #[test]
332    fn auto_repair_with_workspace_multiple_zero_length_files() {
333        let workspace = MemoryWorkspace::new_test()
334            .with_file(".agent/logs/.keep", "")
335            .with_file(".agent/PLAN.md", "")
336            .with_file(".agent/ISSUES.md", "")
337            .with_file(".agent/STATUS.md", "valid content");
338
339        let agent_dir = Path::new(".agent");
340        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
341
342        assert_eq!(status, RecoveryStatus::Recovered);
343        assert!(!workspace.exists(&agent_dir.join("PLAN.md")));
344        assert!(!workspace.exists(&agent_dir.join("ISSUES.md")));
345        assert!(workspace.exists(&agent_dir.join("STATUS.md")));
346    }
347}