clawgarden-agent 0.22.0

Agent runtime with persona/memory loader, judge, and pi RPC for ClawGarden
Documentation
//! Cron registration — create workflow cron jobs from parsed CronRequest.
//!
//! Called by the agent when a user message contains cron intent.
//! Uses std::process::Command to invoke `garden cron add` (via docker exec).

use std::path::Path;
use anyhow::{Context, Result};
use crate::cron_nlp::{CronRequest, StepSpec};
use crate::skill_forge::ensure_tools;

/// Workspace directory (GARDEN_WORKSPACE env var or /workspace).
fn workspace_dir() -> std::path::PathBuf {
    std::path::PathBuf::from(
        std::env::var("GARDEN_WORKSPACE").unwrap_or_else(|_| "/workspace".into()),
    )
}

/// Register a cron job from a parsed CronRequest.
/// 
/// 1. Ensures all tools exist (creates .ts files + updates registry.json)
/// 2. Converts StepSpec → WorkflowStep JSON
/// 3. Invokes `garden cron add --type workflow --workflow-steps '...'`
/// 4. Returns the job_id and confirmation message.
pub async fn register_cron_from_request(
    request: &CronRequest,
    garden_name: Option<&str>,
) -> Result<(String, String)> {
    let workspace = workspace_dir();

    // Step 1: Ensure all tools exist
    let tool_names = ensure_tools(&workspace, &request.steps).await?;

    // Step 2: Convert StepSpec → WorkflowStep JSON
    let steps_json = build_workflow_steps(&tool_names, &request.steps);
    
    // Step 3: Build garden cron add command
    let (garden_flag, garden_arg) = if let Some(g) = garden_name {
        ("-g", g)
    } else {
        ("", "")
    };

    let job_name = if request.name.is_empty() {
        &request.description
    } else {
        &request.name
    };
    let job_nameescaped = shell_escape(job_name);

    // Step 4: Execute garden cron add via docker exec
    let container_name = format!("garden-{}", garden_name.unwrap_or("default"));
    let steps_escaped = steps_json.replace("'", "'\"'\"'");
    let expr_escaped = shell_escape(&request.schedule);

    // Use docker exec to run garden cron add inside the container
    let output = std::process::Command::new("docker")
        .args([
            "exec", &container_name,
            "/app/clawgarden-cli",
            "cron", "add",
            "-e", &expr_escaped,
            "-t", "workflow",
            "-w", &steps_escaped,
            "-n", &job_nameescaped,
        ])
        .output()
        .context("Failed to run docker exec for cron add")?;

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    if !output.status.success() {
        anyhow::bail!(
            "garden cron add failed (exit {}): {}\n{}",
            output.status,
            stdout,
            stderr
        );
    }

    // Extract job_id from output (format: "Cron job created: {job_id}")
    let job_id = extract_job_id(&stdout).unwrap_or_else(|| "unknown".to_string());

    // Build confirmation message
    let tools_str = tool_names.iter()
        .enumerate()
        .map(|(i, n)| format!("  {}. {}", i + 1, n))
        .collect::<Vec<_>>()
        .join("\n");

    let confirmation = format!(
        "✅ **Cron job 등록됨**\n\n\
         **이름:** {}\n\
         **스케줄:** {} (crontab)\n\
         **설명:** {}\n\
         **생성된 도구:**\n{}\n\
         **Job ID:** `{}`\n\n\{}에 실행됩니다.",
        job_name,
        request.schedule,
        request.description,
        tools_str,
        job_id,
        request.schedule
    );

    Ok((job_id, confirmation))
}

/// Build WorkflowStep JSON array from tool names + step specs.
fn build_workflow_steps(tool_names: &[String], steps: &[StepSpec]) -> String {
    use serde_json::json;

    let steps: Vec<_> = tool_names.iter()
        .zip(steps.iter())
        .map(|(name, spec)| {
            json!({
                "tool_name": name,
                "args": spec.params,
                "timeout_ms": 30000
            })
        })
        .collect();

    serde_json::to_string(&steps).unwrap_or_else(|_| "[]".to_string())
}

/// Extract job_id from "Cron job created: {uuid}" output.
fn extract_job_id(output: &str) -> Option<String> {
    output.lines()
        .find_map(|line| {
            if line.contains("Cron job created:") {
                line.split(':').last().map(|s| s.trim().to_string())
            } else {
                None
            }
        })
}

/// Shell-escape a string for use in docker exec arguments.
fn shell_escape(s: &str) -> String {
    // Single quotes prevent interpretation; double any embedded single quotes
    format!("'{}'", s.replace('\'', "'\"'\"'"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_shell_escape() {
        assert_eq!(shell_escape("hello"), "'hello'");
        assert_eq!(shell_escape("it's a test"), "'it'\"'\"'s a test'");
    }

    #[test]
    fn test_extract_job_id() {
        let out = "Cron job created: abc-123-def\nExpression: 0 2 * * *";
        assert_eq!(extract_job_id(out), Some("abc-123-def".to_string()));
        assert_eq!(extract_job_id("no match here"), None);
    }

    #[test]
    fn test_build_workflow_steps() {
        let names = vec!["hackernews-scrape".to_string(), "translate-ko".to_string()];
        let specs = vec![
            StepSpec { action: "hackernews".to_string(), params: "{}".to_string() },
            StepSpec { action: "translate".to_string(), params: "to korean".to_string() },
        ];
        let json = build_workflow_steps(&names, &specs);
        assert!(json.contains("hackernews-scrape"));
        assert!(json.contains("translate-ko"));
    }
}