use serde_json::Value;
use super::{RiskLevel, Tool, ToolContext, ToolError, ToolResult};
pub struct CronTool;
#[async_trait::async_trait]
impl Tool for CronTool {
fn name(&self) -> &str {
"cron"
}
fn description(&self) -> &str {
"Manage scheduled cron jobs. Actions: 'create' (schedule a new job), \
'list' (show all scheduled jobs), 'delete' (remove a job by name or ID). \
For 'create', provide name, schedule (cron expression like '0 9 * * *'), \
and task (the instruction to execute on each run)."
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Caution
}
fn parameters_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "list", "delete"],
"description": "The cron operation to perform"
},
"name": {
"type": "string",
"description": "Name for the cron job (required for create, optional for delete)"
},
"schedule": {
"type": "string",
"description": "Cron expression (e.g. '0 9 * * *' for daily at 9am). Required for create."
},
"task": {
"type": "string",
"description": "The instruction to execute on each run. Required for create."
},
"id": {
"type": "string",
"description": "Job ID to delete (alternative to name for delete action)"
}
},
"required": ["action"]
})
}
async fn execute(
&self,
params: Value,
ctx: &ToolContext,
) -> std::result::Result<ToolResult, ToolError> {
let db = ctx.db.as_ref().ok_or_else(|| ToolError {
message: "database not available in tool context".into(),
})?;
let action = params
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'action' parameter".into(),
})?;
match action {
"create" => {
let name =
params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'name' parameter for create".into(),
})?;
let schedule =
params
.get("schedule")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'schedule' parameter for create".into(),
})?;
let task =
params
.get("task")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'task' parameter for create".into(),
})?;
let payload = serde_json::json!({ "task": task }).to_string();
let job_id = roboticus_db::cron::create_job(
db,
name,
&ctx.agent_id,
"cron",
Some(schedule),
&payload,
)
.map_err(|e| ToolError {
message: format!("failed to create cron job: {e}"),
})?;
Ok(ToolResult {
output: format!(
"Cron job created:\n ID: {job_id}\n Name: {name}\n Schedule: {schedule}\n Task: {task}"
),
metadata: Some(serde_json::json!({
"job_id": job_id,
"name": name,
"schedule": schedule,
})),
})
}
"list" => {
let jobs = roboticus_db::cron::list_jobs(db).map_err(|e| ToolError {
message: format!("failed to list cron jobs: {e}"),
})?;
if jobs.is_empty() {
return Ok(ToolResult {
output: "No cron jobs scheduled.".to_string(),
metadata: None,
});
}
let mut lines = vec![format!("{} cron job(s):", jobs.len())];
for job in &jobs {
let status = if job.enabled { "enabled" } else { "disabled" };
let schedule = job.schedule_expr.as_deref().unwrap_or("(no schedule)");
let last_run = job.last_run_at.as_deref().unwrap_or("never");
lines.push(format!(
" - {} [{}] schedule={} last_run={} ({})",
job.name, job.id, schedule, last_run, status
));
}
Ok(ToolResult {
output: lines.join("\n"),
metadata: Some(serde_json::json!({ "count": jobs.len() })),
})
}
"delete" => {
let id_or_name = params
.get("id")
.or_else(|| params.get("name"))
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError {
message: "missing 'id' or 'name' parameter for delete".into(),
})?;
let job_id = if roboticus_db::cron::get_job(db, id_or_name)
.ok()
.flatten()
.is_some()
{
id_or_name.to_string()
} else {
let jobs = roboticus_db::cron::list_jobs(db).map_err(|e| ToolError {
message: format!("failed to list cron jobs: {e}"),
})?;
let found = jobs
.iter()
.find(|j| j.name.eq_ignore_ascii_case(id_or_name));
match found {
Some(job) => job.id.clone(),
None => {
return Ok(ToolResult {
output: format!(
"No cron job found with ID or name '{id_or_name}'."
),
metadata: None,
});
}
}
};
roboticus_db::cron::delete_job(db, &job_id).map_err(|e| ToolError {
message: format!("failed to delete cron job: {e}"),
})?;
Ok(ToolResult {
output: format!("Cron job '{id_or_name}' deleted."),
metadata: Some(serde_json::json!({ "deleted_id": job_id })),
})
}
other => Err(ToolError {
message: format!("unknown action '{other}'. Valid actions: create, list, delete"),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::{Tool, ToolContext, ToolSandboxSnapshot};
use roboticus_core::InputAuthority;
fn test_ctx_with_db() -> ToolContext {
let db = roboticus_db::Database::new(":memory:").unwrap();
ToolContext {
session_id: "test-session".into(),
agent_id: "test-agent".into(),
agent_name: "Test Agent".into(),
authority: InputAuthority::Creator,
workspace_root: std::env::current_dir().unwrap(),
tool_allowed_paths: vec![],
channel: None,
db: Some(db),
sandbox: ToolSandboxSnapshot::default(),
}
}
#[test]
fn cron_tool_metadata() {
let tool = CronTool;
assert_eq!(tool.name(), "cron");
assert_eq!(tool.risk_level(), RiskLevel::Caution);
let schema = tool.parameters_schema();
assert_eq!(schema["properties"]["action"]["type"], "string");
}
#[tokio::test]
async fn create_and_list() {
let ctx = test_ctx_with_db();
let tool = CronTool;
let result = tool
.execute(serde_json::json!({"action": "list"}), &ctx)
.await
.unwrap();
assert!(result.output.contains("No cron jobs"));
let result = tool
.execute(
serde_json::json!({
"action": "create",
"name": "daily-summary",
"schedule": "0 9 * * *",
"task": "Summarise yesterday's events"
}),
&ctx,
)
.await
.unwrap();
assert!(result.output.contains("Cron job created"));
assert!(result.output.contains("daily-summary"));
assert!(result.metadata.is_some());
let result = tool
.execute(serde_json::json!({"action": "list"}), &ctx)
.await
.unwrap();
assert!(result.output.contains("1 cron job(s)"));
assert!(result.output.contains("daily-summary"));
}
#[tokio::test]
async fn create_missing_params_errors() {
let ctx = test_ctx_with_db();
let tool = CronTool;
let err = tool
.execute(
serde_json::json!({"action": "create", "name": "test"}),
&ctx,
)
.await
.unwrap_err();
assert!(err.message.contains("missing 'schedule'"));
let err = tool
.execute(
serde_json::json!({"action": "create", "name": "test", "schedule": "0 * * * *"}),
&ctx,
)
.await
.unwrap_err();
assert!(err.message.contains("missing 'task'"));
}
#[tokio::test]
async fn delete_by_name() {
let ctx = test_ctx_with_db();
let tool = CronTool;
tool.execute(
serde_json::json!({
"action": "create",
"name": "to-delete",
"schedule": "0 * * * *",
"task": "placeholder"
}),
&ctx,
)
.await
.unwrap();
let result = tool
.execute(
serde_json::json!({"action": "delete", "name": "to-delete"}),
&ctx,
)
.await
.unwrap();
assert!(result.output.contains("deleted"));
let result = tool
.execute(serde_json::json!({"action": "list"}), &ctx)
.await
.unwrap();
assert!(result.output.contains("No cron jobs"));
}
#[tokio::test]
async fn delete_nonexistent_returns_not_found() {
let ctx = test_ctx_with_db();
let tool = CronTool;
let result = tool
.execute(
serde_json::json!({"action": "delete", "name": "ghost"}),
&ctx,
)
.await
.unwrap();
assert!(result.output.contains("No cron job found"));
}
#[tokio::test]
async fn unknown_action_errors() {
let ctx = test_ctx_with_db();
let tool = CronTool;
let err = tool
.execute(serde_json::json!({"action": "purge"}), &ctx)
.await
.unwrap_err();
assert!(err.message.contains("unknown action 'purge'"));
}
#[tokio::test]
async fn no_db_errors() {
let ctx = ToolContext {
session_id: "test".into(),
agent_id: "test".into(),
agent_name: "Test".into(),
authority: InputAuthority::Creator,
workspace_root: std::env::current_dir().unwrap(),
tool_allowed_paths: vec![],
channel: None,
db: None,
sandbox: ToolSandboxSnapshot::default(),
};
let tool = CronTool;
let err = tool
.execute(serde_json::json!({"action": "list"}), &ctx)
.await
.unwrap_err();
assert!(err.message.contains("database not available"));
}
#[tokio::test]
async fn missing_action_errors() {
let ctx = test_ctx_with_db();
let tool = CronTool;
let err = tool.execute(serde_json::json!({}), &ctx).await.unwrap_err();
assert!(err.message.contains("missing 'action'"));
}
}