use serde::{Deserialize, Serialize}; use sqlx::Row; use crate::scheduler::Schedule; /// A scheduled job stored in the database. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScheduledJob { pub id: String, pub name: String, /// JSON-serialized `Schedule` stored as TEXT in SQLite. pub schedule: Schedule, pub prompt: String, pub channel: String, pub chat_id: String, pub model: Option, pub enabled: bool, pub delete_after_run: bool, pub next_run_at: i64, pub last_run_at: Option, pub last_status: Option, pub last_error: Option, pub created_at: i64, pub updated_at: i64, } /// A single execution record for a job. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JobRun { pub id: i64, pub job_id: String, pub started_at: i64, pub finished_at: i64, pub status: String, pub output: Option, pub error: Option, pub duration_ms: i64, } impl crate::storage::Storage { /// Insert a new scheduled job. pub async fn add_scheduled_job(&self, job: &ScheduledJob) -> anyhow::Result<()> { let schedule_json = serde_json::to_string(&job.schedule)?; sqlx::query( r#" INSERT INTO scheduled_jobs (id, name, schedule, prompt, channel, chat_id, model, enabled, delete_after_run, next_run_at, last_run_at, last_status, last_error, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&job.id) .bind(&job.name) .bind(&schedule_json) .bind(&job.prompt) .bind(&job.channel) .bind(&job.chat_id) .bind(&job.model) .bind(job.enabled as i32) .bind(job.delete_after_run as i32) .bind(job.next_run_at) .bind(job.last_run_at) .bind(&job.last_status) .bind(&job.last_error) .bind(job.created_at) .bind(job.updated_at) .execute(self.pool()) .await?; Ok(()) } /// Fetch a single scheduled job by ID. pub async fn get_scheduled_job(&self, id: &str) -> anyhow::Result { let row = sqlx::query("SELECT * FROM scheduled_jobs WHERE id = ?") .bind(id) .fetch_optional(self.pool()) .await? .ok_or_else(|| anyhow::anyhow!("job not found: {id}"))?; row_to_job(&row) } /// List all scheduled jobs, ordered by next_run_at ascending. pub async fn list_scheduled_jobs(&self) -> anyhow::Result> { let rows = sqlx::query("SELECT * FROM scheduled_jobs ORDER BY next_run_at ASC") .fetch_all(self.pool()) .await?; rows.iter().map(row_to_job).collect() } /// Delete a scheduled job (cascades to job_runs). pub async fn remove_scheduled_job(&self, id: &str) -> anyhow::Result<()> { sqlx::query("DELETE FROM scheduled_jobs WHERE id = ?") .bind(id) .execute(self.pool()) .await?; Ok(()) } /// Enable or disable a scheduled job. pub async fn set_scheduled_job_enabled(&self, id: &str, enabled: bool) -> anyhow::Result<()> { sqlx::query("UPDATE scheduled_jobs SET enabled = ?, updated_at = ? WHERE id = ?") .bind(enabled as i32) .bind(now_ms()) .bind(id) .execute(self.pool()) .await?; Ok(()) } /// Update selective fields on a scheduled job. pub async fn update_scheduled_job( &self, id: &str, prompt: Option, schedule: Option, channel: Option, chat_id: Option, model: Option, ) -> anyhow::Result<()> { let now = now_ms(); if let Some(p) = prompt { sqlx::query("UPDATE scheduled_jobs SET prompt = ?, updated_at = ? WHERE id = ?") .bind(&p) .bind(now) .bind(id) .execute(self.pool()) .await?; } if let Some(s) = schedule { let json = serde_json::to_string(&s)?; sqlx::query("UPDATE scheduled_jobs SET schedule = ?, updated_at = ? WHERE id = ?") .bind(&json) .bind(now) .bind(id) .execute(self.pool()) .await?; } if let Some(c) = channel { sqlx::query("UPDATE scheduled_jobs SET channel = ?, updated_at = ? WHERE id = ?") .bind(&c) .bind(now) .bind(id) .execute(self.pool()) .await?; } if let Some(c) = chat_id { sqlx::query("UPDATE scheduled_jobs SET chat_id = ?, updated_at = ? WHERE id = ?") .bind(&c) .bind(now) .bind(id) .execute(self.pool()) .await?; } if let Some(m) = model { sqlx::query("UPDATE scheduled_jobs SET model = ?, updated_at = ? WHERE id = ?") .bind(&m) .bind(now) .bind(id) .execute(self.pool()) .await?; } Ok(()) } /// Update next_run_at and last_run_at for a job. pub async fn set_scheduled_job_next_run( &self, id: &str, next_run_at: i64, ) -> anyhow::Result<()> { let now = now_ms(); sqlx::query( "UPDATE scheduled_jobs SET next_run_at = ?, last_run_at = ?, updated_at = ? WHERE id = ?", ) .bind(next_run_at) .bind(now) .bind(now) .bind(id) .execute(self.pool()) .await?; Ok(()) } /// Set last_run_at for a job (used when starting execution). pub async fn touch_scheduled_job_last_run(&self, id: &str, at: i64) -> anyhow::Result<()> { sqlx::query("UPDATE scheduled_jobs SET last_run_at = ?, updated_at = ? WHERE id = ?") .bind(at) .bind(at) .bind(id) .execute(self.pool()) .await?; Ok(()) } /// Set last_status and last_error after job completion. pub async fn set_scheduled_job_last_status( &self, id: &str, status: &str, error: Option<&str>, ) -> anyhow::Result<()> { let now = now_ms(); sqlx::query( "UPDATE scheduled_jobs SET last_status = ?, last_error = ?, updated_at = ? WHERE id = ?", ) .bind(status) .bind(error) .bind(now) .bind(id) .execute(self.pool()) .await?; Ok(()) } /// Fetch enabled jobs whose next_run_at <= now, up to `limit`. pub async fn due_scheduled_jobs( &self, now: i64, limit: usize, ) -> anyhow::Result> { let rows = sqlx::query( "SELECT * FROM scheduled_jobs WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC LIMIT ?", ) .bind(now) .bind(limit as i64) .fetch_all(self.pool()) .await?; rows.iter().map(row_to_job).collect() } /// Record a job execution run. pub async fn record_scheduled_job_run(&self, run: &JobRun) -> anyhow::Result<()> { sqlx::query( r#" INSERT INTO job_runs (job_id, started_at, finished_at, status, output, error, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&run.job_id) .bind(run.started_at) .bind(run.finished_at) .bind(&run.status) .bind(&run.output) .bind(&run.error) .bind(run.duration_ms) .execute(self.pool()) .await?; Ok(()) } /// List recent runs for a job, newest first. pub async fn list_scheduled_job_runs( &self, job_id: &str, limit: usize, ) -> anyhow::Result> { let rows = sqlx::query( "SELECT * FROM job_runs WHERE job_id = ? ORDER BY finished_at DESC LIMIT ?", ) .bind(job_id) .bind(limit as i64) .fetch_all(self.pool()) .await?; rows.iter() .map(|r| { Ok(JobRun { id: r.try_get("id")?, job_id: r.try_get("job_id")?, started_at: r.try_get("started_at")?, finished_at: r.try_get("finished_at")?, status: r.try_get("status")?, output: r.try_get("output")?, error: r.try_get("error")?, duration_ms: r.try_get("duration_ms")?, }) }) .collect() } /// Delete disabled jobs whose updated_at is before `before`. pub async fn cleanup_disabled_scheduled_jobs(&self, before: i64) -> anyhow::Result<()> { sqlx::query("DELETE FROM scheduled_jobs WHERE enabled = 0 AND updated_at < ?") .bind(before) .execute(self.pool()) .await?; Ok(()) } } fn now_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64 } fn row_to_job(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result { let schedule_json: String = row.try_get("schedule")?; let schedule: Schedule = serde_json::from_str(&schedule_json)?; Ok(ScheduledJob { id: row.try_get("id")?, name: row.try_get("name")?, schedule, prompt: row.try_get("prompt")?, channel: row.try_get("channel")?, chat_id: row.try_get("chat_id")?, model: row.try_get("model")?, enabled: row.try_get::("enabled")? != 0, delete_after_run: row.try_get::("delete_after_run")? != 0, next_run_at: row.try_get("next_run_at")?, last_run_at: row.try_get("last_run_at")?, last_status: row.try_get("last_status")?, last_error: row.try_get("last_error")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, }) } #[cfg(test)] mod tests { use super::ScheduledJob; use crate::scheduler::Schedule; use crate::storage::Storage; use sqlx::SqlitePool; fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() as i64 } async fn setup_storage() -> Storage { let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); let storage = Storage { pool }; Storage::init_scheduler_schema(storage.pool()) .await .unwrap(); storage } #[tokio::test] async fn test_init_creates_tables() { let storage = setup_storage().await; let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scheduled_jobs") .fetch_one(storage.pool()) .await .unwrap(); assert_eq!(row.0, 0); } #[tokio::test] async fn test_add_and_get_job() { let storage = setup_storage().await; let t = now(); let job = ScheduledJob { id: "job-1".into(), name: "test job".into(), schedule: Schedule::Every { every_ms: 3600000 }, prompt: "say hello".into(), channel: "cli_chat".into(), chat_id: "conn-1".into(), model: None, enabled: true, delete_after_run: false, next_run_at: t + 3600000, last_run_at: None, last_status: None, last_error: None, created_at: t, updated_at: t, }; storage.add_scheduled_job(&job).await.unwrap(); let got = storage.get_scheduled_job("job-1").await.unwrap(); assert_eq!(got.id, "job-1"); assert_eq!(got.name, "test job"); assert_eq!(got.prompt, "say hello"); } #[tokio::test] async fn test_list_jobs() { let storage = setup_storage().await; let t = now(); for i in 0..3 { let job = ScheduledJob { id: format!("job-{}", i), name: format!("job {}", i), schedule: Schedule::Every { every_ms: 3600000 }, prompt: "ping".into(), channel: "cli_chat".into(), chat_id: "conn-1".into(), model: None, enabled: true, delete_after_run: false, next_run_at: t + 1000, last_run_at: None, last_status: None, last_error: None, created_at: t, updated_at: t, }; storage.add_scheduled_job(&job).await.unwrap(); } let jobs = storage.list_scheduled_jobs().await.unwrap(); assert_eq!(jobs.len(), 3); } #[tokio::test] async fn test_remove_job() { let storage = setup_storage().await; let t = now(); let job = ScheduledJob { id: "job-rm".into(), name: "remove me".into(), schedule: Schedule::Every { every_ms: 1000 }, prompt: "hi".into(), channel: "cli_chat".into(), chat_id: "c".into(), model: None, enabled: true, delete_after_run: false, next_run_at: t, last_run_at: None, last_status: None, last_error: None, created_at: t, updated_at: t, }; storage.add_scheduled_job(&job).await.unwrap(); storage.remove_scheduled_job("job-rm").await.unwrap(); let result = storage.get_scheduled_job("job-rm").await; assert!(result.is_err()); } #[tokio::test] async fn test_set_enabled() { let storage = setup_storage().await; let t = now(); let job = ScheduledJob { id: "job-toggle".into(), name: "toggle".into(), schedule: Schedule::Every { every_ms: 1000 }, prompt: "hi".into(), channel: "cli_chat".into(), chat_id: "c".into(), model: None, enabled: true, delete_after_run: false, next_run_at: t, last_run_at: None, last_status: None, last_error: None, created_at: t, updated_at: t, }; storage.add_scheduled_job(&job).await.unwrap(); storage .set_scheduled_job_enabled("job-toggle", false) .await .unwrap(); let got = storage.get_scheduled_job("job-toggle").await.unwrap(); assert!(!got.enabled); } #[tokio::test] async fn test_due_jobs_only_returns_enabled_and_overdue() { let storage = setup_storage().await; let t = now(); let jobs = vec![ ScheduledJob { id: "due".into(), name: "due".into(), schedule: Schedule::At { at: t }, prompt: "1".into(), channel: "cli_chat".into(), chat_id: "c".into(), model: None, enabled: true, delete_after_run: false, next_run_at: t - 1000, last_run_at: None, last_status: None, last_error: None, created_at: t, updated_at: t, }, ScheduledJob { id: "future".into(), name: "future".into(), schedule: Schedule::At { at: t + 99999999 }, prompt: "2".into(), channel: "cli_chat".into(), chat_id: "c".into(), model: None, enabled: true, delete_after_run: false, next_run_at: t + 99999999, last_run_at: None, last_status: None, last_error: None, created_at: t, updated_at: t, }, ScheduledJob { id: "disabled-due".into(), name: "disabled due".into(), schedule: Schedule::At { at: t }, prompt: "3".into(), channel: "cli_chat".into(), chat_id: "c".into(), model: None, enabled: false, delete_after_run: false, next_run_at: t - 1000, last_run_at: None, last_status: None, last_error: None, created_at: t, updated_at: t, }, ]; for j in &jobs { storage.add_scheduled_job(j).await.unwrap(); } let due = storage.due_scheduled_jobs(t, 10).await.unwrap(); assert_eq!(due.len(), 1); assert_eq!(due[0].id, "due"); } #[tokio::test] async fn test_record_run_and_list_runs() { let storage = setup_storage().await; let t = now(); let job = ScheduledJob { id: "job-run".into(), name: "run test".into(), schedule: Schedule::Every { every_ms: 1000 }, prompt: "hi".into(), channel: "cli_chat".into(), chat_id: "c".into(), model: None, enabled: true, delete_after_run: false, next_run_at: t, last_run_at: None, last_status: None, last_error: None, created_at: t, updated_at: t, }; storage.add_scheduled_job(&job).await.unwrap(); let run = super::JobRun { id: 0, job_id: "job-run".into(), started_at: t, finished_at: t + 500, status: "ok".into(), output: Some("hello".into()), error: None, duration_ms: 500, }; storage.record_scheduled_job_run(&run).await.unwrap(); let runs = storage .list_scheduled_job_runs("job-run", 10) .await .unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].status, "ok"); assert_eq!(runs[0].output.as_deref(), Some("hello")); } #[tokio::test] async fn test_update_job() { let storage = setup_storage().await; let t = now(); let job = ScheduledJob { id: "job-update".into(), name: "old name".into(), schedule: Schedule::Every { every_ms: 1000 }, prompt: "old prompt".into(), channel: "feishu".into(), chat_id: "oc_1".into(), model: None, enabled: true, delete_after_run: false, next_run_at: t, last_run_at: None, last_status: None, last_error: None, created_at: t, updated_at: t, }; storage.add_scheduled_job(&job).await.unwrap(); storage .update_scheduled_job( "job-update", Some("new prompt".into()), Some(Schedule::Every { every_ms: 60000 }), None, None, None, ) .await .unwrap(); let got = storage.get_scheduled_job("job-update").await.unwrap(); assert_eq!(got.prompt, "new prompt"); } }