Skip to main content

construct/cron/
types.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Try to deserialize a `serde_json::Value` as `T`.  If the value is a JSON
5/// string that looks like an object (i.e. the LLM double-serialized it), parse
6/// the inner string first and then deserialize the resulting object.  This
7/// provides backward-compatible handling for both `Value::Object` and
8/// `Value::String` representations.
9pub fn deserialize_maybe_stringified<T: serde::de::DeserializeOwned>(
10    v: &serde_json::Value,
11) -> Result<T, serde_json::Error> {
12    // Fast path: value is already the right shape (object, array, etc.)
13    match serde_json::from_value::<T>(v.clone()) {
14        Ok(parsed) => Ok(parsed),
15        Err(first_err) => {
16            // If it's a string, try parsing the string as JSON first.
17            if let Some(s) = v.as_str() {
18                let s = s.trim();
19                if s.starts_with('{') || s.starts_with('[') {
20                    if let Ok(inner) = serde_json::from_str::<serde_json::Value>(s) {
21                        return serde_json::from_value::<T>(inner);
22                    }
23                }
24            }
25            Err(first_err)
26        }
27    }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
31#[serde(rename_all = "lowercase")]
32pub enum JobType {
33    #[default]
34    Shell,
35    Agent,
36    Workflow,
37}
38
39impl From<JobType> for &'static str {
40    fn from(value: JobType) -> Self {
41        match value {
42            JobType::Shell => "shell",
43            JobType::Agent => "agent",
44            JobType::Workflow => "workflow",
45        }
46    }
47}
48
49impl TryFrom<&str> for JobType {
50    type Error = String;
51
52    fn try_from(value: &str) -> Result<Self, Self::Error> {
53        match value.to_lowercase().as_str() {
54            "shell" => Ok(JobType::Shell),
55            "agent" => Ok(JobType::Agent),
56            "workflow" => Ok(JobType::Workflow),
57            _ => Err(format!(
58                "Invalid job type '{}'. Expected one of: 'shell', 'agent', 'workflow'",
59                value
60            )),
61        }
62    }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
66#[serde(rename_all = "lowercase")]
67pub enum SessionTarget {
68    #[default]
69    Isolated,
70    Main,
71}
72
73impl SessionTarget {
74    pub(crate) fn as_str(&self) -> &'static str {
75        match self {
76            Self::Isolated => "isolated",
77            Self::Main => "main",
78        }
79    }
80
81    pub(crate) fn parse(raw: &str) -> Self {
82        if raw.eq_ignore_ascii_case("main") {
83            Self::Main
84        } else {
85            Self::Isolated
86        }
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91#[serde(tag = "kind", rename_all = "lowercase")]
92pub enum Schedule {
93    Cron {
94        expr: String,
95        #[serde(default)]
96        tz: Option<String>,
97    },
98    At {
99        at: DateTime<Utc>,
100    },
101    Every {
102        every_ms: u64,
103    },
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct DeliveryConfig {
108    #[serde(default)]
109    pub mode: String,
110    #[serde(default)]
111    pub channel: Option<String>,
112    #[serde(default)]
113    pub to: Option<String>,
114    #[serde(default = "default_true")]
115    pub best_effort: bool,
116}
117
118impl Default for DeliveryConfig {
119    fn default() -> Self {
120        Self {
121            mode: "none".to_string(),
122            channel: None,
123            to: None,
124            best_effort: true,
125        }
126    }
127}
128
129fn default_true() -> bool {
130    true
131}
132
133fn default_source() -> String {
134    "imperative".to_string()
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct CronJob {
139    pub id: String,
140    pub expression: String,
141    pub schedule: Schedule,
142    pub command: String,
143    pub prompt: Option<String>,
144    pub name: Option<String>,
145    pub job_type: JobType,
146    pub session_target: SessionTarget,
147    pub model: Option<String>,
148    pub enabled: bool,
149    pub delivery: DeliveryConfig,
150    pub delete_after_run: bool,
151    /// Optional allowlist of tool names this cron job may use.
152    /// When `Some(list)`, only tools whose name is in the list are available.
153    /// When `None`, all tools are available (backward compatible default).
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub allowed_tools: Option<Vec<String>>,
156    /// How the job was created: `"imperative"` (CLI/API) or `"declarative"` (config).
157    #[serde(default = "default_source")]
158    pub source: String,
159    pub created_at: DateTime<Utc>,
160    pub next_run: DateTime<Utc>,
161    pub last_run: Option<DateTime<Utc>>,
162    pub last_status: Option<String>,
163    pub last_output: Option<String>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct CronRun {
168    pub id: i64,
169    pub job_id: String,
170    pub started_at: DateTime<Utc>,
171    pub finished_at: DateTime<Utc>,
172    pub status: String,
173    pub output: Option<String>,
174    pub duration_ms: Option<i64>,
175}
176
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178pub struct CronJobPatch {
179    pub schedule: Option<Schedule>,
180    pub command: Option<String>,
181    pub prompt: Option<String>,
182    pub name: Option<String>,
183    pub enabled: Option<bool>,
184    pub delivery: Option<DeliveryConfig>,
185    pub model: Option<String>,
186    pub session_target: Option<SessionTarget>,
187    pub delete_after_run: Option<bool>,
188    pub allowed_tools: Option<Vec<String>>,
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn deserialize_schedule_from_object() {
197        let val = serde_json::json!({"kind": "cron", "expr": "*/5 * * * *"});
198        let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
199        assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
200    }
201
202    #[test]
203    fn deserialize_schedule_from_string() {
204        let val = serde_json::Value::String(r#"{"kind":"cron","expr":"*/5 * * * *"}"#.to_string());
205        let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
206        assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
207    }
208
209    #[test]
210    fn deserialize_schedule_string_with_tz() {
211        let val = serde_json::Value::String(
212            r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#.to_string(),
213        );
214        let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
215        match sched {
216            Schedule::Cron { tz, .. } => assert_eq!(tz.as_deref(), Some("Asia/Shanghai")),
217            _ => panic!("expected Cron variant"),
218        }
219    }
220
221    #[test]
222    fn deserialize_every_from_string() {
223        let val = serde_json::Value::String(r#"{"kind":"every","every_ms":60000}"#.to_string());
224        let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
225        assert!(matches!(sched, Schedule::Every { every_ms: 60000 }));
226    }
227
228    #[test]
229    fn deserialize_invalid_string_returns_error() {
230        let val = serde_json::Value::String("not json at all".to_string());
231        assert!(deserialize_maybe_stringified::<Schedule>(&val).is_err());
232    }
233
234    #[test]
235    fn job_type_try_from_accepts_known_values_case_insensitive() {
236        assert_eq!(JobType::try_from("shell").unwrap(), JobType::Shell);
237        assert_eq!(JobType::try_from("SHELL").unwrap(), JobType::Shell);
238        assert_eq!(JobType::try_from("agent").unwrap(), JobType::Agent);
239        assert_eq!(JobType::try_from("AgEnT").unwrap(), JobType::Agent);
240        assert_eq!(JobType::try_from("workflow").unwrap(), JobType::Workflow);
241        assert_eq!(JobType::try_from("Workflow").unwrap(), JobType::Workflow);
242    }
243
244    #[test]
245    fn job_type_try_from_rejects_invalid_values() {
246        assert!(JobType::try_from("").is_err());
247        assert!(JobType::try_from("unknown").is_err());
248    }
249}