1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4pub fn deserialize_maybe_stringified<T: serde::de::DeserializeOwned>(
10 v: &serde_json::Value,
11) -> Result<T, serde_json::Error> {
12 match serde_json::from_value::<T>(v.clone()) {
14 Ok(parsed) => Ok(parsed),
15 Err(first_err) => {
16 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 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub allowed_tools: Option<Vec<String>>,
156 #[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}