Skip to main content

zeroclaw_runtime/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                    && 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    /// Optional thread/conversation identifier carried into the outbound send.
112    /// Used by channels whose recipient and thread-of-conversation are distinct
113    /// (notably webhook, where a callback service routes on `thread_id`).
114    /// Persisted via the `delivery` JSON column, so existing rows without this
115    /// field deserialize as `None`.
116    #[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    /// Agent alias this job runs under. Empty when the row was written
154    /// before the column existed and no agent has claimed it; the
155    /// scheduler skips such rows with a warning rather than coercing
156    /// them to a magic alias.
157    #[serde(default)]
158    pub agent_alias: String,
159    pub enabled: bool,
160    pub delivery: DeliveryConfig,
161    pub delete_after_run: bool,
162    /// Optional allowlist of tool names this cron job may use.
163    /// When `Some(list)`, only tools whose name is in the list are available.
164    /// When `None`, all tools are available (backward compatible default).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub allowed_tools: Option<Vec<String>>,
167    /// Whether to recall and inject memory context before this agent job runs.
168    /// Defaults to `true`; set to `false` for stateless digest jobs that should
169    /// not accumulate or consume memory entries.
170    #[serde(default = "default_true")]
171    pub uses_memory: bool,
172    /// How the job was created: `"imperative"` (CLI/API) or `"declarative"` (config).
173    #[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}