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