runtimo_core/capabilities/
undo.rs1use crate::validation::path::{validate_path, PathContext};
20use crate::{Capability, Context, Output, Result};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct UndoArgs {
26 pub job_id: String,
28 pub file: Option<String>,
30}
31
32#[allow(clippy::exhaustive_structs)]
35pub struct Undo;
36
37impl Capability for Undo {
38 fn name(&self) -> &'static str {
39 "Undo"
40 }
41
42 fn description(&self) -> &'static str {
43 "restore from backup. use `runtimo logs` for job IDs."
44 }
45
46 fn schema(&self) -> Value {
47 serde_json::json!({
48 "type": "object",
49 "properties": {
50 "job_id": { "type": "string" },
51 "file": { "type": "string" }
52 },
53 "required": ["job_id"]
54 })
55 }
56
57 fn validate(&self, args: &serde_json::Value) -> Result<()> {
58 let args: UndoArgs = serde_json::from_value(args.clone())
59 .map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
60 if args.job_id.is_empty() {
61 return Err(crate::Error::SchemaValidationFailed(
62 "job_id is empty".into(),
63 ));
64 }
65 Ok(())
66 }
67
68 fn execute(&self, args: &serde_json::Value, ctx: &Context) -> Result<Output> {
69 let args: UndoArgs = serde_json::from_value(args.clone())
70 .map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
71
72 let backup_dir = crate::utils::backup_dir();
74
75 let backup_mgr = crate::BackupManager::new(backup_dir.clone())?;
76
77 let job_backup_dir = backup_dir.join(&args.job_id);
79 if !job_backup_dir.exists() {
80 return Err(crate::Error::ExecutionFailed(format!(
81 "No backup found for job {}",
82 args.job_id
83 )));
84 }
85
86 let mut restored = Vec::new();
87
88 let wal_path = crate::utils::wal_path();
90
91 let mut original_paths: std::collections::HashMap<String, String> =
92 std::collections::HashMap::new();
93 if wal_path.exists() {
94 match crate::WalReader::load(&wal_path) {
95 Ok(reader) => {
96 for event in reader.events() {
97 if event.job_id == args.job_id {
98 if let Some(output) = &event.output {
99 if let Some(data) = output.get("data") {
100 if let Some(path) = data.get("path").and_then(|p| p.as_str()) {
101 if let Some(backup) =
102 data.get("backup_path").and_then(|b| b.as_str())
103 {
104 original_paths
107 .insert(backup.to_string(), path.to_string());
108 }
109 }
110 }
111 }
112 }
113 }
114 }
115 Err(e) => {
116 return Err(crate::Error::ExecutionFailed(format!(
117 "Failed to load WAL for undo: {}",
118 e
119 )));
120 }
121 }
122 }
123
124 if let Ok(entries) = std::fs::read_dir(&job_backup_dir) {
126 for entry in entries.flatten() {
127 let backup_path = entry.path();
128 if backup_path.is_file() {
129 let backup_path_str = backup_path
130 .to_str()
131 .ok_or_else(|| crate::Error::ExecutionFailed("Invalid backup path".into()))?
132 .to_string();
133
134 let target_path = original_paths
136 .get(&backup_path_str)
137 .map(std::path::PathBuf::from)
138 .ok_or_else(|| {
139 crate::Error::ExecutionFailed(format!(
140 "No original path found for backup {}",
141 backup_path.display()
142 ))
143 })?;
144
145 let restore_ctx = PathContext {
148 require_exists: false,
149 require_file: false,
150 ..Default::default()
151 };
152 let target_path = validate_path(&target_path.to_string_lossy(), &restore_ctx)
153 .map_err(|e| {
154 crate::Error::ExecutionFailed(format!("restore target validation: {}", e))
155 })?;
156
157 if ctx.dry_run {
158 restored.push(format!(
159 "{} -> {} (dry run)",
160 backup_path.display(),
161 target_path.display()
162 ));
163 } else {
164 backup_mgr.restore(&backup_path, &target_path)?;
165 restored.push(format!(
166 "{} -> {}",
167 backup_path.display(),
168 target_path.display()
169 ));
170 }
171 }
172 }
173 }
174
175 Ok(Output {
176 success: true,
177 data: serde_json::json!({
178 "restored": restored,
179 "job_id": args.job_id
180 }),
181 message: Some(format!("Restored {} file(s)", restored.len())),
182 })
183 }
184}
185
186#[cfg(test)]
187#[allow(clippy::items_after_statements)]
188mod tests {
189 use super::*;
190 use crate::capability::Context;
191 use std::fs;
192
193 #[test]
194 fn test_undo_with_backup() {
195 let tmpdir = std::env::temp_dir().join("runtimo_test_undo");
196 let _ = fs::remove_dir_all(&tmpdir);
197 fs::create_dir_all(&tmpdir).unwrap();
198
199 let test_file = tmpdir.join("test.txt");
200 fs::write(&test_file, "original content").unwrap();
201
202 let backup_dir = tmpdir.join("backups");
203 let job_id = "test-job-123";
204 let job_backup_dir = backup_dir.join(job_id);
205 fs::create_dir_all(&job_backup_dir).unwrap();
206
207 let backup_path = job_backup_dir.join("test.txt");
208 fs::copy(&test_file, &backup_path).unwrap();
209
210 fs::write(&test_file, "modified content").unwrap();
212
213 let wal_file = tmpdir.join("test.wal");
215 std::env::set_var("RUNTIMO_WAL_PATH", &wal_file);
216 use crate::wal::{WalEvent, WalEventType, WalWriter};
217 let mut wal = WalWriter::create(&wal_file).unwrap();
218 let ts = std::time::SystemTime::now()
219 .duration_since(std::time::UNIX_EPOCH)
220 .unwrap()
221 .as_secs();
222 wal.append(WalEvent {
223 seq: 0,
224 ts,
225 event_type: WalEventType::JobCompleted,
226 job_id: job_id.to_string(),
227 capability: Some("FileWrite".into()),
228 output: Some(serde_json::json!({
229 "data": {
230 "path": test_file.to_str().unwrap(),
231 "backup_path": backup_path.to_str().unwrap()
232 }
233 })),
234 error: None,
235 telemetry_before: None,
236 telemetry_after: None,
237 process_before: None,
238 process_after: None,
239 cmd: None,
240 cmd_stdout: None,
241 cmd_stderr: None,
242 cmd_exit_code: None,
243 cmd_corrected: None,
244 })
245 .unwrap();
246
247 std::env::set_var("RUNTIMO_BACKUP_DIR", &backup_dir);
248
249 let cap = Undo;
250 let ctx = Context {
251 dry_run: false,
252 job_id: "undo-test-job".to_string(),
253 working_dir: tmpdir.clone(),
254 };
255 let result = cap.execute(&serde_json::json!({"job_id": job_id}), &ctx);
256
257 assert!(result.is_ok(), "undo failed: {:?}", result.err());
258 let output = result.unwrap();
259 assert!(output.success, "undo not successful: {:?}", output.message);
260 assert!(
261 !output.data["restored"].as_array().unwrap().is_empty(),
262 "no files restored"
263 );
264
265 let restored_content = fs::read_to_string(&test_file).unwrap();
267 assert_eq!(restored_content, "original content");
268
269 let _ = fs::remove_dir_all(&tmpdir);
271 std::env::remove_var("RUNTIMO_WAL_PATH");
272 std::env::remove_var("RUNTIMO_BACKUP_DIR");
273 }
274}