Skip to main content

agent_code_lib/schedule/
storage.rs

1//! Schedule persistence.
2//!
3//! Each schedule is stored as a JSON file in
4//! `~/.config/agent-code/schedules/<name>.json`. The store handles
5//! CRUD operations and persists execution history.
6
7use std::path::PathBuf;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use tracing::debug;
12
13/// A persisted schedule definition.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Schedule {
16    /// Unique schedule name (used as filename).
17    pub name: String,
18    /// Cron expression (5-field).
19    pub cron: String,
20    /// Prompt to send to the agent on each run.
21    pub prompt: String,
22    /// Working directory for the agent session.
23    pub cwd: String,
24    /// Whether this schedule is active.
25    #[serde(default = "default_true")]
26    pub enabled: bool,
27    /// Optional model override.
28    pub model: Option<String>,
29    /// Optional permission mode override.
30    pub permission_mode: Option<String>,
31    /// Maximum cost (USD) per run.
32    pub max_cost_usd: Option<f64>,
33    /// Maximum turns per run.
34    pub max_turns: Option<usize>,
35    /// When this schedule was created.
36    pub created_at: DateTime<Utc>,
37    /// Last execution time (if any).
38    pub last_run_at: Option<DateTime<Utc>>,
39    /// Last execution result.
40    pub last_result: Option<RunResult>,
41    /// Webhook secret for HTTP trigger (if set).
42    pub webhook_secret: Option<String>,
43}
44
45fn default_true() -> bool {
46    true
47}
48
49/// Result of one execution.
50#[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    /// First 500 chars of the response.
58    pub summary: String,
59    /// Session ID for `/resume`.
60    pub session_id: String,
61}
62
63/// CRUD operations for schedules.
64pub struct ScheduleStore {
65    dir: PathBuf,
66}
67
68impl ScheduleStore {
69    /// Open or create the schedule store.
70    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    /// Open a store at a specific directory (for testing).
78    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    /// Save a schedule (creates or updates).
85    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    /// Load a schedule by name.
95    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    /// List all schedules, sorted by name.
105    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    /// Remove a schedule by name.
122    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    /// Find a schedule by webhook secret.
133    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"); // sorted
194        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}