Skip to main content

zeroclaw/cron/
mod.rs

1pub use zeroclaw_runtime::cron::*;
2
3use crate::config::Config;
4use anyhow::{Result, bail};
5
6/// Bail with a clear error if the named agent isn't configured.
7fn require_configured_agent(config: &Config, agent_alias: &str) -> Result<()> {
8    if config.agent(agent_alias).is_none() {
9        ::zeroclaw_log::record!(
10            WARN,
11            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
12                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
13                .with_attrs(::serde_json::json!({"agent_alias": agent_alias})),
14            "cron CLI rejected: unknown agent alias"
15        );
16        anyhow::bail!("Unknown agent {agent_alias:?} (no [agents.{agent_alias}] entry configured)");
17    }
18    Ok(())
19}
20
21fn parse_explicit_rfc3339_utc(raw: &str) -> Result<chrono::DateTime<chrono::Utc>> {
22    chrono::DateTime::parse_from_rfc3339(raw)
23        .map(|timestamp| timestamp.with_timezone(&chrono::Utc))
24        .map_err(|err| {
25            ::zeroclaw_log::record!(
26                WARN,
27                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
28                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
29                    .with_attrs(::serde_json::json!({
30                        "raw": raw,
31                        "error": format!("{}", err),
32                    })),
33                "cron --at rejected: timestamp lacks explicit Z/offset or is malformed"
34            );
35            anyhow::Error::msg(format!(
36                "Invalid RFC3339 timestamp for --at: expected RFC3339 timestamp with explicit Z or offset, e.g. 2026-05-18T09:00:00Z or 2026-05-18T09:00:00-04:00; got '{raw}': {err}"
37            ))
38        })
39}
40
41pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<()> {
42    match command {
43        crate::CronCommands::List => {
44            let jobs = list_jobs(config)?;
45            if jobs.is_empty() {
46                println!("No scheduled tasks yet.");
47                println!("\nUsage:");
48                println!("  zeroclaw cron add '0 9 * * *' 'agent -m \"Good morning!\"'");
49                return Ok(());
50            }
51
52            println!("πŸ•’ Scheduled jobs ({}):", jobs.len());
53            for job in jobs {
54                let last_run = job
55                    .last_run
56                    .map_or_else(|| "never".into(), |d| d.to_rfc3339());
57                let last_status = job.last_status.unwrap_or_else(|| "n/a".into());
58                println!(
59                    "- {} | {:?} | next={} | last={} ({})",
60                    job.id,
61                    job.schedule,
62                    job.next_run.to_rfc3339(),
63                    last_run,
64                    last_status,
65                );
66                if !job.command.is_empty() {
67                    println!("    cmd: {}", job.command);
68                }
69                if let Some(prompt) = &job.prompt {
70                    println!("    prompt: {prompt}");
71                }
72            }
73            Ok(())
74        }
75        crate::CronCommands::Add {
76            expression,
77            agent_alias,
78            tz,
79            prompt,
80            allowed_tools,
81            command,
82        } => {
83            require_configured_agent(config, &agent_alias)?;
84            let schedule = Schedule::Cron {
85                expr: expression,
86                tz,
87            };
88            if prompt {
89                let job = add_agent_job(
90                    config,
91                    &agent_alias,
92                    None,
93                    schedule,
94                    &command,
95                    SessionTarget::Isolated,
96                    None,
97                    None,
98                    false,
99                    if allowed_tools.is_empty() {
100                        None
101                    } else {
102                        Some(allowed_tools)
103                    },
104                )?;
105                println!("βœ… Added agent cron job {}", job.id);
106                println!("  Expr  : {}", job.expression);
107                println!("  Next  : {}", job.next_run.to_rfc3339());
108                println!("  Prompt: {}", job.prompt.as_deref().unwrap_or_default());
109            } else {
110                if !allowed_tools.is_empty() {
111                    bail!("--allowed-tool is only supported with --prompt cron jobs");
112                }
113                let job = add_shell_job(config, &agent_alias, None, schedule, &command)?;
114                println!("βœ… Added cron job {}", job.id);
115                println!("  Expr: {}", job.expression);
116                println!("  Next: {}", job.next_run.to_rfc3339());
117                println!("  Cmd : {}", job.command);
118            }
119            Ok(())
120        }
121        crate::CronCommands::AddAt {
122            at,
123            agent_alias,
124            prompt,
125            allowed_tools,
126            command,
127        } => {
128            require_configured_agent(config, &agent_alias)?;
129            let at = parse_explicit_rfc3339_utc(&at)?;
130            let schedule = Schedule::At { at };
131            if prompt {
132                let job = add_agent_job(
133                    config,
134                    &agent_alias,
135                    None,
136                    schedule,
137                    &command,
138                    SessionTarget::Isolated,
139                    None,
140                    None,
141                    true,
142                    if allowed_tools.is_empty() {
143                        None
144                    } else {
145                        Some(allowed_tools)
146                    },
147                )?;
148                println!("βœ… Added one-shot agent cron job {}", job.id);
149                println!("  At    : {}", job.next_run.to_rfc3339());
150                println!("  Prompt: {}", job.prompt.as_deref().unwrap_or_default());
151            } else {
152                if !allowed_tools.is_empty() {
153                    bail!("--allowed-tool is only supported with --prompt cron jobs");
154                }
155                let job = add_shell_job(config, &agent_alias, None, schedule, &command)?;
156                println!("βœ… Added one-shot cron job {}", job.id);
157                println!("  At  : {}", job.next_run.to_rfc3339());
158                println!("  Cmd : {}", job.command);
159            }
160            Ok(())
161        }
162        crate::CronCommands::AddEvery {
163            every_ms,
164            agent_alias,
165            prompt,
166            allowed_tools,
167            command,
168        } => {
169            require_configured_agent(config, &agent_alias)?;
170            let schedule = Schedule::Every { every_ms };
171            if prompt {
172                let job = add_agent_job(
173                    config,
174                    &agent_alias,
175                    None,
176                    schedule,
177                    &command,
178                    SessionTarget::Isolated,
179                    None,
180                    None,
181                    false,
182                    if allowed_tools.is_empty() {
183                        None
184                    } else {
185                        Some(allowed_tools)
186                    },
187                )?;
188                println!("βœ… Added interval agent cron job {}", job.id);
189                println!("  Every(ms): {every_ms}");
190                println!("  Next     : {}", job.next_run.to_rfc3339());
191                println!("  Prompt   : {}", job.prompt.as_deref().unwrap_or_default());
192            } else {
193                if !allowed_tools.is_empty() {
194                    bail!("--allowed-tool is only supported with --prompt cron jobs");
195                }
196                let job = add_shell_job(config, &agent_alias, None, schedule, &command)?;
197                println!("βœ… Added interval cron job {}", job.id);
198                println!("  Every(ms): {every_ms}");
199                println!("  Next     : {}", job.next_run.to_rfc3339());
200                println!("  Cmd      : {}", job.command);
201            }
202            Ok(())
203        }
204        crate::CronCommands::Once {
205            delay,
206            agent_alias,
207            prompt,
208            allowed_tools,
209            command,
210        } => {
211            require_configured_agent(config, &agent_alias)?;
212            if prompt {
213                let duration = parse_delay(&delay)?;
214                let at = chrono::Utc::now() + duration;
215                let schedule = Schedule::At { at };
216                let job = add_agent_job(
217                    config,
218                    &agent_alias,
219                    None,
220                    schedule,
221                    &command,
222                    SessionTarget::Isolated,
223                    None,
224                    None,
225                    true,
226                    if allowed_tools.is_empty() {
227                        None
228                    } else {
229                        Some(allowed_tools)
230                    },
231                )?;
232                println!("βœ… Added one-shot agent cron job {}", job.id);
233                println!("  At    : {}", job.next_run.to_rfc3339());
234                println!("  Prompt: {}", job.prompt.as_deref().unwrap_or_default());
235            } else {
236                if !allowed_tools.is_empty() {
237                    bail!("--allowed-tool is only supported with --prompt cron jobs");
238                }
239                let job = add_once(config, &agent_alias, &delay, &command)?;
240                println!("βœ… Added one-shot cron job {}", job.id);
241                println!("  At  : {}", job.next_run.to_rfc3339());
242                println!("  Cmd : {}", job.command);
243            }
244            Ok(())
245        }
246        crate::CronCommands::Update {
247            id,
248            agent_alias,
249            expression,
250            tz,
251            command,
252            name,
253            allowed_tools,
254        } => {
255            require_configured_agent(config, &agent_alias)?;
256            if expression.is_none()
257                && tz.is_none()
258                && command.is_none()
259                && name.is_none()
260                && allowed_tools.is_empty()
261            {
262                bail!(
263                    "At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided"
264                );
265            }
266
267            let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() {
268                Some(get_job(config, &id)?)
269            } else {
270                None
271            };
272
273            // Merge expression/tz with the existing schedule so that
274            // --tz alone updates the timezone and --expression alone
275            // preserves the existing timezone.
276            let schedule = if expression.is_some() || tz.is_some() {
277                let existing = existing
278                    .as_ref()
279                    .expect("existing job must be loaded when updating schedule");
280                let (existing_expr, existing_tz) = match &existing.schedule {
281                    Schedule::Cron {
282                        expr,
283                        tz: existing_tz,
284                    } => (expr.clone(), existing_tz.clone()),
285                    _ => bail!("Cannot update expression/tz on a non-cron schedule"),
286                };
287                Some(Schedule::Cron {
288                    expr: expression.unwrap_or(existing_expr),
289                    tz: tz.or(existing_tz),
290                })
291            } else {
292                None
293            };
294
295            if !allowed_tools.is_empty() {
296                let existing = existing
297                    .as_ref()
298                    .expect("existing job must be loaded when updating allowed tools");
299                if existing.job_type != JobType::Agent {
300                    bail!("--allowed-tool is only supported for agent cron jobs");
301                }
302            }
303
304            let patch = CronJobPatch {
305                schedule,
306                command,
307                name,
308                allowed_tools: if allowed_tools.is_empty() {
309                    None
310                } else {
311                    Some(allowed_tools)
312                },
313                ..CronJobPatch::default()
314            };
315
316            let job = update_shell_job_with_approval(config, &agent_alias, &id, patch, false)?;
317            println!("\u{2705} Updated cron job {}", job.id);
318            println!("  Expr: {}", job.expression);
319            println!("  Next: {}", job.next_run.to_rfc3339());
320            println!("  Cmd : {}", job.command);
321            Ok(())
322        }
323        crate::CronCommands::Remove { id } => remove_job(config, &id),
324        crate::CronCommands::Pause { id } => {
325            pause_job(config, &id)?;
326            println!("⏸️  Paused cron job {id}");
327            Ok(())
328        }
329        crate::CronCommands::Resume { id } => {
330            resume_job(config, &id)?;
331            println!("▢️  Resumed cron job {id}");
332            Ok(())
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use tempfile::TempDir;
341
342    fn test_config(tmp: &TempDir) -> Config {
343        let mut config = Config {
344            data_dir: tmp.path().join("workspace"),
345            config_path: tmp.path().join("config.toml"),
346            ..Config::default()
347        };
348        std::fs::create_dir_all(&config.data_dir).unwrap();
349        config
350            .risk_profiles
351            .entry("test-agent".to_string())
352            .or_default();
353        config
354            .runtime_profiles
355            .entry("test-agent".to_string())
356            .or_default();
357        config
358            .providers
359            .models
360            .ensure("openrouter", "test-agent")
361            .expect("known family");
362        config.agents.entry("test-agent".to_string()).or_insert(
363            zeroclaw_config::schema::AliasedAgentConfig {
364                model_provider: "openrouter.test-agent".into(),
365                risk_profile: "test-agent".to_string(),
366                runtime_profile: "test-agent".to_string(),
367                ..Default::default()
368            },
369        );
370        config
371    }
372
373    #[test]
374    fn cli_add_at_rejects_timestamp_without_explicit_offset_with_actionable_error() {
375        let tmp = TempDir::new().unwrap();
376        let config = test_config(&tmp);
377
378        let result = handle_command(
379            crate::CronCommands::AddAt {
380                at: "2026-05-18T09:00:00".into(),
381                agent_alias: "test-agent".into(),
382                prompt: false,
383                allowed_tools: vec![],
384                command: "echo at".into(),
385            },
386            &config,
387        );
388
389        let error = result.expect_err("bare local timestamp must be rejected");
390        let message = error.to_string();
391        assert!(
392            message.contains("RFC3339 timestamp with explicit Z or offset"),
393            "error should explain the explicit offset requirement: {message}"
394        );
395        assert!(message.contains("2026-05-18T09:00:00Z"));
396        assert!(message.contains("2026-05-18T09:00:00-04:00"));
397    }
398}