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")]
166 pub allowed_tools: Option<Vec<String>>,
167 #[serde(default = "default_true")]
171 pub uses_memory: bool,
172 #[serde(default = "default_source")]
174 pub source: String,
175 pub created_at: DateTime<Utc>,
176 pub next_run: DateTime<Utc>,
177 pub last_run: Option<DateTime<Utc>>,
178 pub last_status: Option<String>,
179 pub last_output: Option<String>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct CronRun {
184 pub id: i64,
185 pub job_id: String,
186 pub started_at: DateTime<Utc>,
187 pub finished_at: DateTime<Utc>,
188 pub status: String,
189 pub output: Option<String>,
190 pub duration_ms: Option<i64>,
191}
192
193#[derive(Debug, Clone, Default, Serialize, Deserialize)]
194pub struct CronJobPatch {
195 pub schedule: Option<Schedule>,
196 pub command: Option<String>,
197 pub prompt: Option<String>,
198 pub name: Option<String>,
199 pub enabled: Option<bool>,
200 pub delivery: Option<DeliveryConfig>,
201 pub model: Option<String>,
202 pub session_target: Option<SessionTarget>,
203 pub delete_after_run: Option<bool>,
204 pub allowed_tools: Option<Vec<String>>,
205 pub uses_memory: Option<bool>,
206}
207
208impl ::zeroclaw_api::attribution::Attributable for CronJob {
209 fn role(&self) -> ::zeroclaw_api::attribution::Role {
210 let kind = match self.schedule {
211 Schedule::Cron { .. } => ::zeroclaw_api::attribution::CronKind::Cron,
212 Schedule::At { .. } => ::zeroclaw_api::attribution::CronKind::At,
213 Schedule::Every { .. } => ::zeroclaw_api::attribution::CronKind::Interval,
214 };
215 ::zeroclaw_api::attribution::Role::Cron(kind)
216 }
217 fn alias(&self) -> &str {
218 &self.id
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn deserialize_schedule_from_object() {
228 let val = serde_json::json!({"kind": "cron", "expr": "*/5 * * * *"});
229 let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
230 assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
231 }
232
233 #[test]
234 fn deserialize_schedule_from_string() {
235 let val = serde_json::Value::String(r#"{"kind":"cron","expr":"*/5 * * * *"}"#.to_string());
236 let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
237 assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
238 }
239
240 #[test]
241 fn deserialize_schedule_string_with_tz() {
242 let val = serde_json::Value::String(
243 r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#.to_string(),
244 );
245 let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
246 match sched {
247 Schedule::Cron { tz, .. } => assert_eq!(tz.as_deref(), Some("Asia/Shanghai")),
248 _ => panic!("expected Cron variant"),
249 }
250 }
251
252 #[test]
253 fn deserialize_every_from_string() {
254 let val = serde_json::Value::String(r#"{"kind":"every","every_ms":60000}"#.to_string());
255 let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
256 assert!(matches!(sched, Schedule::Every { every_ms: 60000 }));
257 }
258
259 #[test]
260 fn deserialize_invalid_string_returns_error() {
261 let val = serde_json::Value::String("not json at all".to_string());
262 assert!(deserialize_maybe_stringified::<Schedule>(&val).is_err());
263 }
264
265 #[test]
266 fn job_type_try_from_accepts_known_values_case_insensitive() {
267 assert_eq!(JobType::try_from("shell").unwrap(), JobType::Shell);
268 assert_eq!(JobType::try_from("SHELL").unwrap(), JobType::Shell);
269 assert_eq!(JobType::try_from("agent").unwrap(), JobType::Agent);
270 assert_eq!(JobType::try_from("AgEnT").unwrap(), JobType::Agent);
271 }
272
273 #[test]
274 fn job_type_try_from_rejects_invalid_values() {
275 assert!(JobType::try_from("").is_err());
276 assert!(JobType::try_from("unknown").is_err());
277 }
278}