Skip to main content

hermes_agent_cli_core/
cron.rs

1// Cron job management - scheduling, storage, and execution
2
3use crate::config::Config;
4use anyhow::{Context, Result};
5use chrono::{DateTime, Duration, Utc};
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10/// Cron jobs storage directory
11pub fn cron_dir() -> PathBuf {
12    Config::hermes_home().join("cron")
13}
14
15/// Cron jobs file path
16pub fn cron_jobs_path() -> PathBuf {
17    cron_dir().join("jobs.json")
18}
19
20/// Cron job output directory
21pub fn cron_output_dir() -> PathBuf {
22    cron_dir().join("output")
23}
24
25/// Schedule kind
26#[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/// Schedule definition
53#[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/// Repeat configuration
66#[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/// Cron job
74#[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/// Jobs storage wrapper
141#[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
153/// Parse duration string into minutes
154pub 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
174/// Parse schedule string
175pub fn parse_schedule(s: &str) -> Result<Schedule> {
176    let s = s.trim();
177    let s_lower = s.to_lowercase();
178
179    // "every X" pattern - recurring interval
180    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    // Cron expression (5 fields)
193    let parts: Vec<&str> = s.split_whitespace().collect();
194    if parts.len() >= 5 {
195        // Basic cron validation
196        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    // Duration like "30m", "2h", "1d" - one-shot
206    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    // ISO timestamp
218    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
240/// Compute next run time based on schedule
241pub 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            // For cron, we'd need a cron parser library
255            // For now, just return None (not implemented)
256            None
257        }
258    }
259}
260
261/// Ensure cron directories exist
262pub 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
271/// Load all jobs from storage
272pub 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
288/// Save all jobs to storage
289pub 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
303/// Create a new cron job
304pub 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
316/// Get a job by ID
317pub 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
322/// List all jobs
323pub 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
331/// Remove a job by ID
332pub 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
344/// Pause a job
345pub 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
363/// Resume a paused job
364pub 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
383/// Get all jobs that are due to run now
384pub 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
409/// Simple UUID generator
410fn 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        // IDs should be 12 hex chars
448        assert!(id1.chars().all(|c| c.is_ascii_hexdigit()));
449    }
450}