Skip to main content

zeroclaw/cron/
mod.rs

1pub use zeroclaw_runtime::cron::*;
2
3use crate::config::Config;
4use anyhow::{Result, bail};
5use zeroclaw_runtime::i18n::{get_required_cli_string, get_required_cli_string_with_args};
6
7/// Bail with a clear error if the named agent isn't configured.
8fn require_configured_agent(config: &Config, agent_alias: &str) -> Result<()> {
9    if config.agent(agent_alias).is_none() {
10        ::zeroclaw_log::record!(
11            WARN,
12            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
13                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
14                .with_attrs(::serde_json::json!({"agent_alias": agent_alias})),
15            "cron CLI rejected: unknown agent alias"
16        );
17        anyhow::bail!("Unknown agent {agent_alias:?} (no [agents.{agent_alias}] entry configured)");
18    }
19    Ok(())
20}
21
22fn parse_explicit_rfc3339_utc(raw: &str) -> Result<chrono::DateTime<chrono::Utc>> {
23    chrono::DateTime::parse_from_rfc3339(raw)
24        .map(|timestamp| timestamp.with_timezone(&chrono::Utc))
25        .map_err(|err| {
26            ::zeroclaw_log::record!(
27                WARN,
28                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
29                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
30                    .with_attrs(::serde_json::json!({
31                        "raw": raw,
32                        "error": format!("{}", err),
33                    })),
34                "cron --at rejected: timestamp lacks explicit Z/offset or is malformed"
35            );
36            anyhow::Error::msg(format!(
37                "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}"
38            ))
39        })
40}
41
42pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<()> {
43    match command {
44        crate::CronCommands::List => {
45            let jobs = list_jobs(config)?;
46            if jobs.is_empty() {
47                println!("{}", get_required_cli_string("cli-cron-none"));
48                println!("\n{}", get_required_cli_string("cli-cron-usage"));
49                println!("  zeroclaw cron add '0 9 * * *' 'agent -m \"Good morning!\"'"); // i18n-exempt: literal command example
50                return Ok(());
51            }
52
53            println!(
54                "{}",
55                get_required_cli_string_with_args(
56                    "cli-cron-jobs-header",
57                    &[("count", &jobs.len().to_string())]
58                )
59            );
60            for job in jobs {
61                let last_run = job
62                    .last_run
63                    .map_or_else(|| "never".into(), |d| d.to_rfc3339());
64                let last_status = job.last_status.unwrap_or_else(|| "n/a".into());
65                println!(
66                    "- {} | {:?} | next={} | last={} ({})",
67                    job.id,
68                    job.schedule,
69                    job.next_run.to_rfc3339(),
70                    last_run,
71                    last_status,
72                );
73                if !job.command.is_empty() {
74                    println!(
75                        "{}",
76                        get_required_cli_string_with_args(
77                            "cli-cron-list-cmd",
78                            &[("cmd", &job.command)]
79                        )
80                    );
81                }
82                if let Some(prompt) = &job.prompt {
83                    println!(
84                        "{}",
85                        get_required_cli_string_with_args(
86                            "cli-cron-list-prompt",
87                            &[("prompt", prompt)]
88                        )
89                    );
90                }
91            }
92            Ok(())
93        }
94        crate::CronCommands::Add {
95            expression,
96            agent_alias,
97            tz,
98            prompt,
99            allowed_tools,
100            command,
101        } => {
102            require_configured_agent(config, &agent_alias)?;
103            let schedule = Schedule::Cron {
104                expr: expression,
105                tz,
106            };
107            if prompt {
108                let job = add_agent_job(
109                    config,
110                    &agent_alias,
111                    None,
112                    schedule,
113                    &command,
114                    SessionTarget::Isolated,
115                    None,
116                    None,
117                    false,
118                    if allowed_tools.is_empty() {
119                        None
120                    } else {
121                        Some(allowed_tools)
122                    },
123                )?;
124                println!(
125                    "{}",
126                    get_required_cli_string_with_args("cli-cron-added-agent", &[("id", &job.id)])
127                );
128                println!(
129                    "{}",
130                    get_required_cli_string_with_args("cli-cron-expr", &[("v", &job.expression)])
131                );
132                println!(
133                    "{}",
134                    get_required_cli_string_with_args(
135                        "cli-cron-next",
136                        &[("v", &job.next_run.to_rfc3339())]
137                    )
138                );
139                println!(
140                    "{}",
141                    get_required_cli_string_with_args(
142                        "cli-cron-prompt",
143                        &[("v", job.prompt.as_deref().unwrap_or_default())]
144                    )
145                );
146            } else {
147                if !allowed_tools.is_empty() {
148                    bail!("--allowed-tool is only supported with --prompt cron jobs");
149                }
150                let job = add_shell_job(config, &agent_alias, None, schedule, &command)?;
151                println!(
152                    "{}",
153                    get_required_cli_string_with_args("cli-cron-added", &[("id", &job.id)])
154                );
155                println!(
156                    "{}",
157                    get_required_cli_string_with_args("cli-cron-expr2", &[("v", &job.expression)])
158                );
159                println!(
160                    "{}",
161                    get_required_cli_string_with_args(
162                        "cli-cron-next2",
163                        &[("v", &job.next_run.to_rfc3339())]
164                    )
165                );
166                println!(
167                    "{}",
168                    get_required_cli_string_with_args("cli-cron-cmd", &[("v", &job.command)])
169                );
170            }
171            Ok(())
172        }
173        crate::CronCommands::AddAt {
174            at,
175            agent_alias,
176            prompt,
177            allowed_tools,
178            command,
179        } => {
180            require_configured_agent(config, &agent_alias)?;
181            let at = parse_explicit_rfc3339_utc(&at)?;
182            let schedule = Schedule::At { at };
183            if prompt {
184                let job = add_agent_job(
185                    config,
186                    &agent_alias,
187                    None,
188                    schedule,
189                    &command,
190                    SessionTarget::Isolated,
191                    None,
192                    None,
193                    true,
194                    if allowed_tools.is_empty() {
195                        None
196                    } else {
197                        Some(allowed_tools)
198                    },
199                )?;
200                println!(
201                    "{}",
202                    get_required_cli_string_with_args(
203                        "cli-cron-added-oneshot-agent",
204                        &[("id", &job.id)]
205                    )
206                );
207                println!(
208                    "{}",
209                    get_required_cli_string_with_args(
210                        "cli-cron-at",
211                        &[("v", &job.next_run.to_rfc3339())]
212                    )
213                );
214                println!(
215                    "{}",
216                    get_required_cli_string_with_args(
217                        "cli-cron-prompt",
218                        &[("v", job.prompt.as_deref().unwrap_or_default())]
219                    )
220                );
221            } else {
222                if !allowed_tools.is_empty() {
223                    bail!("--allowed-tool is only supported with --prompt cron jobs");
224                }
225                let job = add_shell_job(config, &agent_alias, None, schedule, &command)?;
226                println!(
227                    "{}",
228                    get_required_cli_string_with_args("cli-cron-added-oneshot", &[("id", &job.id)])
229                );
230                println!(
231                    "{}",
232                    get_required_cli_string_with_args(
233                        "cli-cron-at2",
234                        &[("v", &job.next_run.to_rfc3339())]
235                    )
236                );
237                println!(
238                    "{}",
239                    get_required_cli_string_with_args("cli-cron-cmd", &[("v", &job.command)])
240                );
241            }
242            Ok(())
243        }
244        crate::CronCommands::AddEvery {
245            every_ms,
246            agent_alias,
247            prompt,
248            allowed_tools,
249            command,
250        } => {
251            require_configured_agent(config, &agent_alias)?;
252            let schedule = Schedule::Every { every_ms };
253            if prompt {
254                let job = add_agent_job(
255                    config,
256                    &agent_alias,
257                    None,
258                    schedule,
259                    &command,
260                    SessionTarget::Isolated,
261                    None,
262                    None,
263                    false,
264                    if allowed_tools.is_empty() {
265                        None
266                    } else {
267                        Some(allowed_tools)
268                    },
269                )?;
270                println!(
271                    "{}",
272                    get_required_cli_string_with_args(
273                        "cli-cron-added-interval-agent",
274                        &[("id", &job.id)]
275                    )
276                );
277                println!(
278                    "{}",
279                    get_required_cli_string_with_args(
280                        "cli-cron-every",
281                        &[("v", &every_ms.to_string())]
282                    )
283                );
284                println!(
285                    "{}",
286                    get_required_cli_string_with_args(
287                        "cli-cron-next3",
288                        &[("v", &job.next_run.to_rfc3339())]
289                    )
290                );
291                println!(
292                    "{}",
293                    get_required_cli_string_with_args(
294                        "cli-cron-prompt3",
295                        &[("v", job.prompt.as_deref().unwrap_or_default())]
296                    )
297                );
298            } else {
299                if !allowed_tools.is_empty() {
300                    bail!("--allowed-tool is only supported with --prompt cron jobs");
301                }
302                let job = add_shell_job(config, &agent_alias, None, schedule, &command)?;
303                println!(
304                    "{}",
305                    get_required_cli_string_with_args(
306                        "cli-cron-added-interval",
307                        &[("id", &job.id)]
308                    )
309                );
310                println!(
311                    "{}",
312                    get_required_cli_string_with_args(
313                        "cli-cron-every",
314                        &[("v", &every_ms.to_string())]
315                    )
316                );
317                println!(
318                    "{}",
319                    get_required_cli_string_with_args(
320                        "cli-cron-next3",
321                        &[("v", &job.next_run.to_rfc3339())]
322                    )
323                );
324                println!(
325                    "{}",
326                    get_required_cli_string_with_args("cli-cron-cmd3", &[("v", &job.command)])
327                );
328            }
329            Ok(())
330        }
331        crate::CronCommands::Once {
332            delay,
333            agent_alias,
334            prompt,
335            allowed_tools,
336            command,
337        } => {
338            require_configured_agent(config, &agent_alias)?;
339            if prompt {
340                let duration = parse_delay(&delay)?;
341                let at = chrono::Utc::now() + duration;
342                let schedule = Schedule::At { at };
343                let job = add_agent_job(
344                    config,
345                    &agent_alias,
346                    None,
347                    schedule,
348                    &command,
349                    SessionTarget::Isolated,
350                    None,
351                    None,
352                    true,
353                    if allowed_tools.is_empty() {
354                        None
355                    } else {
356                        Some(allowed_tools)
357                    },
358                )?;
359                println!(
360                    "{}",
361                    get_required_cli_string_with_args(
362                        "cli-cron-added-oneshot-agent",
363                        &[("id", &job.id)]
364                    )
365                );
366                println!(
367                    "{}",
368                    get_required_cli_string_with_args(
369                        "cli-cron-at",
370                        &[("v", &job.next_run.to_rfc3339())]
371                    )
372                );
373                println!(
374                    "{}",
375                    get_required_cli_string_with_args(
376                        "cli-cron-prompt",
377                        &[("v", job.prompt.as_deref().unwrap_or_default())]
378                    )
379                );
380            } else {
381                if !allowed_tools.is_empty() {
382                    bail!("--allowed-tool is only supported with --prompt cron jobs");
383                }
384                let job = add_once(config, &agent_alias, &delay, &command)?;
385                println!(
386                    "{}",
387                    get_required_cli_string_with_args("cli-cron-added-oneshot", &[("id", &job.id)])
388                );
389                println!(
390                    "{}",
391                    get_required_cli_string_with_args(
392                        "cli-cron-at2",
393                        &[("v", &job.next_run.to_rfc3339())]
394                    )
395                );
396                println!(
397                    "{}",
398                    get_required_cli_string_with_args("cli-cron-cmd", &[("v", &job.command)])
399                );
400            }
401            Ok(())
402        }
403        crate::CronCommands::Update {
404            id,
405            agent_alias,
406            expression,
407            tz,
408            command,
409            name,
410            allowed_tools,
411        } => {
412            require_configured_agent(config, &agent_alias)?;
413            if expression.is_none()
414                && tz.is_none()
415                && command.is_none()
416                && name.is_none()
417                && allowed_tools.is_empty()
418            {
419                bail!(
420                    "At least one of --expression, --tz, --command, --name, or --allowed-tool must be provided"
421                );
422            }
423
424            let existing = if expression.is_some() || tz.is_some() || !allowed_tools.is_empty() {
425                Some(get_job(config, &id)?)
426            } else {
427                None
428            };
429
430            // Merge expression/tz with the existing schedule so that
431            // --tz alone updates the timezone and --expression alone
432            // preserves the existing timezone.
433            let schedule = if expression.is_some() || tz.is_some() {
434                let existing = existing
435                    .as_ref()
436                    .expect("existing job must be loaded when updating schedule");
437                let (existing_expr, existing_tz) = match &existing.schedule {
438                    Schedule::Cron {
439                        expr,
440                        tz: existing_tz,
441                    } => (expr.clone(), existing_tz.clone()),
442                    _ => bail!("Cannot update expression/tz on a non-cron schedule"),
443                };
444                Some(Schedule::Cron {
445                    expr: expression.unwrap_or(existing_expr),
446                    tz: tz.or(existing_tz),
447                })
448            } else {
449                None
450            };
451
452            if !allowed_tools.is_empty() {
453                let existing = existing
454                    .as_ref()
455                    .expect("existing job must be loaded when updating allowed tools");
456                if existing.job_type != JobType::Agent {
457                    bail!("--allowed-tool is only supported for agent cron jobs");
458                }
459            }
460
461            let patch = CronJobPatch {
462                schedule,
463                command,
464                name,
465                allowed_tools: if allowed_tools.is_empty() {
466                    None
467                } else {
468                    Some(allowed_tools)
469                },
470                ..CronJobPatch::default()
471            };
472
473            let job = update_shell_job_with_approval(config, &agent_alias, &id, patch, false)?;
474            println!(
475                "{}",
476                get_required_cli_string_with_args("cli-cron-updated", &[("id", &job.id)])
477            );
478            println!(
479                "{}",
480                get_required_cli_string_with_args("cli-cron-expr2", &[("v", &job.expression)])
481            );
482            println!(
483                "{}",
484                get_required_cli_string_with_args(
485                    "cli-cron-next2",
486                    &[("v", &job.next_run.to_rfc3339())]
487                )
488            );
489            println!(
490                "{}",
491                get_required_cli_string_with_args("cli-cron-cmd", &[("v", &job.command)])
492            );
493            Ok(())
494        }
495        crate::CronCommands::Remove { id } => remove_job(config, &id),
496        crate::CronCommands::Pause { id } => {
497            pause_job(config, &id)?;
498            println!(
499                "{}",
500                get_required_cli_string_with_args("cli-cron-paused", &[("id", &id)])
501            );
502            Ok(())
503        }
504        crate::CronCommands::Resume { id } => {
505            resume_job(config, &id)?;
506            println!(
507                "{}",
508                get_required_cli_string_with_args("cli-cron-resumed", &[("id", &id)])
509            );
510            Ok(())
511        }
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use tempfile::TempDir;
519
520    fn test_config(tmp: &TempDir) -> Config {
521        let mut config = Config {
522            data_dir: tmp.path().join("workspace"),
523            config_path: tmp.path().join("config.toml"),
524            ..Config::default()
525        };
526        std::fs::create_dir_all(&config.data_dir).unwrap();
527        config
528            .risk_profiles
529            .entry("test-agent".to_string())
530            .or_default();
531        config
532            .runtime_profiles
533            .entry("test-agent".to_string())
534            .or_default();
535        config
536            .providers
537            .models
538            .ensure("openrouter", "test-agent")
539            .expect("known family");
540        config.agents.entry("test-agent".to_string()).or_insert(
541            zeroclaw_config::schema::AliasedAgentConfig {
542                model_provider: "openrouter.test-agent".into(),
543                risk_profile: "test-agent".to_string(),
544                runtime_profile: "test-agent".to_string(),
545                ..Default::default()
546            },
547        );
548        config
549    }
550
551    #[test]
552    fn cli_add_at_rejects_timestamp_without_explicit_offset_with_actionable_error() {
553        let tmp = TempDir::new().unwrap();
554        let config = test_config(&tmp);
555
556        let result = handle_command(
557            crate::CronCommands::AddAt {
558                at: "2026-05-18T09:00:00".into(),
559                agent_alias: "test-agent".into(),
560                prompt: false,
561                allowed_tools: vec![],
562                command: "echo at".into(),
563            },
564            &config,
565        );
566
567        let error = result.expect_err("bare local timestamp must be rejected");
568        let message = error.to_string();
569        assert!(
570            message.contains("RFC3339 timestamp with explicit Z or offset"),
571            "error should explain the explicit offset requirement: {message}"
572        );
573        assert!(message.contains("2026-05-18T09:00:00Z"));
574        assert!(message.contains("2026-05-18T09:00:00-04:00"));
575    }
576}