Skip to main content

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