1use crate::config::Config;
4use anyhow::{Context, Result};
5use chrono::{DateTime, Duration, Utc};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10pub fn cron_dir() -> PathBuf {
12 Config::hermes_home().join("cron")
13}
14
15pub fn cron_jobs_path() -> PathBuf {
17 cron_dir().join("jobs.json")
18}
19
20pub fn cron_output_dir() -> PathBuf {
22 cron_dir().join("output")
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27#[serde(rename_all = "lowercase")]
28pub enum ScheduleKind {
29 Once,
30 Interval,
31 Cron,
32}
33
34impl ScheduleKind {
35 pub fn parse(s: &str) -> Option<Self> {
36 match s {
37 "once" => Some(ScheduleKind::Once),
38 "interval" => Some(ScheduleKind::Interval),
39 "cron" => Some(ScheduleKind::Cron),
40 _ => None,
41 }
42 }
43}
44
45impl std::str::FromStr for ScheduleKind {
46 type Err = String;
47 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
48 Self::parse(s).ok_or_else(|| format!("Invalid schedule kind: {}", s))
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Schedule {
55 pub kind: ScheduleKind,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub minutes: Option<u32>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub expr: Option<String>,
60 pub display: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub run_at: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct RepeatConfig {
68 #[serde(rename = "times")]
69 pub max_times: Option<u32>,
70 pub completed: u32,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct CronJob {
76 pub id: String,
77 pub name: String,
78 pub prompt: String,
79 #[serde(default)]
80 pub skills: Vec<String>,
81 #[serde(default)]
82 pub skill: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub model: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub provider: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub base_url: Option<String>,
89 pub schedule: Schedule,
90 pub schedule_display: String,
91 pub repeat: RepeatConfig,
92 pub enabled: bool,
93 pub state: String,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub paused_at: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub paused_reason: Option<String>,
98 pub created_at: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub next_run_at: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub last_run_at: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub last_status: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub last_error: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub deliver: Option<String>,
109}
110
111impl CronJob {
112 pub fn new(id: &str, name: &str, prompt: &str, schedule: Schedule) -> Self {
113 let display = schedule.display.clone();
114 Self {
115 id: id.to_string(),
116 name: name.to_string(),
117 prompt: prompt.to_string(),
118 skills: vec![],
119 skill: None,
120 model: None,
121 provider: None,
122 base_url: None,
123 schedule,
124 schedule_display: display,
125 repeat: RepeatConfig { max_times: None, completed: 0 },
126 enabled: true,
127 state: "scheduled".to_string(),
128 paused_at: None,
129 paused_reason: None,
130 created_at: Utc::now().to_rfc3339(),
131 next_run_at: None,
132 last_run_at: None,
133 last_status: None,
134 last_error: None,
135 deliver: None,
136 }
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142struct JobsStorage {
143 jobs: Vec<CronJob>,
144 updated_at: String,
145}
146
147impl Default for JobsStorage {
148 fn default() -> Self {
149 Self { jobs: vec![], updated_at: Utc::now().to_rfc3339() }
150 }
151}
152
153pub fn parse_duration(s: &str) -> Result<u32> {
155 let s = s.trim().to_lowercase();
156 let re = regex_lite::Regex::new(r"^(\d+)\s*(m|min|mins|h|hr|hrs|d|day|days)$").unwrap();
157
158 if let Some(caps) = re.captures(&s) {
159 let value: u32 = caps.get(1).unwrap().as_str().parse().unwrap();
160 let unit = caps.get(2).unwrap().as_str();
161
162 let multiplier = match unit.chars().next().unwrap() {
163 'm' => 1,
164 'h' => 60,
165 'd' => 1440,
166 _ => 1,
167 };
168 return Ok(value * multiplier);
169 }
170
171 anyhow::bail!("Invalid duration format: '{}'. Use format like '30m', '2h', '1d'", s);
172}
173
174pub fn parse_schedule(s: &str) -> Result<Schedule> {
176 let s = s.trim();
177 let s_lower = s.to_lowercase();
178
179 if s_lower.starts_with("every ") {
181 let duration_str = &s[6..].trim();
182 let minutes = parse_duration(duration_str)?;
183 return Ok(Schedule {
184 kind: ScheduleKind::Interval,
185 minutes: Some(minutes),
186 expr: None,
187 display: format!("every {}m", minutes),
188 run_at: None,
189 });
190 }
191
192 let parts: Vec<&str> = s.split_whitespace().collect();
194 if parts.len() >= 5 {
195 return Ok(Schedule {
197 kind: ScheduleKind::Cron,
198 minutes: None,
199 expr: Some(s.to_string()),
200 display: s.to_string(),
201 run_at: None,
202 });
203 }
204
205 if let Ok(minutes) = parse_duration(s) {
207 let run_at = (Utc::now() + Duration::minutes(minutes as i64)).to_rfc3339();
208 return Ok(Schedule {
209 kind: ScheduleKind::Once,
210 minutes: None,
211 expr: None,
212 display: format!("once in {}m", minutes),
213 run_at: Some(run_at),
214 });
215 }
216
217 if s.contains('T') || s.starts_with(|c: char| c.is_ascii_digit()) {
219 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
220 return Ok(Schedule {
221 kind: ScheduleKind::Once,
222 minutes: None,
223 expr: None,
224 display: format!("once at {}", dt.format("%Y-%m-%d %H:%M")),
225 run_at: Some(dt.to_rfc3339()),
226 });
227 }
228 }
229
230 anyhow::bail!(
231 "Invalid schedule '{}'. Use:\n\
232 - Duration: '30m', '2h', '1d' (one-shot)\n\
233 - Interval: 'every 30m', 'every 2h' (recurring)\n\
234 - Cron: '0 9 * * *' (cron expression)\n\
235 - Timestamp: '2024-12-25T14:00:00Z' (one-shot)",
236 s
237 );
238}
239
240pub fn compute_next_run(schedule: &Schedule, last_run_at: Option<&str>) -> Option<String> {
242 match schedule.kind {
243 ScheduleKind::Once => schedule.run_at.clone(),
244 ScheduleKind::Interval => {
245 let minutes = schedule.minutes.unwrap_or(30);
246 let base = if let Some(last) = last_run_at {
247 DateTime::parse_from_rfc3339(last).ok().map(|dt| dt.with_timezone(&Utc))
248 } else {
249 Some(Utc::now())
250 };
251 base.map(|t| (t + Duration::minutes(minutes as i64)).to_rfc3339())
252 }
253 ScheduleKind::Cron => {
254 None
257 }
258 }
259}
260
261pub fn ensure_dirs() -> Result<()> {
263 let dirs = [cron_dir(), cron_output_dir()];
264 for dir in &dirs {
265 fs::create_dir_all(dir)
266 .with_context(|| format!("failed to create cron directory {:?}", dir))?;
267 }
268 Ok(())
269}
270
271pub fn load_jobs() -> Result<Vec<CronJob>> {
273 ensure_dirs()?;
274 let path = cron_jobs_path();
275
276 if !path.exists() {
277 return Ok(vec![]);
278 }
279
280 let content = fs::read_to_string(&path).context("failed to read cron jobs file")?;
281
282 let storage: JobsStorage =
283 serde_json::from_str(&content).context("failed to parse cron jobs JSON")?;
284
285 Ok(storage.jobs)
286}
287
288pub fn save_jobs(jobs: &[CronJob]) -> Result<()> {
290 ensure_dirs()?;
291 let path = cron_jobs_path();
292
293 let storage = JobsStorage { jobs: jobs.to_vec(), updated_at: Utc::now().to_rfc3339() };
294
295 let content =
296 serde_json::to_string_pretty(&storage).context("failed to serialize cron jobs")?;
297
298 fs::write(&path, content).context("failed to write cron jobs file")?;
299
300 Ok(())
301}
302
303pub fn create_job(prompt: String, schedule: String) -> Result<CronJob> {
305 let parsed = parse_schedule(&schedule)?;
306 let id = uuid_simple();
307 let job = CronJob::new(&id, &prompt, &prompt, parsed);
308
309 let mut jobs = load_jobs()?;
310 jobs.push(job.clone());
311 save_jobs(&jobs)?;
312
313 Ok(job)
314}
315
316pub fn get_job(job_id: &str) -> Result<Option<CronJob>> {
318 let jobs = load_jobs()?;
319 Ok(jobs.into_iter().find(|j| j.id == job_id))
320}
321
322pub fn list_jobs(include_disabled: bool) -> Result<Vec<CronJob>> {
324 let jobs = load_jobs()?;
325 if include_disabled {
326 return Ok(jobs);
327 }
328 Ok(jobs.into_iter().filter(|j| j.enabled).collect())
329}
330
331pub fn remove_job(job_id: &str) -> Result<bool> {
333 let mut jobs = load_jobs()?;
334 let original_len = jobs.len();
335 jobs.retain(|j| j.id != job_id);
336
337 if jobs.len() < original_len {
338 save_jobs(&jobs)?;
339 return Ok(true);
340 }
341 Ok(false)
342}
343
344pub fn pause_job(job_id: &str, reason: Option<&str>) -> Result<Option<CronJob>> {
346 let mut jobs = load_jobs()?;
347
348 for job in &mut jobs {
349 if job.id == job_id {
350 job.enabled = false;
351 job.state = "paused".to_string();
352 job.paused_at = Some(Utc::now().to_rfc3339());
353 job.paused_reason = reason.map(String::from);
354 let updated = job.clone();
355 save_jobs(&jobs)?;
356 return Ok(Some(updated));
357 }
358 }
359
360 Ok(None)
361}
362
363pub fn resume_job(job_id: &str) -> Result<Option<CronJob>> {
365 let mut jobs = load_jobs()?;
366
367 for job in &mut jobs {
368 if job.id == job_id {
369 job.enabled = true;
370 job.state = "scheduled".to_string();
371 job.paused_at = None;
372 job.paused_reason = None;
373 job.next_run_at = compute_next_run(&job.schedule, job.last_run_at.as_deref());
374 let updated = job.clone();
375 save_jobs(&jobs)?;
376 return Ok(Some(updated));
377 }
378 }
379
380 Ok(None)
381}
382
383pub fn get_due_jobs() -> Vec<CronJob> {
385 let jobs = match load_jobs() {
386 Ok(j) => j,
387 Err(_) => return vec![],
388 };
389
390 let now = chrono::Utc::now();
391 let mut due = vec![];
392
393 for job in jobs {
394 if !job.enabled {
395 continue;
396 }
397 if let Some(next_run) = &job.next_run_at {
398 if let Ok(next_dt) = DateTime::parse_from_rfc3339(next_run) {
399 if next_dt.with_timezone(&chrono::Utc) <= now {
400 due.push(job);
401 }
402 }
403 }
404 }
405
406 due
407}
408
409fn uuid_simple() -> String {
411 use std::time::{SystemTime, UNIX_EPOCH};
412 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
413 format!("{:x}", now)[..12].to_string()
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn test_parse_duration() {
422 assert_eq!(parse_duration("30m").unwrap(), 30);
423 assert_eq!(parse_duration("2h").unwrap(), 120);
424 assert_eq!(parse_duration("1d").unwrap(), 1440);
425 }
426
427 #[test]
428 fn test_parse_schedule() {
429 let s = parse_schedule("30m").unwrap();
430 assert_eq!(s.kind, ScheduleKind::Once);
431
432 let s = parse_schedule("every 30m").unwrap();
433 assert_eq!(s.kind, ScheduleKind::Interval);
434
435 let s = parse_schedule("every 2h").unwrap();
436 assert_eq!(s.kind, ScheduleKind::Interval);
437 assert_eq!(s.minutes, Some(120));
438 }
439
440 #[test]
441 fn test_uuid_simple() {
442 let id1 = uuid_simple();
443 std::thread::sleep(std::time::Duration::from_millis(1));
444 let id2 = uuid_simple();
445 assert_eq!(id1.len(), 12);
446 assert_eq!(id2.len(), 12);
447 assert!(id1.chars().all(|c| c.is_ascii_hexdigit()));
449 }
450}