1use serde_json::Value;
7
8use super::{RiskLevel, Tool, ToolContext, ToolError, ToolResult};
9
10pub struct CronTool;
11
12#[async_trait::async_trait]
13impl Tool for CronTool {
14 fn name(&self) -> &str {
15 "cron"
16 }
17
18 fn description(&self) -> &str {
19 "Manage scheduled cron jobs. Actions: 'create' (schedule a new job), \
20 'list' (show all scheduled jobs), 'delete' (remove a job by name or ID). \
21 For 'create', provide name, schedule (cron expression like '0 9 * * *'), \
22 and task (the instruction to execute on each run)."
23 }
24
25 fn risk_level(&self) -> RiskLevel {
26 RiskLevel::Caution
27 }
28
29 fn parameters_schema(&self) -> Value {
30 serde_json::json!({
31 "type": "object",
32 "properties": {
33 "action": {
34 "type": "string",
35 "enum": ["create", "list", "delete"],
36 "description": "The cron operation to perform"
37 },
38 "name": {
39 "type": "string",
40 "description": "Name for the cron job (required for create, optional for delete)"
41 },
42 "schedule": {
43 "type": "string",
44 "description": "Cron expression (e.g. '0 9 * * *' for daily at 9am). Required for create."
45 },
46 "task": {
47 "type": "string",
48 "description": "The instruction to execute on each run. Required for create."
49 },
50 "id": {
51 "type": "string",
52 "description": "Job ID to delete (alternative to name for delete action)"
53 }
54 },
55 "required": ["action"]
56 })
57 }
58
59 async fn execute(
60 &self,
61 params: Value,
62 ctx: &ToolContext,
63 ) -> std::result::Result<ToolResult, ToolError> {
64 let db = ctx.db.as_ref().ok_or_else(|| ToolError {
65 message: "database not available in tool context".into(),
66 })?;
67
68 let action = params
69 .get("action")
70 .and_then(|v| v.as_str())
71 .ok_or_else(|| ToolError {
72 message: "missing 'action' parameter".into(),
73 })?;
74
75 match action {
76 "create" => {
77 let name =
78 params
79 .get("name")
80 .and_then(|v| v.as_str())
81 .ok_or_else(|| ToolError {
82 message: "missing 'name' parameter for create".into(),
83 })?;
84 let schedule =
85 params
86 .get("schedule")
87 .and_then(|v| v.as_str())
88 .ok_or_else(|| ToolError {
89 message: "missing 'schedule' parameter for create".into(),
90 })?;
91 let task =
92 params
93 .get("task")
94 .and_then(|v| v.as_str())
95 .ok_or_else(|| ToolError {
96 message: "missing 'task' parameter for create".into(),
97 })?;
98
99 let payload = serde_json::json!({ "task": task }).to_string();
100 let job_id = roboticus_db::cron::create_job(
101 db,
102 name,
103 &ctx.agent_id,
104 "cron",
105 Some(schedule),
106 &payload,
107 )
108 .map_err(|e| ToolError {
109 message: format!("failed to create cron job: {e}"),
110 })?;
111
112 Ok(ToolResult {
113 output: format!(
114 "Cron job created:\n ID: {job_id}\n Name: {name}\n Schedule: {schedule}\n Task: {task}"
115 ),
116 metadata: Some(serde_json::json!({
117 "job_id": job_id,
118 "name": name,
119 "schedule": schedule,
120 })),
121 })
122 }
123 "list" => {
124 let jobs = roboticus_db::cron::list_jobs(db).map_err(|e| ToolError {
125 message: format!("failed to list cron jobs: {e}"),
126 })?;
127
128 if jobs.is_empty() {
129 return Ok(ToolResult {
130 output: "No cron jobs scheduled.".to_string(),
131 metadata: None,
132 });
133 }
134
135 let mut lines = vec![format!("{} cron job(s):", jobs.len())];
136 for job in &jobs {
137 let status = if job.enabled { "enabled" } else { "disabled" };
138 let schedule = job.schedule_expr.as_deref().unwrap_or("(no schedule)");
139 let last_run = job.last_run_at.as_deref().unwrap_or("never");
140 lines.push(format!(
141 " - {} [{}] schedule={} last_run={} ({})",
142 job.name, job.id, schedule, last_run, status
143 ));
144 }
145
146 Ok(ToolResult {
147 output: lines.join("\n"),
148 metadata: Some(serde_json::json!({ "count": jobs.len() })),
149 })
150 }
151 "delete" => {
152 let id_or_name = params
153 .get("id")
154 .or_else(|| params.get("name"))
155 .and_then(|v| v.as_str())
156 .ok_or_else(|| ToolError {
157 message: "missing 'id' or 'name' parameter for delete".into(),
158 })?;
159
160 let job_id = if roboticus_db::cron::get_job(db, id_or_name)
162 .ok()
163 .flatten()
164 .is_some()
165 {
166 id_or_name.to_string()
167 } else {
168 let jobs = roboticus_db::cron::list_jobs(db).map_err(|e| ToolError {
170 message: format!("failed to list cron jobs: {e}"),
171 })?;
172 let found = jobs
173 .iter()
174 .find(|j| j.name.eq_ignore_ascii_case(id_or_name));
175 match found {
176 Some(job) => job.id.clone(),
177 None => {
178 return Ok(ToolResult {
179 output: format!(
180 "No cron job found with ID or name '{id_or_name}'."
181 ),
182 metadata: None,
183 });
184 }
185 }
186 };
187
188 roboticus_db::cron::delete_job(db, &job_id).map_err(|e| ToolError {
189 message: format!("failed to delete cron job: {e}"),
190 })?;
191
192 Ok(ToolResult {
193 output: format!("Cron job '{id_or_name}' deleted."),
194 metadata: Some(serde_json::json!({ "deleted_id": job_id })),
195 })
196 }
197 other => Err(ToolError {
198 message: format!("unknown action '{other}'. Valid actions: create, list, delete"),
199 }),
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::tools::{Tool, ToolContext, ToolSandboxSnapshot};
208 use roboticus_core::InputAuthority;
209
210 fn test_ctx_with_db() -> ToolContext {
211 let db = roboticus_db::Database::new(":memory:").unwrap();
212 ToolContext {
213 session_id: "test-session".into(),
214 agent_id: "test-agent".into(),
215 agent_name: "Test Agent".into(),
216 authority: InputAuthority::Creator,
217 workspace_root: std::env::current_dir().unwrap(),
218 tool_allowed_paths: vec![],
219 channel: None,
220 db: Some(db),
221 sandbox: ToolSandboxSnapshot::default(),
222 }
223 }
224
225 #[test]
226 fn cron_tool_metadata() {
227 let tool = CronTool;
228 assert_eq!(tool.name(), "cron");
229 assert_eq!(tool.risk_level(), RiskLevel::Caution);
230 let schema = tool.parameters_schema();
231 assert_eq!(schema["properties"]["action"]["type"], "string");
232 }
233
234 #[tokio::test]
235 async fn create_and_list() {
236 let ctx = test_ctx_with_db();
237 let tool = CronTool;
238
239 let result = tool
241 .execute(serde_json::json!({"action": "list"}), &ctx)
242 .await
243 .unwrap();
244 assert!(result.output.contains("No cron jobs"));
245
246 let result = tool
248 .execute(
249 serde_json::json!({
250 "action": "create",
251 "name": "daily-summary",
252 "schedule": "0 9 * * *",
253 "task": "Summarise yesterday's events"
254 }),
255 &ctx,
256 )
257 .await
258 .unwrap();
259 assert!(result.output.contains("Cron job created"));
260 assert!(result.output.contains("daily-summary"));
261 assert!(result.metadata.is_some());
262
263 let result = tool
265 .execute(serde_json::json!({"action": "list"}), &ctx)
266 .await
267 .unwrap();
268 assert!(result.output.contains("1 cron job(s)"));
269 assert!(result.output.contains("daily-summary"));
270 }
271
272 #[tokio::test]
273 async fn create_missing_params_errors() {
274 let ctx = test_ctx_with_db();
275 let tool = CronTool;
276
277 let err = tool
278 .execute(
279 serde_json::json!({"action": "create", "name": "test"}),
280 &ctx,
281 )
282 .await
283 .unwrap_err();
284 assert!(err.message.contains("missing 'schedule'"));
285
286 let err = tool
287 .execute(
288 serde_json::json!({"action": "create", "name": "test", "schedule": "0 * * * *"}),
289 &ctx,
290 )
291 .await
292 .unwrap_err();
293 assert!(err.message.contains("missing 'task'"));
294 }
295
296 #[tokio::test]
297 async fn delete_by_name() {
298 let ctx = test_ctx_with_db();
299 let tool = CronTool;
300
301 tool.execute(
303 serde_json::json!({
304 "action": "create",
305 "name": "to-delete",
306 "schedule": "0 * * * *",
307 "task": "placeholder"
308 }),
309 &ctx,
310 )
311 .await
312 .unwrap();
313
314 let result = tool
316 .execute(
317 serde_json::json!({"action": "delete", "name": "to-delete"}),
318 &ctx,
319 )
320 .await
321 .unwrap();
322 assert!(result.output.contains("deleted"));
323
324 let result = tool
326 .execute(serde_json::json!({"action": "list"}), &ctx)
327 .await
328 .unwrap();
329 assert!(result.output.contains("No cron jobs"));
330 }
331
332 #[tokio::test]
333 async fn delete_nonexistent_returns_not_found() {
334 let ctx = test_ctx_with_db();
335 let tool = CronTool;
336
337 let result = tool
338 .execute(
339 serde_json::json!({"action": "delete", "name": "ghost"}),
340 &ctx,
341 )
342 .await
343 .unwrap();
344 assert!(result.output.contains("No cron job found"));
345 }
346
347 #[tokio::test]
348 async fn unknown_action_errors() {
349 let ctx = test_ctx_with_db();
350 let tool = CronTool;
351
352 let err = tool
353 .execute(serde_json::json!({"action": "purge"}), &ctx)
354 .await
355 .unwrap_err();
356 assert!(err.message.contains("unknown action 'purge'"));
357 }
358
359 #[tokio::test]
360 async fn no_db_errors() {
361 let ctx = ToolContext {
362 session_id: "test".into(),
363 agent_id: "test".into(),
364 agent_name: "Test".into(),
365 authority: InputAuthority::Creator,
366 workspace_root: std::env::current_dir().unwrap(),
367 tool_allowed_paths: vec![],
368 channel: None,
369 db: None,
370 sandbox: ToolSandboxSnapshot::default(),
371 };
372 let tool = CronTool;
373
374 let err = tool
375 .execute(serde_json::json!({"action": "list"}), &ctx)
376 .await
377 .unwrap_err();
378 assert!(err.message.contains("database not available"));
379 }
380
381 #[tokio::test]
382 async fn missing_action_errors() {
383 let ctx = test_ctx_with_db();
384 let tool = CronTool;
385
386 let err = tool.execute(serde_json::json!({}), &ctx).await.unwrap_err();
387 assert!(err.message.contains("missing 'action'"));
388 }
389}