1pub use zeroclaw_runtime::cron::*;
2
3use crate::config::Config;
4use anyhow::{Result, bail};
5
6fn 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 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}