runtimo_core/capabilities/
undo.rs1use crate::{Capability, Context, Output, Result};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct UndoArgs {
25 pub job_id: String,
27 pub file: Option<String>,
29}
30
31pub struct Undo;
32
33impl Capability for Undo {
34 fn name(&self) -> &'static str {
35 "Undo"
36 }
37
38 fn description(&self) -> &'static str {
39 "Restore files from a backup created by a previous job. Use `moe logs` to find job IDs."
40 }
41
42 fn schema(&self) -> Value {
43 serde_json::json!({
44 "type": "object",
45 "properties": {
46 "job_id": { "type": "string" },
47 "file": { "type": "string" }
48 },
49 "required": ["job_id"]
50 })
51 }
52
53 fn validate(&self, args: &serde_json::Value) -> Result<()> {
54 let args: UndoArgs = serde_json::from_value(args.clone())
55 .map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
56 if args.job_id.is_empty() {
57 return Err(crate::Error::SchemaValidationFailed(
58 "job_id is empty".into(),
59 ));
60 }
61 Ok(())
62 }
63
64 fn execute(&self, args: &serde_json::Value, _ctx: &Context) -> Result<Output> {
65 let args: UndoArgs = serde_json::from_value(args.clone())
66 .map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
67
68 let backup_dir = crate::utils::backup_dir();
70
71 let backup_mgr = crate::BackupManager::new(backup_dir.clone())?;
72
73 let job_backup_dir = backup_dir.join(&args.job_id);
75 if !job_backup_dir.exists() {
76 return Err(crate::Error::ExecutionFailed(format!(
77 "No backup found for job {}",
78 args.job_id
79 )));
80 }
81
82 let mut restored = Vec::new();
83
84 let wal_path = crate::utils::wal_path();
86
87 let mut original_paths: std::collections::HashMap<String, String> =
88 std::collections::HashMap::new();
89 if wal_path.exists() {
90 match crate::WalReader::load(&wal_path) {
91 Ok(reader) => {
92 for event in reader.events() {
93 if event.job_id == args.job_id {
94 if let Some(output) = &event.output {
95 if let Some(data) = output.get("data") {
96 if let Some(path) = data.get("path").and_then(|p| p.as_str()) {
97 if let Some(backup) =
98 data.get("backup_path").and_then(|b| b.as_str())
99 {
100 original_paths
103 .insert(backup.to_string(), path.to_string());
104 }
105 }
106 }
107 }
108 }
109 }
110 }
111 Err(e) => {
112 return Err(crate::Error::ExecutionFailed(format!(
113 "Failed to load WAL for undo: {}",
114 e
115 )));
116 }
117 }
118 }
119
120 if let Ok(entries) = std::fs::read_dir(&job_backup_dir) {
122 for entry in entries.flatten() {
123 let backup_path = entry.path();
124 if backup_path.is_file() {
125 let backup_path_str = backup_path
126 .to_str()
127 .ok_or_else(|| {
128 crate::Error::ExecutionFailed("Invalid backup path".into())
129 })?
130 .to_string();
131
132 let target_path = original_paths
134 .get(&backup_path_str)
135 .map(std::path::PathBuf::from)
136 .ok_or_else(|| {
137 crate::Error::ExecutionFailed(format!(
138 "No original path found for backup {}",
139 backup_path.display()
140 ))
141 })?;
142
143 backup_mgr.restore(&backup_path, &target_path)?;
144 restored.push(format!("{} -> {}", backup_path.display(), target_path.display()));
145 }
146 }
147 }
148
149 Ok(Output {
150 success: true,
151 data: serde_json::json!({
152 "restored": restored,
153 "job_id": args.job_id
154 }),
155 message: Some(format!("Restored {} file(s)", restored.len())),
156 })
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::capability::Context;
164 use std::fs;
165
166 #[test]
167 fn test_undo_with_backup() {
168 let tmpdir = std::env::temp_dir().join("runtimo_test_undo");
169 let _ = fs::remove_dir_all(&tmpdir);
170 fs::create_dir_all(&tmpdir).unwrap();
171
172 let test_file = tmpdir.join("test.txt");
173 fs::write(&test_file, "original content").unwrap();
174
175 let backup_dir = tmpdir.join("backups");
177 let job_id = "test-job-123";
178 let job_backup_dir = backup_dir.join(job_id);
179 fs::create_dir_all(&job_backup_dir).unwrap();
180
181 let backup_path = job_backup_dir.join("test.txt");
182 fs::copy(&test_file, &backup_path).unwrap();
183
184 fs::write(&test_file, "modified content").unwrap();
186
187 std::env::set_var("RUNTIMO_BACKUP_DIR", &backup_dir);
189
190 let cap = Undo;
191 let ctx = Context {
192 dry_run: false,
193 job_id: "undo-test-job".to_string(),
194 working_dir: tmpdir.clone(),
195 };
196 let result = cap.execute(&serde_json::json!({"job_id": job_id}), &ctx);
199
200if let Ok(output) = result {
203 assert!(output.success);
204 }
205
206 let _ = fs::remove_dir_all(&tmpdir);
208 std::env::remove_var("RUNTIMO_BACKUP_DIR");
209 }
210}