ralph_workflow/files/io/
recovery.rs1use std::fs;
8use std::io;
9use std::path::Path;
10
11use crate::workspace::Workspace;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum RecoveryStatus {
16 Valid,
18 Recovered,
20 Unrecoverable(String),
22}
23
24#[derive(Debug, Clone)]
25pub struct StateValidation {
26 pub is_valid: bool,
27 pub issues: Vec<String>,
28}
29
30fn 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 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 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
113pub 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 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
146fn 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
241pub 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", ""); 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}