Skip to main content

zeroclaw_runtime/tools/
cron_remove.rs

1use crate::cron;
2use crate::security::SecurityPolicy;
3use async_trait::async_trait;
4use serde_json::json;
5use std::sync::Arc;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::schema::Config;
8
9pub struct CronRemoveTool {
10    config: Arc<Config>,
11    security: Arc<SecurityPolicy>,
12}
13
14impl CronRemoveTool {
15    pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
16        Self { config, security }
17    }
18
19    fn enforce_mutation_allowed(&self, action: &str) -> Option<ToolResult> {
20        if !self.security.can_act() {
21            return Some(ToolResult {
22                success: false,
23                output: String::new(),
24                error: Some(format!(
25                    "Security policy: read-only mode, cannot perform '{action}'"
26                )),
27            });
28        }
29
30        if self.security.is_rate_limited() {
31            return Some(ToolResult {
32                success: false,
33                output: String::new(),
34                error: Some("Rate limit exceeded: too many actions in the last hour".to_string()),
35            });
36        }
37
38        if !self.security.record_action() {
39            return Some(ToolResult {
40                success: false,
41                output: String::new(),
42                error: Some("Rate limit exceeded: action budget exhausted".to_string()),
43            });
44        }
45
46        None
47    }
48}
49
50#[async_trait]
51impl Tool for CronRemoveTool {
52    fn name(&self) -> &str {
53        "cron_remove"
54    }
55
56    fn description(&self) -> &str {
57        "Remove a cron job by id"
58    }
59
60    fn parameters_schema(&self) -> serde_json::Value {
61        json!({
62            "type": "object",
63            "properties": {
64                "job_id": { "type": "string" }
65            },
66            "required": ["job_id"]
67        })
68    }
69
70    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
71        if !self.config.scheduler.enabled {
72            return Ok(ToolResult {
73                success: false,
74                output: String::new(),
75                error: Some("cron is disabled by config (scheduler.enabled=false)".to_string()),
76            });
77        }
78
79        let job_id = match args.get("job_id").and_then(serde_json::Value::as_str) {
80            Some(v) if !v.trim().is_empty() => v,
81            _ => {
82                return Ok(ToolResult {
83                    success: false,
84                    output: String::new(),
85                    error: Some("Missing 'job_id' parameter".to_string()),
86                });
87            }
88        };
89
90        if let Some(blocked) = self.enforce_mutation_allowed("cron_remove") {
91            return Ok(blocked);
92        }
93
94        match cron::remove_job(&self.config, job_id) {
95            Ok(()) => Ok(ToolResult {
96                success: true,
97                output: format!("Removed cron job {job_id}"),
98                error: None,
99            }),
100            Err(e) => Ok(ToolResult {
101                success: false,
102                output: String::new(),
103                error: Some(e.to_string()),
104            }),
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::security::AutonomyLevel;
113    use tempfile::TempDir;
114    use zeroclaw_config::schema::Config;
115
116    const TEST_AGENT: &str = "test-agent";
117
118    async fn test_config(tmp: &TempDir) -> Arc<Config> {
119        let mut config = Config {
120            data_dir: tmp.path().join("data"),
121            config_path: tmp.path().join("config.toml"),
122            ..Config::default()
123        };
124        seed_test_agent(&mut config);
125        tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
126        Arc::new(config)
127    }
128
129    fn seed_test_agent(config: &mut Config) {
130        config
131            .risk_profiles
132            .entry(TEST_AGENT.to_string())
133            .or_default();
134        config
135            .runtime_profiles
136            .entry(TEST_AGENT.to_string())
137            .or_default();
138        config
139            .providers
140            .models
141            .ensure("openrouter", TEST_AGENT)
142            .expect("known family");
143        config.agents.entry(TEST_AGENT.to_string()).or_insert(
144            zeroclaw_config::schema::AliasedAgentConfig {
145                model_provider: format!("openrouter.{TEST_AGENT}").into(),
146                risk_profile: TEST_AGENT.to_string(),
147                runtime_profile: TEST_AGENT.to_string(),
148                ..Default::default()
149            },
150        );
151    }
152
153    fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
154        Arc::new(
155            SecurityPolicy::for_agent(cfg, TEST_AGENT).expect("test-agent has resolvable profiles"),
156        )
157    }
158
159    #[tokio::test]
160    async fn removes_existing_job() {
161        let tmp = TempDir::new().unwrap();
162        let cfg = test_config(&tmp).await;
163        let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
164        let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
165
166        let result = tool.execute(json!({"job_id": job.id})).await.unwrap();
167        assert!(result.success);
168        assert!(cron::list_jobs(&cfg).unwrap().is_empty());
169    }
170
171    #[tokio::test]
172    async fn errors_when_job_id_missing() {
173        let tmp = TempDir::new().unwrap();
174        let cfg = test_config(&tmp).await;
175        let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
176
177        let result = tool.execute(json!({})).await.unwrap();
178        assert!(!result.success);
179        assert!(
180            result
181                .error
182                .unwrap_or_default()
183                .contains("Missing 'job_id'")
184        );
185    }
186
187    #[tokio::test]
188    async fn blocks_remove_in_read_only_mode() {
189        let tmp = TempDir::new().unwrap();
190        let mut config = Config {
191            data_dir: tmp.path().join("data"),
192            config_path: tmp.path().join("config.toml"),
193            ..Config::default()
194        };
195        std::fs::create_dir_all(&config.data_dir).unwrap();
196        seed_test_agent(&mut config);
197        let job = cron::add_job(&config, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
198        config
199            .risk_profiles
200            .entry(TEST_AGENT.into())
201            .or_default()
202            .level = AutonomyLevel::ReadOnly;
203        let cfg = Arc::new(config);
204        let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
205
206        let result = tool.execute(json!({"job_id": job.id})).await.unwrap();
207        assert!(!result.success);
208        assert!(result.error.unwrap_or_default().contains("read-only"));
209    }
210
211    #[tokio::test]
212    async fn blocks_remove_when_rate_limited() {
213        let tmp = TempDir::new().unwrap();
214        let mut config = Config {
215            data_dir: tmp.path().join("data"),
216            config_path: tmp.path().join("config.toml"),
217            ..Config::default()
218        };
219        config
220            .risk_profiles
221            .entry(TEST_AGENT.into())
222            .or_default()
223            .level = AutonomyLevel::Full;
224        config
225            .runtime_profiles
226            .entry(TEST_AGENT.into())
227            .or_default()
228            .max_actions_per_hour = 0;
229        std::fs::create_dir_all(&config.data_dir).unwrap();
230        seed_test_agent(&mut config);
231        let cfg = Arc::new(config);
232        let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
233        let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
234
235        let result = tool.execute(json!({"job_id": job.id})).await.unwrap();
236        assert!(!result.success);
237        assert!(
238            result
239                .error
240                .unwrap_or_default()
241                .contains("Rate limit exceeded")
242        );
243        assert_eq!(cron::list_jobs(&cfg).unwrap().len(), 1);
244    }
245}