Skip to main content

zeroclaw_runtime/tools/
cron_runs.rs

1use crate::cron;
2use async_trait::async_trait;
3use serde::Serialize;
4use serde_json::json;
5use std::sync::Arc;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::schema::Config;
8
9const MAX_RUN_OUTPUT_CHARS: usize = 500;
10
11pub struct CronRunsTool {
12    config: Arc<Config>,
13}
14
15impl CronRunsTool {
16    pub fn new(config: Arc<Config>) -> Self {
17        Self { config }
18    }
19}
20
21#[derive(Serialize)]
22struct RunView {
23    id: i64,
24    job_id: String,
25    started_at: chrono::DateTime<chrono::Utc>,
26    finished_at: chrono::DateTime<chrono::Utc>,
27    status: String,
28    output: Option<String>,
29    duration_ms: Option<i64>,
30}
31
32#[async_trait]
33impl Tool for CronRunsTool {
34    fn name(&self) -> &str {
35        "cron_runs"
36    }
37
38    fn description(&self) -> &str {
39        "List recent run history for a cron job"
40    }
41
42    fn parameters_schema(&self) -> serde_json::Value {
43        json!({
44            "type": "object",
45            "properties": {
46                "job_id": { "type": "string" },
47                "limit": { "type": "integer" }
48            },
49            "required": ["job_id"]
50        })
51    }
52
53    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
54        if !self.config.scheduler.enabled {
55            return Ok(ToolResult {
56                success: false,
57                output: String::new(),
58                error: Some("cron is disabled by config (scheduler.enabled=false)".to_string()),
59            });
60        }
61
62        let job_id = match args.get("job_id").and_then(serde_json::Value::as_str) {
63            Some(v) if !v.trim().is_empty() => v,
64            _ => {
65                return Ok(ToolResult {
66                    success: false,
67                    output: String::new(),
68                    error: Some("Missing 'job_id' parameter".to_string()),
69                });
70            }
71        };
72
73        let limit = args
74            .get("limit")
75            .and_then(serde_json::Value::as_u64)
76            .map_or(10, |v| usize::try_from(v).unwrap_or(10));
77
78        match cron::list_runs(&self.config, job_id, limit) {
79            Ok(runs) => {
80                let runs: Vec<RunView> = runs
81                    .into_iter()
82                    .map(|run| RunView {
83                        id: run.id,
84                        job_id: run.job_id,
85                        started_at: run.started_at,
86                        finished_at: run.finished_at,
87                        status: run.status,
88                        output: run.output.map(|out| truncate(&out, MAX_RUN_OUTPUT_CHARS)),
89                        duration_ms: run.duration_ms,
90                    })
91                    .collect();
92
93                Ok(ToolResult {
94                    success: true,
95                    output: serde_json::to_string_pretty(&runs)?,
96                    error: None,
97                })
98            }
99            Err(e) => Ok(ToolResult {
100                success: false,
101                output: String::new(),
102                error: Some(e.to_string()),
103            }),
104        }
105    }
106}
107
108fn truncate(input: &str, max_chars: usize) -> String {
109    if input.chars().count() <= max_chars {
110        return input.to_string();
111    }
112    let mut out: String = input.chars().take(max_chars).collect();
113    out.push_str("...");
114    out
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use chrono::{Duration as ChronoDuration, Utc};
121    use tempfile::TempDir;
122    use zeroclaw_config::schema::Config;
123
124    const TEST_AGENT: &str = "test-agent";
125
126    async fn test_config(tmp: &TempDir) -> Arc<Config> {
127        let mut config = Config {
128            data_dir: tmp.path().join("data"),
129            config_path: tmp.path().join("config.toml"),
130            ..Config::default()
131        };
132        config.risk_profiles.insert(
133            TEST_AGENT.to_string(),
134            zeroclaw_config::schema::RiskProfileConfig::default(),
135        );
136        config.runtime_profiles.insert(
137            TEST_AGENT.to_string(),
138            zeroclaw_config::schema::RuntimeProfileConfig::default(),
139        );
140        config.providers.models.openrouter.insert(
141            TEST_AGENT.to_string(),
142            zeroclaw_config::schema::OpenRouterModelProviderConfig::default(),
143        );
144        config.agents.insert(
145            TEST_AGENT.to_string(),
146            zeroclaw_config::schema::AliasedAgentConfig {
147                model_provider: format!("openrouter.{TEST_AGENT}").into(),
148                risk_profile: TEST_AGENT.to_string(),
149                runtime_profile: TEST_AGENT.to_string(),
150                ..Default::default()
151            },
152        );
153        tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
154        Arc::new(config)
155    }
156
157    #[tokio::test]
158    async fn lists_runs_with_truncation() {
159        let tmp = TempDir::new().unwrap();
160        let cfg = test_config(&tmp).await;
161        let job = cron::add_job(&cfg, TEST_AGENT, "*/5 * * * *", "echo ok").unwrap();
162
163        let long_output = "x".repeat(1000);
164        let now = Utc::now();
165        cron::record_run(
166            &cfg,
167            &job.id,
168            now,
169            now + ChronoDuration::milliseconds(1),
170            "ok",
171            Some(&long_output),
172            1,
173        )
174        .unwrap();
175
176        let tool = CronRunsTool::new(cfg.clone());
177        let result = tool
178            .execute(json!({ "job_id": job.id, "limit": 5 }))
179            .await
180            .unwrap();
181
182        assert!(result.success);
183        assert!(result.output.contains("..."));
184    }
185
186    #[tokio::test]
187    async fn errors_when_job_id_missing() {
188        let tmp = TempDir::new().unwrap();
189        let cfg = test_config(&tmp).await;
190        let tool = CronRunsTool::new(cfg);
191        let result = tool.execute(json!({})).await.unwrap();
192        assert!(!result.success);
193        assert!(
194            result
195                .error
196                .unwrap_or_default()
197                .contains("Missing 'job_id'")
198        );
199    }
200}