roboticus-agent 0.11.4

Agent core with ReAct loop, policy engine, injection defense, memory system, and skill loader
Documentation
//! Cron tool — manages scheduled jobs through the tool registry.
//!
//! Replaces the retired `CronShortcut` with a proper tool that routes through
//! the ReAct loop and guard chain. Supports create, list, and delete operations.

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(),
                    })?;

                // Try by ID first, then search by name
                let job_id = if roboticus_db::cron::get_job(db, id_or_name)
                    .ok()
                    .flatten()
                    .is_some()
                {
                    id_or_name.to_string()
                } else {
                    // Search by name
                    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;

        // List should be empty
        let result = tool
            .execute(serde_json::json!({"action": "list"}), &ctx)
            .await
            .unwrap();
        assert!(result.output.contains("No cron jobs"));

        // Create a job
        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());

        // List should now show one job
        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;

        // Create
        tool.execute(
            serde_json::json!({
                "action": "create",
                "name": "to-delete",
                "schedule": "0 * * * *",
                "task": "placeholder"
            }),
            &ctx,
        )
        .await
        .unwrap();

        // Delete by name
        let result = tool
            .execute(
                serde_json::json!({"action": "delete", "name": "to-delete"}),
                &ctx,
            )
            .await
            .unwrap();
        assert!(result.output.contains("deleted"));

        // List should be empty again
        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'"));
    }
}