Skip to main content

roboticus_agent/tools/
cron.rs

1//! Cron tool — manages scheduled jobs through the tool registry.
2//!
3//! Replaces the retired `CronShortcut` with a proper tool that routes through
4//! the ReAct loop and guard chain. Supports create, list, and delete operations.
5
6use 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                // Try by ID first, then search by name
161                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                    // Search by name
169                    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        // List should be empty
240        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        // Create a job
247        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        // List should now show one job
264        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        // Create
302        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        // Delete by name
315        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        // List should be empty again
325        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}