use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleKind {
At,
Every,
Cron,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronSchedule {
pub kind: ScheduleKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub every_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expr: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tz: Option<String>,
}
impl Default for CronSchedule {
fn default() -> Self {
Self {
kind: ScheduleKind::Every,
at_ms: None,
every_ms: None,
expr: None,
tz: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PayloadKind {
SystemEvent,
AgentTurn,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronPayload {
#[serde(default = "default_payload_kind")]
pub kind: PayloadKind,
#[serde(default)]
pub message: String,
#[serde(default)]
pub deliver: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
}
fn default_payload_kind() -> PayloadKind {
PayloadKind::AgentTurn
}
impl Default for CronPayload {
fn default() -> Self {
Self {
kind: PayloadKind::AgentTurn,
message: String::new(),
deliver: false,
channel: None,
to: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JobStatus {
Ok,
Error,
Skipped,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CronJobState {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_datetime_or_ms"
)]
pub next_run_at: Option<DateTime<Utc>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_datetime_or_ms"
)]
pub last_run_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_status: Option<JobStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronJob {
pub id: String,
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub schedule: CronSchedule,
#[serde(default)]
pub payload: CronPayload,
#[serde(default)]
pub state: CronJobState,
#[serde(default = "default_epoch", deserialize_with = "deserialize_datetime_or_ms")]
pub created_at: DateTime<Utc>,
#[serde(default = "default_epoch", deserialize_with = "deserialize_datetime_or_ms")]
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub delete_after_run: bool,
}
fn default_epoch() -> DateTime<Utc> {
DateTime::UNIX_EPOCH
}
fn deserialize_datetime_or_ms<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
let value: serde_json::Value = Deserialize::deserialize(deserializer)?;
match &value {
serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc))
.or_else(|_| s.parse::<DateTime<Utc>>())
.map_err(de::Error::custom),
serde_json::Value::Number(n) => {
let ms = n.as_i64().ok_or_else(|| de::Error::custom("expected i64"))?;
Utc.timestamp_millis_opt(ms)
.single()
.ok_or_else(|| de::Error::custom(format!("invalid ms timestamp: {ms}")))
}
serde_json::Value::Null => Ok(DateTime::UNIX_EPOCH),
_ => Err(de::Error::custom("expected string, integer, or null")),
}
}
fn deserialize_optional_datetime_or_ms<'de, D>(
deserializer: D,
) -> Result<Option<DateTime<Utc>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
match value {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(s)) => DateTime::parse_from_rfc3339(&s)
.map(|dt| Some(dt.with_timezone(&Utc)))
.or_else(|_| s.parse::<DateTime<Utc>>().map(Some))
.map_err(de::Error::custom),
Some(serde_json::Value::Number(n)) => {
let ms = n.as_i64().ok_or_else(|| de::Error::custom("expected i64"))?;
Ok(Utc.timestamp_millis_opt(ms).single())
}
_ => Err(de::Error::custom("expected string, integer, or null")),
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronStore {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub jobs: Vec<CronJob>,
}
fn default_version() -> u32 {
1
}
impl Default for CronStore {
fn default() -> Self {
Self {
version: 1,
jobs: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schedule_default() {
let s = CronSchedule::default();
assert_eq!(s.kind, ScheduleKind::Every);
assert!(s.at_ms.is_none());
assert!(s.every_ms.is_none());
}
#[test]
fn payload_default() {
let p = CronPayload::default();
assert_eq!(p.kind, PayloadKind::AgentTurn);
assert!(p.message.is_empty());
assert!(!p.deliver);
}
#[test]
fn cron_store_default() {
let store = CronStore::default();
assert_eq!(store.version, 1);
assert!(store.jobs.is_empty());
}
#[test]
fn cron_job_serde_roundtrip() {
let now = Utc::now();
let job = CronJob {
id: "job-1".into(),
name: "daily check".into(),
enabled: true,
schedule: CronSchedule {
kind: ScheduleKind::Cron,
at_ms: None,
every_ms: None,
expr: Some("0 9 * * *".into()),
tz: Some("UTC".into()),
},
payload: CronPayload {
kind: PayloadKind::AgentTurn,
message: "run daily report".into(),
deliver: true,
channel: Some("slack".into()),
to: Some("C123".into()),
},
state: CronJobState::default(),
created_at: now,
updated_at: now,
delete_after_run: false,
};
let json = serde_json::to_string(&job).unwrap();
let restored: CronJob = serde_json::from_str(&json).unwrap();
assert_eq!(restored.id, "job-1");
assert_eq!(restored.schedule.kind, ScheduleKind::Cron);
assert_eq!(restored.schedule.expr.as_deref(), Some("0 9 * * *"));
assert_eq!(restored.payload.channel.as_deref(), Some("slack"));
}
#[test]
fn cron_store_serde_roundtrip() {
let store = CronStore {
version: 1,
jobs: vec![CronJob {
id: "j1".into(),
name: "test".into(),
enabled: true,
schedule: CronSchedule::default(),
payload: CronPayload::default(),
state: CronJobState::default(),
created_at: DateTime::UNIX_EPOCH,
updated_at: DateTime::UNIX_EPOCH,
delete_after_run: true,
}],
};
let json = serde_json::to_string(&store).unwrap();
let restored: CronStore = serde_json::from_str(&json).unwrap();
assert_eq!(restored.version, 1);
assert_eq!(restored.jobs.len(), 1);
assert!(restored.jobs[0].delete_after_run);
}
#[test]
fn schedule_kind_serde() {
let kinds = [
(ScheduleKind::At, "\"at\""),
(ScheduleKind::Every, "\"every\""),
(ScheduleKind::Cron, "\"cron\""),
];
for (kind, expected) in &kinds {
let json = serde_json::to_string(kind).unwrap();
assert_eq!(&json, expected);
let restored: ScheduleKind = serde_json::from_str(&json).unwrap();
assert_eq!(restored, *kind);
}
}
#[test]
fn job_status_serde() {
let statuses = [
(JobStatus::Ok, "\"ok\""),
(JobStatus::Error, "\"error\""),
(JobStatus::Skipped, "\"skipped\""),
];
for (status, expected) in &statuses {
let json = serde_json::to_string(status).unwrap();
assert_eq!(&json, expected);
}
}
#[test]
fn cron_job_defaults_on_missing_fields() {
let json = r#"{"id": "j1", "name": "test"}"#;
let job: CronJob = serde_json::from_str(json).unwrap();
assert!(job.enabled); assert_eq!(job.schedule.kind, ScheduleKind::Every);
assert_eq!(job.payload.kind, PayloadKind::AgentTurn);
assert!(!job.delete_after_run);
}
#[test]
fn job_state_with_error() {
let now = Utc::now();
let state = CronJobState {
next_run_at: Some(now),
last_run_at: Some(now),
last_status: Some(JobStatus::Error),
last_error: Some("connection refused".into()),
};
let json = serde_json::to_string(&state).unwrap();
let restored: CronJobState = serde_json::from_str(&json).unwrap();
assert_eq!(restored.last_status, Some(JobStatus::Error));
assert_eq!(restored.last_error.as_deref(), Some("connection refused"));
}
#[test]
fn backward_compat_ms_timestamps() {
let json = r#"{
"id": "legacy-1",
"name": "old-job",
"created_at": 1700000000000,
"updated_at": 1700000000000,
"state": {
"next_run_at": 1700000100000,
"last_run_at": 1700000000000
}
}"#;
let job: CronJob = serde_json::from_str(json).unwrap();
assert_eq!(job.id, "legacy-1");
assert_eq!(job.created_at.timestamp_millis(), 1_700_000_000_000);
assert!(job.state.next_run_at.is_some());
assert!(job.state.last_run_at.is_some());
}
#[test]
fn backward_compat_legacy_field_names() {
let json = r#"{
"id": "j1",
"name": "test",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}"#;
let job: CronJob = serde_json::from_str(json).unwrap();
assert_eq!(job.created_at, Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
}
}