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) -> io::Result<StateValidation> {
37    let mut issues = Vec::new();
38
39    if !workspace.exists(agent_dir) {
40        return Ok(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    Ok(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.
115pub fn auto_repair_with_workspace(
116    workspace: &dyn Workspace,
117    agent_dir: &Path,
118) -> io::Result<RecoveryStatus> {
119    if !workspace.exists(agent_dir) {
120        workspace.create_dir_all(&agent_dir.join("logs"))?;
121        return Ok(RecoveryStatus::Recovered);
122    }
123
124    let validation = validate_agent_state_with_workspace(workspace, agent_dir)?;
125    if validation.is_valid {
126        workspace.create_dir_all(&agent_dir.join("logs"))?;
127        return Ok(RecoveryStatus::Valid);
128    }
129
130    // Attempt repairs.
131    remove_zero_length_files_with_workspace(workspace, agent_dir)?;
132    workspace.create_dir_all(&agent_dir.join("logs"))?;
133
134    let post_validation = validate_agent_state_with_workspace(workspace, agent_dir)?;
135    if post_validation.is_valid {
136        Ok(RecoveryStatus::Recovered)
137    } else {
138        Ok(RecoveryStatus::Unrecoverable(format!(
139            "Unresolved .agent issues: {}",
140            post_validation.issues.join(", ")
141        )))
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::workspace::MemoryWorkspace;
149    use std::path::Path;
150
151    #[test]
152    fn auto_repair_with_workspace_creates_missing_directory() {
153        let workspace = MemoryWorkspace::new_test();
154        let agent_dir = Path::new(".agent");
155
156        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
157
158        assert_eq!(status, RecoveryStatus::Recovered);
159        assert!(workspace.exists(&agent_dir.join("logs")));
160    }
161
162    #[test]
163    fn auto_repair_with_workspace_removes_zero_length_files() {
164        let workspace = MemoryWorkspace::new_test()
165            .with_file(".agent/logs/.keep", "")
166            .with_file(".agent/PLAN.md", ""); // Empty file
167
168        let agent_dir = Path::new(".agent");
169        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
170
171        assert_eq!(status, RecoveryStatus::Recovered);
172        assert!(!workspace.exists(&agent_dir.join("PLAN.md")));
173    }
174
175    #[test]
176    fn auto_repair_with_workspace_valid_state() {
177        let workspace = MemoryWorkspace::new_test()
178            .with_file(".agent/logs/.keep", "")
179            .with_file(".agent/PLAN.md", "# Plan\nSome content");
180
181        let agent_dir = Path::new(".agent");
182        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
183
184        assert_eq!(status, RecoveryStatus::Valid);
185        assert!(workspace.exists(&agent_dir.join("PLAN.md")));
186    }
187
188    #[test]
189    fn auto_repair_with_workspace_multiple_zero_length_files() {
190        let workspace = MemoryWorkspace::new_test()
191            .with_file(".agent/logs/.keep", "")
192            .with_file(".agent/PLAN.md", "")
193            .with_file(".agent/ISSUES.md", "")
194            .with_file(".agent/STATUS.md", "valid content");
195
196        let agent_dir = Path::new(".agent");
197        let status = auto_repair_with_workspace(&workspace, agent_dir).unwrap();
198
199        assert_eq!(status, RecoveryStatus::Recovered);
200        assert!(!workspace.exists(&agent_dir.join("PLAN.md")));
201        assert!(!workspace.exists(&agent_dir.join("ISSUES.md")));
202        assert!(workspace.exists(&agent_dir.join("STATUS.md")));
203    }
204}