use crate::config::Config; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use cron::Schedule; use rusqlite::{params, Connection}; use std::str::FromStr; use uuid::Uuid; pub mod scheduler; #[derive(Debug, Clone)] pub struct CronJob { pub id: String, pub expression: String, pub command: String, pub next_run: DateTime, pub last_run: Option>, pub last_status: Option, } pub fn handle_command(command: super::CronCommands, config: Config) -> Result<()> { match command { super::CronCommands::List => { let jobs = list_jobs(&config)?; if jobs.is_empty() { println!("No scheduled tasks yet."); println!("\nUsage:"); println!(" zeroclaw cron add '0 9 * * *' 'agent -m \"Good morning!\"'"); return Ok(()); } println!("🕒 Scheduled jobs ({}):", jobs.len()); for job in jobs { let last_run = job .last_run .map(|d| d.to_rfc3339()) .unwrap_or_else(|| "never".into()); let last_status = job.last_status.unwrap_or_else(|| "n/a".into()); println!( "- {} | {} | next={} | last={} ({})\n cmd: {}", job.id, job.expression, job.next_run.to_rfc3339(), last_run, last_status, job.command ); } Ok(()) } super::CronCommands::Add { expression, command, } => { let job = add_job(&config, &expression, &command)?; println!("✅ Added cron job {}", job.id); println!(" Expr: {}", job.expression); println!(" Next: {}", job.next_run.to_rfc3339()); println!(" Cmd : {}", job.command); Ok(()) } super::CronCommands::Remove { id } => remove_job(&config, &id), } } pub fn add_job(config: &Config, expression: &str, command: &str) -> Result { let now = Utc::now(); let next_run = next_run_for(expression, now)?; let id = Uuid::new_v4().to_string(); with_connection(config, |conn| { conn.execute( "INSERT INTO cron_jobs (id, expression, command, created_at, next_run) VALUES (?1, ?2, ?3, ?4, ?5)", params![ id, expression, command, now.to_rfc3339(), next_run.to_rfc3339() ], ) .context("Failed to insert cron job")?; Ok(()) })?; Ok(CronJob { id, expression: expression.to_string(), command: command.to_string(), next_run, last_run: None, last_status: None, }) } pub fn list_jobs(config: &Config) -> Result> { with_connection(config, |conn| { let mut stmt = conn.prepare( "SELECT id, expression, command, next_run, last_run, last_status FROM cron_jobs ORDER BY next_run ASC", )?; let rows = stmt.query_map([], |row| { let next_run_raw: String = row.get(3)?; let last_run_raw: Option = row.get(4)?; Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, next_run_raw, last_run_raw, row.get::<_, Option>(5)?, )) })?; let mut jobs = Vec::new(); for row in rows { let (id, expression, command, next_run_raw, last_run_raw, last_status) = row?; jobs.push(CronJob { id, expression, command, next_run: parse_rfc3339(&next_run_raw)?, last_run: match last_run_raw { Some(raw) => Some(parse_rfc3339(&raw)?), None => None, }, last_status, }); } Ok(jobs) }) } pub fn remove_job(config: &Config, id: &str) -> Result<()> { let changed = with_connection(config, |conn| { conn.execute("DELETE FROM cron_jobs WHERE id = ?1", params![id]) .context("Failed to delete cron job") })?; if changed == 0 { anyhow::bail!("Cron job '{id}' not found"); } println!("✅ Removed cron job {id}"); Ok(()) } pub fn due_jobs(config: &Config, now: DateTime) -> Result> { with_connection(config, |conn| { let mut stmt = conn.prepare( "SELECT id, expression, command, next_run, last_run, last_status FROM cron_jobs WHERE next_run <= ?1 ORDER BY next_run ASC", )?; let rows = stmt.query_map(params![now.to_rfc3339()], |row| { let next_run_raw: String = row.get(3)?; let last_run_raw: Option = row.get(4)?; Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, next_run_raw, last_run_raw, row.get::<_, Option>(5)?, )) })?; let mut jobs = Vec::new(); for row in rows { let (id, expression, command, next_run_raw, last_run_raw, last_status) = row?; jobs.push(CronJob { id, expression, command, next_run: parse_rfc3339(&next_run_raw)?, last_run: match last_run_raw { Some(raw) => Some(parse_rfc3339(&raw)?), None => None, }, last_status, }); } Ok(jobs) }) } pub fn reschedule_after_run( config: &Config, job: &CronJob, success: bool, output: &str, ) -> Result<()> { let now = Utc::now(); let next_run = next_run_for(&job.expression, now)?; let status = if success { "ok" } else { "error" }; with_connection(config, |conn| { conn.execute( "UPDATE cron_jobs SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4 WHERE id = ?5", params![ next_run.to_rfc3339(), now.to_rfc3339(), status, output, job.id ], ) .context("Failed to update cron job run state")?; Ok(()) }) } fn next_run_for(expression: &str, from: DateTime) -> Result> { let normalized = normalize_expression(expression)?; let schedule = Schedule::from_str(&normalized) .with_context(|| format!("Invalid cron expression: {expression}"))?; schedule .after(&from) .next() .ok_or_else(|| anyhow::anyhow!("No future occurrence for expression: {expression}")) } fn normalize_expression(expression: &str) -> Result { let expression = expression.trim(); let field_count = expression.split_whitespace().count(); match field_count { // standard crontab syntax: minute hour day month weekday 5 => Ok(format!("0 {expression}")), // crate-native syntax includes seconds (+ optional year) 6 | 7 => Ok(expression.to_string()), _ => anyhow::bail!( "Invalid cron expression: {expression} (expected 5, 6, or 7 fields, got {field_count})" ), } } fn parse_rfc3339(raw: &str) -> Result> { let parsed = DateTime::parse_from_rfc3339(raw) .with_context(|| format!("Invalid RFC3339 timestamp in cron DB: {raw}"))?; Ok(parsed.with_timezone(&Utc)) } fn with_connection(config: &Config, f: impl FnOnce(&Connection) -> Result) -> Result { let db_path = config.workspace_dir.join("cron").join("jobs.db"); if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("Failed to create cron directory: {}", parent.display()))?; } let conn = Connection::open(&db_path) .with_context(|| format!("Failed to open cron DB: {}", db_path.display()))?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS cron_jobs ( id TEXT PRIMARY KEY, expression TEXT NOT NULL, command TEXT NOT NULL, created_at TEXT NOT NULL, next_run TEXT NOT NULL, last_run TEXT, last_status TEXT, last_output TEXT ); CREATE INDEX IF NOT EXISTS idx_cron_jobs_next_run ON cron_jobs(next_run);", ) .context("Failed to initialize cron schema")?; f(&conn) } #[cfg(test)] mod tests { use super::*; use crate::config::Config; use chrono::Duration as ChronoDuration; use tempfile::TempDir; fn test_config(tmp: &TempDir) -> Config { let mut config = Config::default(); config.workspace_dir = tmp.path().join("workspace"); config.config_path = tmp.path().join("config.toml"); std::fs::create_dir_all(&config.workspace_dir).unwrap(); config } #[test] fn add_job_accepts_five_field_expression() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); let job = add_job(&config, "*/5 * * * *", "echo ok").unwrap(); assert_eq!(job.expression, "*/5 * * * *"); assert_eq!(job.command, "echo ok"); } #[test] fn add_job_rejects_invalid_field_count() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); let err = add_job(&config, "* * * *", "echo bad").unwrap_err(); assert!(err.to_string().contains("expected 5, 6, or 7 fields")); } #[test] fn add_list_remove_roundtrip() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); let job = add_job(&config, "*/10 * * * *", "echo roundtrip").unwrap(); let listed = list_jobs(&config).unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].id, job.id); remove_job(&config, &job.id).unwrap(); assert!(list_jobs(&config).unwrap().is_empty()); } #[test] fn due_jobs_filters_by_timestamp() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); let _job = add_job(&config, "* * * * *", "echo due").unwrap(); let due_now = due_jobs(&config, Utc::now()).unwrap(); assert!(due_now.is_empty(), "new job should not be due immediately"); let far_future = Utc::now() + ChronoDuration::days(365); let due_future = due_jobs(&config, far_future).unwrap(); assert_eq!(due_future.len(), 1, "job should be due in far future"); } #[test] fn reschedule_after_run_persists_last_status_and_last_run() { let tmp = TempDir::new().unwrap(); let config = test_config(&tmp); let job = add_job(&config, "*/15 * * * *", "echo run").unwrap(); reschedule_after_run(&config, &job, false, "failed output").unwrap(); let listed = list_jobs(&config).unwrap(); let stored = listed.iter().find(|j| j.id == job.id).unwrap(); assert_eq!(stored.last_status.as_deref(), Some("error")); assert!(stored.last_run.is_some()); } }