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 && let Ok(inner) = serde_json::from_str::<serde_json::Value>(s)
21 {
22 return serde_json::from_value::<T>(inner);
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}
37
38impl From<JobType> for &'static str {
39 fn from(value: JobType) -> Self {
40 match value {
41 JobType::Shell => "shell",
42 JobType::Agent => "agent",
43 }
44 }
45}
46
47impl TryFrom<&str> for JobType {
48 type Error = String;
49
50 fn try_from(value: &str) -> Result<Self, Self::Error> {
51 match value.to_lowercase().as_str() {
52 "shell" => Ok(JobType::Shell),
53 "agent" => Ok(JobType::Agent),
54 _ => Err(format!(
55 "Invalid job type '{}'. Expected one of: 'shell', 'agent'",
56 value
57 )),
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
63#[serde(rename_all = "lowercase")]
64pub enum SessionTarget {
65 #[default]
66 Isolated,
67 Main,
68}
69
70impl SessionTarget {
71 pub fn as_str(&self) -> &'static str {
72 match self {
73 Self::Isolated => "isolated",
74 Self::Main => "main",
75 }
76 }
77
78 pub fn parse(raw: &str) -> Self {
79 if raw.eq_ignore_ascii_case("main") {
80 Self::Main
81 } else {
82 Self::Isolated
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[serde(tag = "kind", rename_all = "lowercase")]
89pub enum Schedule {
90 Cron {
91 expr: String,
92 #[serde(default)]
93 tz: Option<String>,
94 },
95 At {
96 at: DateTime<Utc>,
97 },
98 Every {
99 every_ms: u64,
100 },
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104pub struct DeliveryConfig {
105 #[serde(default)]
106 pub mode: String,
107 #[serde(default)]
108 pub channel: Option<String>,
109 #[serde(default)]
110 pub to: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub thread_id: Option<String>,
118 #[serde(default = "default_true")]
119 pub best_effort: bool,
120}
121
122impl Default for DeliveryConfig {
123 fn default() -> Self {
124 Self {
125 mode: "none".to_string(),
126 channel: None,
127 to: None,
128 thread_id: None,
129 best_effort: true,
130 }
131 }
132}
133
134pub fn default_true() -> bool {
135 true
136}
137
138fn default_source() -> String {
139 "imperative".to_string()
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct CronJob {
144 pub id: String,
145 pub expression: String,
146 pub schedule: Schedule,
147 pub command: String,
148 pub prompt: Option<String>,
149 pub name: Option<String>,
150 pub job_type: JobType,
151 pub session_target: SessionTarget,
152 pub model: Option<String>,
153 #[serde(default)]
158 pub agent_alias: String,
159 pub enabled: bool,
160 pub delivery: DeliveryConfig,
161 pub delete_after_run: bool,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub allowed_tools: Option<Vec<String>>,
169 #[serde(default = "default_true")]
173 pub uses_memory: bool,
174 #[serde(default = "default_source")]
176 pub source: String,
177 pub created_at: DateTime<Utc>,
178 pub next_run: DateTime<Utc>,
179 pub last_run: Option<DateTime<Utc>>,
180 pub last_status: Option<String>,
181 pub last_output: Option<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct CronRun {
186 pub id: i64,
187 pub job_id: String,
188 pub started_at: DateTime<Utc>,
189 pub finished_at: DateTime<Utc>,
190 pub status: String,
191 pub output: Option<String>,
192 pub duration_ms: Option<i64>,
193}
194
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196pub struct CronJobPatch {
197 pub schedule: Option<Schedule>,
198 pub command: Option<String>,
199 pub prompt: Option<String>,
200 pub name: Option<String>,
201 pub enabled: Option<bool>,
202 pub delivery: Option<DeliveryConfig>,
203 pub model: Option<String>,
204 pub session_target: Option<SessionTarget>,
205 pub delete_after_run: Option<bool>,
206 pub allowed_tools: Option<Vec<String>>,
207 pub uses_memory: Option<bool>,
208}
209
210impl ::zeroclaw_api::attribution::Attributable for CronJob {
211 fn role(&self) -> ::zeroclaw_api::attribution::Role {
212 let kind = match self.schedule {
213 Schedule::Cron { .. } => ::zeroclaw_api::attribution::CronKind::Cron,
214 Schedule::At { .. } => ::zeroclaw_api::attribution::CronKind::At,
215 Schedule::Every { .. } => ::zeroclaw_api::attribution::CronKind::Interval,
216 };
217 ::zeroclaw_api::attribution::Role::Cron(kind)
218 }
219 fn alias(&self) -> &str {
220 &self.id
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn deserialize_schedule_from_object() {
230 let val = serde_json::json!({"kind": "cron", "expr": "*/5 * * * *"});
231 let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
232 assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
233 }
234
235 #[test]
236 fn deserialize_schedule_from_string() {
237 let val = serde_json::Value::String(r#"{"kind":"cron","expr":"*/5 * * * *"}"#.to_string());
238 let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
239 assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
240 }
241
242 #[test]
243 fn deserialize_schedule_string_with_tz() {
244 let val = serde_json::Value::String(
245 r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#.to_string(),
246 );
247 let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
248 match sched {
249 Schedule::Cron { tz, .. } => assert_eq!(tz.as_deref(), Some("Asia/Shanghai")),
250 _ => panic!("expected Cron variant"),
251 }
252 }
253
254 #[test]
255 fn deserialize_every_from_string() {
256 let val = serde_json::Value::String(r#"{"kind":"every","every_ms":60000}"#.to_string());
257 let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
258 assert!(matches!(sched, Schedule::Every { every_ms: 60000 }));
259 }
260
261 #[test]
262 fn deserialize_invalid_string_returns_error() {
263 let val = serde_json::Value::String("not json at all".to_string());
264 assert!(deserialize_maybe_stringified::<Schedule>(&val).is_err());
265 }
266
267 #[test]
268 fn job_type_try_from_accepts_known_values_case_insensitive() {
269 assert_eq!(JobType::try_from("shell").unwrap(), JobType::Shell);
270 assert_eq!(JobType::try_from("SHELL").unwrap(), JobType::Shell);
271 assert_eq!(JobType::try_from("agent").unwrap(), JobType::Agent);
272 assert_eq!(JobType::try_from("AgEnT").unwrap(), JobType::Agent);
273 }
274
275 #[test]
276 fn job_type_try_from_rejects_invalid_values() {
277 assert!(JobType::try_from("").is_err());
278 assert!(JobType::try_from("unknown").is_err());
279 }
280}