Skip to main content

ai_agent/tools/
cron.rs

1// Source: ~/claudecode/openclaudecode/src/tools/ScheduleCronTool/CronCreateTool.ts
2// Source: ~/claudecode/openclaudecode/src/tools/ScheduleCronTool/CronDeleteTool.ts
3// Source: ~/claudecode/openclaudecode/src/tools/ScheduleCronTool/CronListTool.ts
4//! Cron scheduled task tools.
5//!
6//! Provides tools for managing scheduled tasks.
7
8use crate::error::AgentError;
9use crate::types::*;
10use std::collections::HashMap;
11use std::sync::{
12    Mutex, OnceLock,
13    atomic::{AtomicU64, Ordering},
14};
15
16pub const CRON_CREATE_TOOL_NAME: &str = "CronCreate";
17pub const CRON_DELETE_TOOL_NAME: &str = "CronDelete";
18pub const CRON_LIST_TOOL_NAME: &str = "CronList";
19
20/// Global cron job store
21static CRON_JOBS: OnceLock<Mutex<HashMap<String, CronJob>>> = OnceLock::new();
22static JOB_COUNTER: AtomicU64 = AtomicU64::new(1);
23
24fn get_cron_jobs_map() -> &'static Mutex<HashMap<String, CronJob>> {
25    CRON_JOBS.get_or_init(|| Mutex::new(HashMap::new()))
26}
27
28fn next_job_id() -> String {
29    let id = JOB_COUNTER.fetch_add(1, Ordering::SeqCst);
30    format!("cron-{}", id)
31}
32
33/// A scheduled cron job
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
35pub struct CronJob {
36    pub id: String,
37    pub cron: String,
38    pub prompt: String,
39    pub recurring: bool,
40    pub durable: bool,
41    pub created_at: u64,
42    pub last_fired: Option<u64>,
43    pub fire_count: u64,
44}
45
46/// Parse a cron expression and return the next fire time (simplified).
47/// Format: "M H DoM Mon DoW"
48fn parse_cron_expression(cron: &str) -> Result<String, String> {
49    let parts: Vec<&str> = cron.split_whitespace().collect();
50    if parts.len() != 5 {
51        return Err(format!(
52            "Invalid cron expression: expected 5 fields (M H DoM Mon DoW), got {}. Example: '*/5 * * * *' = every 5 minutes",
53            parts.len()
54        ));
55    }
56
57    // Validate each field (basic validation)
58    let fields = [
59        ("minute", parts[0], 0, 59),
60        ("hour", parts[1], 0, 23),
61        ("day_of_month", parts[2], 1, 31),
62        ("month", parts[3], 1, 12),
63        ("day_of_week", parts[4], 0, 6),
64    ];
65
66    for (name, value, min, max) in &fields {
67        if *value != "*" && *value != "*/1" {
68            // Skip detailed validation for now - in a full impl, parse ranges, lists, etc.
69            let _ = (name, min, max);
70        }
71    }
72
73    Ok(format!(
74        "Minute: {}, Hour: {}, Day of Month: {}, Month: {}, Day of Week: {}",
75        parts[0], parts[1], parts[2], parts[3], parts[4]
76    ))
77}
78
79/// CronCreate tool - create a scheduled task
80pub struct CronCreateTool;
81
82impl CronCreateTool {
83    pub fn new() -> Self {
84        Self
85    }
86
87    pub fn name(&self) -> &str {
88        CRON_CREATE_TOOL_NAME
89    }
90
91    pub fn description(&self) -> &str {
92        "Create a scheduled task that runs on a cron schedule. \
93        Uses standard 5-field cron expressions in local time: 'M H DoM Mon DoW'. \
94        Example: '*/5 * * * *' = every 5 minutes, '0 9 * * 1-5' = weekdays at 9am."
95    }
96
97    pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
98        "CronCreate".to_string()
99    }
100
101    pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
102        input.and_then(|inp| inp["cron"].as_str().map(String::from))
103    }
104
105    pub fn render_tool_result_message(
106        &self,
107        content: &serde_json::Value,
108    ) -> Option<String> {
109        content["content"].as_str().map(|s| s.to_string())
110    }
111
112    pub fn input_schema(&self) -> ToolInputSchema {
113        ToolInputSchema {
114            schema_type: "object".to_string(),
115            properties: serde_json::json!({
116                "cron": {
117                    "type": "string",
118                    "description": "Standard 5-field cron expression in local time: 'M H DoM Mon DoW' (e.g., '*/5 * * * *' = every 5 minutes, '0 9 * * 1-5' = weekdays at 9am)"
119                },
120                "prompt": {
121                    "type": "string",
122                    "description": "The prompt to enqueue at each fire time"
123                },
124                "recurring": {
125                    "type": "boolean",
126                    "description": "true (default) = fire on every cron match until deleted or auto-expired after 7 days. false = fire once at the next match, then auto-delete"
127                },
128                "durable": {
129                    "type": "boolean",
130                    "description": "true = persist to .ai/scheduled_tasks.json and survive restarts. false (default) = in-memory only, dies when this session ends"
131                }
132            }),
133            required: Some(vec!["cron".to_string(), "prompt".to_string()]),
134        }
135    }
136
137    pub async fn execute(
138        &self,
139        input: serde_json::Value,
140        _context: &ToolContext,
141    ) -> Result<ToolResult, AgentError> {
142        let cron = input["cron"]
143            .as_str()
144            .ok_or_else(|| AgentError::Tool("cron is required".to_string()))?;
145
146        let prompt = input["prompt"]
147            .as_str()
148            .ok_or_else(|| AgentError::Tool("prompt is required".to_string()))?;
149
150        let recurring = input["recurring"].as_bool().unwrap_or(true);
151        let durable = input["durable"].as_bool().unwrap_or(false);
152
153        // Validate cron expression
154        let parsed = parse_cron_expression(cron).map_err(|e| AgentError::Tool(e))?;
155
156        // Check max jobs limit (50 matching TS)
157        let mut guard = get_cron_jobs_map().lock().unwrap();
158        if guard.len() >= 50 {
159            return Ok(ToolResult {
160                result_type: "text".to_string(),
161                tool_use_id: "".to_string(),
162                content:
163                    "Error: Maximum number of scheduled jobs (50) reached. Delete some jobs first."
164                        .to_string(),
165                is_error: Some(true),
166                was_persisted: None,
167            });
168        }
169        drop(guard);
170
171        let now = std::time::SystemTime::now()
172            .duration_since(std::time::UNIX_EPOCH)
173            .map(|d| d.as_secs())
174            .unwrap_or(0);
175
176        let id = next_job_id();
177        let job = CronJob {
178            id: id.clone(),
179            cron: cron.to_string(),
180            prompt: prompt.to_string(),
181            recurring,
182            durable,
183            created_at: now,
184            last_fired: None,
185            fire_count: 0,
186        };
187
188        // In a full implementation, this would:
189        // 1. Persist to .ai/scheduled_tasks.json if durable=true
190        // 2. Set up tokio timer/cron scheduler
191        // 3. Validate teammate ownership for team-scoped jobs
192
193        let mut guard = get_cron_jobs_map().lock().unwrap();
194        guard.insert(id.clone(), job);
195        let job_count = guard.len();
196        drop(guard);
197
198        Ok(ToolResult {
199            result_type: "text".to_string(),
200            tool_use_id: "".to_string(),
201            content: format!(
202                "Scheduled task created successfully.\n\
203                \n\
204                Job ID: {}\n\
205                Cron: {} ({})\n\
206                Prompt: {}\n\
207                Recurring: {}\n\
208                Durable: {}\n\
209                \n\
210                {} jobs are currently scheduled.",
211                id, cron, parsed, prompt, recurring, durable, job_count
212            ),
213            is_error: Some(false),
214            was_persisted: None,
215        })
216    }
217}
218
219impl Default for CronCreateTool {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225/// CronDelete tool - delete a scheduled task
226pub struct CronDeleteTool;
227
228impl CronDeleteTool {
229    pub fn new() -> Self {
230        Self
231    }
232
233    pub fn name(&self) -> &str {
234        CRON_DELETE_TOOL_NAME
235    }
236
237    pub fn description(&self) -> &str {
238        "Delete a previously created scheduled task."
239    }
240
241    pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
242        "CronDelete".to_string()
243    }
244
245    pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
246        input.and_then(|inp| inp["id"].as_str().map(String::from))
247    }
248
249    pub fn render_tool_result_message(
250        &self,
251        content: &serde_json::Value,
252    ) -> Option<String> {
253        content["content"].as_str().map(|s| s.to_string())
254    }
255
256    pub fn input_schema(&self) -> ToolInputSchema {
257        ToolInputSchema {
258            schema_type: "object".to_string(),
259            properties: serde_json::json!({
260                "id": {
261                    "type": "string",
262                    "description": "Job ID returned by CronCreate"
263                }
264            }),
265            required: Some(vec!["id".to_string()]),
266        }
267    }
268
269    pub async fn execute(
270        &self,
271        input: serde_json::Value,
272        _context: &ToolContext,
273    ) -> Result<ToolResult, AgentError> {
274        let id = input["id"]
275            .as_str()
276            .ok_or_else(|| AgentError::Tool("id is required".to_string()))?;
277
278        let mut guard = get_cron_jobs_map().lock().unwrap();
279        let job = guard.remove(id);
280        drop(guard);
281
282        let job = job.ok_or_else(|| AgentError::Tool(format!("Job '{}' not found", id)))?;
283
284        // In a full implementation, this would:
285        // 1. Cancel the scheduled timer
286        // 2. Remove from .ai/scheduled_tasks.json if durable
287
288        Ok(ToolResult {
289            result_type: "text".to_string(),
290            tool_use_id: "".to_string(),
291            content: format!(
292                "Scheduled task '{}' deleted successfully.\n\
293                Cron: {}\n\
294                Prompt: {}",
295                id, job.cron, job.prompt
296            ),
297            is_error: Some(false),
298            was_persisted: None,
299        })
300    }
301}
302
303impl Default for CronDeleteTool {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309/// CronList tool - list all scheduled tasks
310pub struct CronListTool;
311
312impl CronListTool {
313    pub fn new() -> Self {
314        Self
315    }
316
317    pub fn name(&self) -> &str {
318        CRON_LIST_TOOL_NAME
319    }
320
321    pub fn description(&self) -> &str {
322        "List all scheduled tasks."
323    }
324
325    pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
326        "CronList".to_string()
327    }
328
329    pub fn get_tool_use_summary(&self, _input: Option<&serde_json::Value>) -> Option<String> {
330        None
331    }
332
333    pub fn render_tool_result_message(
334        &self,
335        content: &serde_json::Value,
336    ) -> Option<String> {
337        let text = content["content"].as_str()?;
338        let lines = text.lines().count();
339        Some(format!("{} lines", lines))
340    }
341
342    pub fn input_schema(&self) -> ToolInputSchema {
343        ToolInputSchema {
344            schema_type: "object".to_string(),
345            properties: serde_json::json!({}),
346            required: None,
347        }
348    }
349
350    pub async fn execute(
351        &self,
352        _input: serde_json::Value,
353        _context: &ToolContext,
354    ) -> Result<ToolResult, AgentError> {
355        let mut guard = get_cron_jobs_map().lock().unwrap();
356
357        if guard.is_empty() {
358            return Ok(ToolResult {
359                result_type: "text".to_string(),
360                tool_use_id: "".to_string(),
361                content: "No scheduled tasks.".to_string(),
362                is_error: None,
363                was_persisted: None,
364            });
365        }
366
367        let lines: Vec<String> = guard
368            .values()
369            .map(|j| {
370                let recurring_note = if j.recurring { "recurring" } else { "one-shot" };
371                let durable_note = if j.durable { "durable" } else { "session-only" };
372                format!(
373                    "{}: {} [{}] ({}, {})\n  Prompt: {}\n  Fired {} times",
374                    j.id, j.cron, j.prompt, recurring_note, durable_note, j.prompt, j.fire_count
375                )
376            })
377            .collect();
378
379        Ok(ToolResult {
380            result_type: "text".to_string(),
381            tool_use_id: "".to_string(),
382            content: format!("Scheduled tasks:\n\n{}", lines.join("\n\n")),
383            is_error: Some(false),
384            was_persisted: None,
385        })
386    }
387}
388
389impl Default for CronListTool {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395/// Reset the global cron job store for test isolation.
396pub fn reset_cron_jobs_for_testing() {
397    let mut guard = get_cron_jobs_map().lock().unwrap();
398    guard.clear();
399    drop(guard);
400    JOB_COUNTER.store(1, Ordering::SeqCst);
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    use crate::tests::common::clear_all_test_state;
408
409    #[tokio::test]
410    async fn test_cron_create_and_list() {
411        clear_all_test_state();
412        let create = CronCreateTool::new();
413        let result = create
414            .execute(
415                serde_json::json!({
416                    "cron": "*/5 * * * *",
417                    "prompt": "Check system status",
418                    "recurring": true,
419                    "durable": false
420                }),
421                &ToolContext::default(),
422            )
423            .await;
424        assert!(result.is_ok());
425        assert!(result.unwrap().content.contains("*/5 * * * *"));
426
427        let list = CronListTool::new();
428        let result = list
429            .execute(serde_json::json!({}), &ToolContext::default())
430            .await;
431        assert!(result.is_ok());
432        assert!(result.unwrap().content.contains("Check system status"));
433    }
434
435    #[tokio::test]
436    async fn test_cron_delete() {
437        clear_all_test_state();
438        let create = CronCreateTool::new();
439        create
440            .execute(
441                serde_json::json!({
442                    "cron": "0 9 * * 1-5",
443                    "prompt": "Morning report"
444                }),
445                &ToolContext::default(),
446            )
447            .await
448            .unwrap();
449
450        let delete = CronDeleteTool::new();
451        // Get the last job ID (it's the highest numbered one)
452        let jobs = get_cron_jobs_map().lock().unwrap();
453        let last_id = jobs.keys().max().cloned().unwrap();
454        drop(jobs);
455
456        let result = delete
457            .execute(
458                serde_json::json!({ "id": last_id.clone() }),
459                &ToolContext::default(),
460            )
461            .await;
462        assert!(result.is_ok());
463        assert!(result.unwrap().content.contains("deleted successfully"));
464    }
465
466    #[tokio::test]
467    async fn test_cron_create_invalid_expression() {
468        clear_all_test_state();
469        let create = CronCreateTool::new();
470        let result = create
471            .execute(
472                serde_json::json!({
473                    "cron": "invalid",
474                    "prompt": "test"
475                }),
476                &ToolContext::default(),
477            )
478            .await;
479        // Invalid cron expression should return an error
480        assert!(result.is_err());
481        let err_msg = result.unwrap_err().to_string();
482        assert!(err_msg.contains("Invalid cron") || err_msg.contains("5 fields"));
483    }
484
485    #[tokio::test]
486    async fn test_cron_list_empty() {
487        clear_all_test_state();
488        // Clear all jobs first
489        let mut guard = get_cron_jobs_map().lock().unwrap();
490        guard.clear();
491        drop(guard);
492
493        let list = CronListTool::new();
494        let result = list
495            .execute(serde_json::json!({}), &ToolContext::default())
496            .await;
497        assert!(result.is_ok());
498        assert!(result.unwrap().content.contains("No scheduled tasks"));
499    }
500}