//! Cron Manage Tool
//!
//! Allows the agent to create, list, delete, enable, and disable cron jobs.
//! Jobs run in isolated sessions with configurable provider/model/thinking.
use super::error::Result;
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use crate::db::CronJobRepository;
use crate::db::models::CronJob;
use async_trait::async_trait;
use serde_json::Value;
/// Tool for managing cron jobs via the agent.
pub struct CronManageTool {
repo: CronJobRepository,
}
impl CronManageTool {
pub fn new(repo: CronJobRepository) -> Self {
Self { repo }
}
}
#[async_trait]
impl Tool for CronManageTool {
fn name(&self) -> &str {
"cron_manage"
}
fn description(&self) -> &str {
"Manage scheduled cron jobs. Jobs run in isolated sessions with configurable provider/model. \
Use 'create' to schedule a new job, 'list' to see all jobs, 'delete' to remove one, \
'enable'/'disable' to toggle a job without deleting it."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "list", "delete", "enable", "disable", "test"],
"description": "Action to perform. 'test' triggers a job immediately (runs on next scheduler tick within 60s)"
},
"name": {
"type": "string",
"description": "Job name (required for create)"
},
"cron": {
"type": "string",
"description": "Cron expression, 5-field: 'min hour dom mon dow'. Required for create. Day-of-week is 1-7 = Sun-Sat (1=Sunday, 7=Saturday; 0 is INVALID) — prefer day NAMES (Sun, Mon..Sat, ranges like Mon-Fri) to avoid off-by-one mistakes. Month also accepts names (Jan-Mar). No @daily/@hourly macros. Examples: '0 9 * * *' (daily 9am), '*/30 * * * *' (every 30min), '0 9 * * Mon-Fri' (weekdays 9am), '0 22 * * Sun' (Sundays 10pm). The create reply shows the next run times — verify them."
},
"tz": {
"type": "string",
"description": "IANA timezone (default: UTC) — the schedule runs in this zone's local wall clock, DST-aware. Examples: America/New_York, Europe/London, Asia/Tokyo. An unknown zone is rejected."
},
"prompt": {
"type": "string",
"description": "Instructions for the agent to execute (required for create)"
},
"provider": {
"type": "string",
"description": "Override provider (e.g. 'anthropic', 'openai'). Omit for current default"
},
"model": {
"type": "string",
"description": "Override model (e.g. 'claude-sonnet-4-20250514'). Omit for provider default"
},
"thinking": {
"type": "string",
"enum": ["off", "on", "budget"],
"description": "Thinking mode (default: off)"
},
"auto_approve": {
"type": "boolean",
"description": "Auto-approve tool executions (default: true for cron)"
},
"deliver_to": {
"type": "string",
"description": "Where to deliver results. Format: 'telegram:chat_id', 'discord:channel_id', 'slack:channel_id', or an HTTP(S) URL for webhook delivery"
},
"deliver_api_key": {
"type": "string",
"description": "Optional Bearer token for HTTP webhook delivery. Added as Authorization: Bearer <key> header when delivering to an HTTP(S) URL."
},
"job_id": {
"type": "string",
"description": "Job ID (required for delete/enable/disable)"
},
"enabled": {
"type": "boolean",
"description": "Whether the job is enabled (for create, default: true)"
},
"confirm": {
"type": "boolean",
"description": "Must be true to actually delete a job. Without it, delete only shows job details as a safety check."
}
},
"required": ["action"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::SystemModification]
}
fn requires_approval_for_input(&self, input: &Value) -> bool {
// create, delete, and disable need approval; list/enable/test are safe
matches!(
input.get("action").and_then(|v| v.as_str()),
Some("create") | Some("delete") | Some("disable") | Some("test")
)
}
async fn execute(&self, input: Value, _context: &ToolExecutionContext) -> Result<ToolResult> {
let action = input
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("list");
match action {
"create" => self.create_job(&input).await,
"list" => self.list_jobs().await,
"delete" => self.delete_job(&input).await,
"enable" => self.toggle_job(&input, true).await,
"disable" => self.toggle_job(&input, false).await,
"test" => self.test_job(&input).await,
unknown => Ok(ToolResult::error(format!(
"Unknown action '{unknown}'. Valid: create, list, delete, enable, disable, test"
))),
}
}
}
impl CronManageTool {
async fn create_job(&self, input: &Value) -> Result<ToolResult> {
let name = match input.get("name").and_then(|v| v.as_str()) {
Some(n) if !n.is_empty() => n,
_ => {
return Ok(ToolResult::error(
"'name' is required for create".to_string(),
));
}
};
let cron_expr = match input.get("cron").and_then(|v| v.as_str()) {
Some(c) if !c.is_empty() => c,
_ => {
return Ok(ToolResult::error(
"'cron' expression is required for create".to_string(),
));
}
};
// Validate cron expression (user provides 5-field, we prepend "0" for seconds)
let cron_with_secs = format!("0 {cron_expr}");
if let Err(e) = cron_with_secs.parse::<cron::Schedule>() {
return Ok(ToolResult::error(format!(
"Invalid cron expression '{cron_expr}': {e}. Use 5-field format: 'min hour dom mon dow'. \
Day-of-week is 1-7 = Sun-Sat (1=Sunday, 7=Saturday; 0 is invalid) — prefer names like \
Mon-Fri or Sun to avoid mistakes. Example: '0 9 * * *' = daily 9am, '0 9 * * Mon-Fri' = weekdays 9am."
)));
}
let prompt = match input.get("prompt").and_then(|v| v.as_str()) {
Some(p) if !p.is_empty() => p,
_ => {
return Ok(ToolResult::error(
"'prompt' is required for create".to_string(),
));
}
};
// Check for duplicate name
if let Ok(Some(_)) = self.repo.find_by_name(name).await {
return Ok(ToolResult::error(format!(
"A cron job named '{name}' already exists. Use a different name or delete the existing one first."
)));
}
let tz = input
.get("tz")
.and_then(|v| v.as_str())
.unwrap_or("UTC")
.to_string();
// Validate the timezone now — it's honored by the scheduler (jobs run
// in this zone's wall clock, DST-aware), so an unknown zone must be
// rejected here rather than silently falling back to UTC.
let parsed_tz = match crate::cron::parse_timezone(&tz) {
Some(t) => t,
None => {
return Ok(ToolResult::error(format!(
"Unknown timezone '{tz}'. Use an IANA name like 'America/New_York', 'Europe/London', 'Asia/Tokyo', or 'UTC'."
)));
}
};
let provider = input
.get("provider")
.and_then(|v| v.as_str())
.map(String::from);
let model = input
.get("model")
.and_then(|v| v.as_str())
.map(String::from);
let thinking = input
.get("thinking")
.and_then(|v| v.as_str())
.unwrap_or("off")
.to_string();
let auto_approve = input
.get("auto_approve")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let deliver_to = input
.get("deliver_to")
.and_then(|v| v.as_str())
.map(String::from);
let deliver_api_key = input
.get("deliver_api_key")
.and_then(|v| v.as_str())
.map(String::from);
let job = CronJob::new(
name.to_string(),
cron_expr.to_string(),
tz,
prompt.to_string(),
provider,
model,
thinking,
auto_approve,
deliver_to.clone(),
deliver_api_key,
);
let job_id = job.id.to_string();
self.repo
.insert(&job)
.await
.map_err(|e| super::error::ToolError::Execution(e.to_string()))?;
let delivery = deliver_to
.as_deref()
.unwrap_or("none (results logged only)");
// Confirmation feedback: show the next few fire times in the job's
// timezone so the agent (and user) can verify the schedule means what
// they intended before treating it as done — this is what catches a
// day-of-week mistake that still parses fine.
let next_runs = crate::cron::format_upcoming(cron_expr, parsed_tz, 3, chrono::Utc::now());
Ok(ToolResult::success(format!(
"Cron job created:\n ID: {job_id}\n Name: {name}\n Schedule: {cron_expr}\n Timezone: {}\n Deliver to: {delivery}\n Enabled: true\n Next runs:\n{next_runs}\n\nVerify the Next runs match what you intended (day-of-week is Sun-Sat, DST handled) before confirming to the user.",
job.timezone
)))
}
async fn list_jobs(&self) -> Result<ToolResult> {
let jobs = self
.repo
.list_all()
.await
.map_err(|e| super::error::ToolError::Execution(e.to_string()))?;
if jobs.is_empty() {
return Ok(ToolResult::success("No cron jobs configured.".to_string()));
}
let lines: Vec<String> = jobs
.iter()
.map(|j| {
let status = if j.enabled { "enabled" } else { "disabled" };
let deliver = j.deliver_to.as_deref().unwrap_or("none");
let last = j
.last_run_at
.map(|d| d.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_else(|| "never".to_string());
format!(
"- [{}] {} (id={})\n Schedule: {} ({})\n Deliver: {}\n Last run: {}\n Prompt: {}",
status,
j.name,
j.id,
j.cron_expr,
j.timezone,
deliver,
last,
truncate(&j.prompt, 80),
)
})
.collect();
Ok(ToolResult::success(format!(
"Cron jobs ({}):\n{}",
jobs.len(),
lines.join("\n")
)))
}
async fn delete_job(&self, input: &Value) -> Result<ToolResult> {
let job_id = match input.get("job_id").and_then(|v| v.as_str()) {
Some(id) if !id.is_empty() => id,
_ => {
return Ok(ToolResult::error(
"'job_id' is required for delete".to_string(),
));
}
};
// Step 1: Look up the job to show what will be deleted
let job = match self.repo.find_by_id(job_id).await {
Ok(Some(j)) => j,
Ok(None) => {
// Try by name
match self.repo.find_by_name(job_id).await {
Ok(Some(j)) => j,
_ => {
return Ok(ToolResult::error(format!(
"No cron job found with ID or name '{job_id}'."
)));
}
}
}
Err(e) => {
return Ok(ToolResult::error(format!("Error looking up cron job: {e}")));
}
};
let confirm = input
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !confirm {
// Safety check: show job details, do NOT delete
let deliver = job.deliver_to.as_deref().unwrap_or("none");
return Ok(ToolResult::success(format!(
"⚠️ DELETE REQUEST (not yet executed)\n\n\
Job: {} (id={})\n\
Schedule: {} ({})\n\
Deliver: {}\n\
Prompt: {}\n\n\
To confirm deletion, call again with confirm=true.\n\
Use 'disable' to temporarily pause without losing the job.",
job.name,
job.id,
job.cron_expr,
job.timezone,
deliver,
truncate(&job.prompt, 120)
)));
}
// Step 2: Back up all jobs before deleting
if let Ok(all_jobs) = self.repo.list_all().await {
self.backup_jobs(&all_jobs).await;
}
// Step 3: Actually delete
let deleted = self
.repo
.delete(&job.id.to_string())
.await
.map_err(|e| super::error::ToolError::Execution(e.to_string()))?;
if deleted {
Ok(ToolResult::success(format!(
"Cron job '{}' (id={}) deleted. Backup saved to ~/.opencrabs/backups/cron/",
job.name, job.id
)))
} else {
Ok(ToolResult::error(format!(
"Failed to delete cron job '{}' (id={}).",
job.name, job.id
)))
}
}
async fn backup_jobs(&self, jobs: &[CronJob]) {
use std::fs;
use std::path::PathBuf;
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let backup_dir = home.join(".opencrabs/backups/cron");
if fs::create_dir_all(&backup_dir).is_err() {
return;
}
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S");
let backup_path = backup_dir.join(format!("cron-jobs-{timestamp}.json"));
if let Ok(json) = serde_json::to_string_pretty(jobs) {
let _ = fs::write(&backup_path, json);
}
// Rotate: keep only the 10 most recent backups
if let Ok(entries) = fs::read_dir(&backup_dir) {
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n.starts_with("cron-jobs-") && n.ends_with(".json"))
.unwrap_or(false)
})
.collect();
files.sort_by_key(|e| e.file_name());
if files.len() > 10 {
for old in &files[..files.len() - 10] {
let _ = fs::remove_file(old.path());
}
}
}
}
async fn toggle_job(&self, input: &Value, enabled: bool) -> Result<ToolResult> {
let job_id = match input.get("job_id").and_then(|v| v.as_str()) {
Some(id) if !id.is_empty() => id,
_ => {
return Ok(ToolResult::error(
"'job_id' is required for enable/disable".to_string(),
));
}
};
let updated = self
.repo
.set_enabled(job_id, enabled)
.await
.map_err(|e| super::error::ToolError::Execution(e.to_string()))?;
if updated {
let state = if enabled { "enabled" } else { "disabled" };
Ok(ToolResult::success(format!("Cron job {job_id} {state}.")))
} else {
Ok(ToolResult::error(format!(
"No cron job found with ID '{job_id}'."
)))
}
}
async fn test_job(&self, input: &Value) -> Result<ToolResult> {
let job_id = match input.get("job_id").and_then(|v| v.as_str()) {
Some(id) if !id.is_empty() => id,
_ => {
// Also accept name
match input.get("name").and_then(|v| v.as_str()) {
Some(name) if !name.is_empty() => {
if let Ok(Some(job)) = self.repo.find_by_name(name).await {
return self.trigger_by_id(&job.id.to_string(), &job.name).await;
}
return Ok(ToolResult::error(format!(
"No cron job found with name '{name}'."
)));
}
_ => {
return Ok(ToolResult::error(
"'job_id' or 'name' is required for test".to_string(),
));
}
}
}
};
// Try ID first, then name
if let Ok(Some(job)) = self.repo.find_by_id(job_id).await {
return self.trigger_by_id(&job.id.to_string(), &job.name).await;
}
if let Ok(Some(job)) = self.repo.find_by_name(job_id).await {
return self.trigger_by_id(&job.id.to_string(), &job.name).await;
}
Ok(ToolResult::error(format!(
"No cron job found with ID or name '{job_id}'."
)))
}
async fn trigger_by_id(&self, id: &str, name: &str) -> Result<ToolResult> {
let triggered = self
.repo
.trigger_now(id)
.await
.map_err(|e| super::error::ToolError::Execution(e.to_string()))?;
if triggered {
Ok(ToolResult::success(format!(
"Cron job '{name}' (id={id}) triggered. It will execute on the next scheduler tick (within 60 seconds). Check logs for execution status."
)))
} else {
Ok(ToolResult::error(format!(
"Failed to trigger cron job '{id}'."
)))
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
format!("{}...", s.chars().take(max).collect::<String>())
}
}