agent_code_lib/schedule/
storage.rs1use std::path::PathBuf;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use tracing::debug;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Schedule {
16 pub name: String,
18 pub cron: String,
20 pub prompt: String,
22 pub cwd: String,
24 #[serde(default = "default_true")]
26 pub enabled: bool,
27 pub model: Option<String>,
29 pub permission_mode: Option<String>,
31 pub max_cost_usd: Option<f64>,
33 pub max_turns: Option<usize>,
35 pub created_at: DateTime<Utc>,
37 pub last_run_at: Option<DateTime<Utc>>,
39 pub last_result: Option<RunResult>,
41 pub webhook_secret: Option<String>,
43}
44
45fn default_true() -> bool {
46 true
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct RunResult {
52 pub started_at: DateTime<Utc>,
53 pub finished_at: DateTime<Utc>,
54 pub success: bool,
55 pub turns: usize,
56 pub cost_usd: f64,
57 pub summary: String,
59 pub session_id: String,
61}
62
63pub struct ScheduleStore {
65 dir: PathBuf,
66}
67
68impl ScheduleStore {
69 pub fn open() -> Result<Self, String> {
71 let dir = schedules_dir().ok_or("Could not determine config directory")?;
72 std::fs::create_dir_all(&dir)
73 .map_err(|e| format!("Failed to create schedules dir: {e}"))?;
74 Ok(Self { dir })
75 }
76
77 pub fn open_at(dir: PathBuf) -> Result<Self, String> {
79 std::fs::create_dir_all(&dir)
80 .map_err(|e| format!("Failed to create schedules dir: {e}"))?;
81 Ok(Self { dir })
82 }
83
84 pub fn save(&self, schedule: &Schedule) -> Result<(), String> {
86 let path = self.path_for(&schedule.name);
87 let json = serde_json::to_string_pretty(schedule)
88 .map_err(|e| format!("Serialization error: {e}"))?;
89 std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?;
90 debug!("Schedule saved: {}", path.display());
91 Ok(())
92 }
93
94 pub fn load(&self, name: &str) -> Result<Schedule, String> {
96 let path = self.path_for(name);
97 if !path.exists() {
98 return Err(format!("Schedule '{name}' not found"));
99 }
100 let content = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
101 serde_json::from_str(&content).map_err(|e| format!("Parse error: {e}"))
102 }
103
104 pub fn list(&self) -> Vec<Schedule> {
106 let mut schedules: Vec<Schedule> = std::fs::read_dir(&self.dir)
107 .ok()
108 .into_iter()
109 .flatten()
110 .flatten()
111 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
112 .filter_map(|entry| {
113 let content = std::fs::read_to_string(entry.path()).ok()?;
114 serde_json::from_str(&content).ok()
115 })
116 .collect();
117 schedules.sort_by(|a, b| a.name.cmp(&b.name));
118 schedules
119 }
120
121 pub fn remove(&self, name: &str) -> Result<(), String> {
123 let path = self.path_for(name);
124 if !path.exists() {
125 return Err(format!("Schedule '{name}' not found"));
126 }
127 std::fs::remove_file(&path).map_err(|e| format!("Delete error: {e}"))?;
128 debug!("Schedule removed: {name}");
129 Ok(())
130 }
131
132 pub fn find_by_secret(&self, secret: &str) -> Option<Schedule> {
134 self.list()
135 .into_iter()
136 .find(|s| s.webhook_secret.as_deref() == Some(secret))
137 }
138
139 fn path_for(&self, name: &str) -> PathBuf {
140 self.dir.join(format!("{name}.json"))
141 }
142}
143
144fn schedules_dir() -> Option<PathBuf> {
145 dirs::config_dir().map(|d| d.join("agent-code").join("schedules"))
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn test_schedule(name: &str) -> Schedule {
153 Schedule {
154 name: name.to_string(),
155 cron: "0 9 * * *".to_string(),
156 prompt: "run tests".to_string(),
157 cwd: "/tmp/project".to_string(),
158 enabled: true,
159 model: None,
160 permission_mode: None,
161 max_cost_usd: None,
162 max_turns: None,
163 created_at: Utc::now(),
164 last_run_at: None,
165 last_result: None,
166 webhook_secret: None,
167 }
168 }
169
170 #[test]
171 fn test_save_and_load() {
172 let dir = tempfile::tempdir().unwrap();
173 let store = ScheduleStore::open_at(dir.path().to_path_buf()).unwrap();
174 let sched = test_schedule("daily-tests");
175 store.save(&sched).unwrap();
176
177 let loaded = store.load("daily-tests").unwrap();
178 assert_eq!(loaded.name, "daily-tests");
179 assert_eq!(loaded.cron, "0 9 * * *");
180 assert_eq!(loaded.prompt, "run tests");
181 assert!(loaded.enabled);
182 }
183
184 #[test]
185 fn test_list() {
186 let dir = tempfile::tempdir().unwrap();
187 let store = ScheduleStore::open_at(dir.path().to_path_buf()).unwrap();
188 store.save(&test_schedule("beta")).unwrap();
189 store.save(&test_schedule("alpha")).unwrap();
190
191 let list = store.list();
192 assert_eq!(list.len(), 2);
193 assert_eq!(list[0].name, "alpha"); assert_eq!(list[1].name, "beta");
195 }
196
197 #[test]
198 fn test_remove() {
199 let dir = tempfile::tempdir().unwrap();
200 let store = ScheduleStore::open_at(dir.path().to_path_buf()).unwrap();
201 store.save(&test_schedule("temp")).unwrap();
202 assert!(store.load("temp").is_ok());
203
204 store.remove("temp").unwrap();
205 assert!(store.load("temp").is_err());
206 }
207
208 #[test]
209 fn test_remove_nonexistent() {
210 let dir = tempfile::tempdir().unwrap();
211 let store = ScheduleStore::open_at(dir.path().to_path_buf()).unwrap();
212 assert!(store.remove("nope").is_err());
213 }
214
215 #[test]
216 fn test_find_by_secret() {
217 let dir = tempfile::tempdir().unwrap();
218 let store = ScheduleStore::open_at(dir.path().to_path_buf()).unwrap();
219 let mut sched = test_schedule("webhook-job");
220 sched.webhook_secret = Some("s3cret".to_string());
221 store.save(&sched).unwrap();
222
223 let found = store.find_by_secret("s3cret").unwrap();
224 assert_eq!(found.name, "webhook-job");
225 assert!(store.find_by_secret("wrong").is_none());
226 }
227
228 #[test]
229 fn test_serialization_roundtrip() {
230 let mut sched = test_schedule("roundtrip");
231 sched.model = Some("gpt-5.4".to_string());
232 sched.max_cost_usd = Some(1.0);
233 sched.max_turns = Some(10);
234 sched.last_result = Some(RunResult {
235 started_at: Utc::now(),
236 finished_at: Utc::now(),
237 success: true,
238 turns: 3,
239 cost_usd: 0.05,
240 summary: "All tests passed".to_string(),
241 session_id: "abc12345".to_string(),
242 });
243
244 let json = serde_json::to_string(&sched).unwrap();
245 let loaded: Schedule = serde_json::from_str(&json).unwrap();
246 assert_eq!(loaded.model.as_deref(), Some("gpt-5.4"));
247 assert!(loaded.last_result.unwrap().success);
248 }
249}