car-scheduler 0.16.1

Task scheduling and background execution for Common Agent Runtime
Documentation
//! Task definitions — what to run, when, and how.
//!
//! A Task is a persistent definition: a prompt + trigger + config.
//! Each execution creates a record with timing, status, and result.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// How a task is triggered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskTrigger {
    /// Run once immediately.
    Once,
    /// Run on a cron schedule.
    Cron,
    /// Run every N seconds.
    Interval,
    /// Run when a file changes.
    FileWatch,
    /// Only via explicit run command.
    Manual,
}

/// Current status of a task.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
    Pending,
    Running,
    Completed,
    Failed,
    Scheduled,
    Disabled,
}

/// Record of a single task execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskExecution {
    pub execution_id: String,
    pub started_at: DateTime<Utc>,
    pub finished_at: Option<DateTime<Utc>>,
    pub status: TaskStatus,
    pub answer: String,
    pub error: Option<String>,
    pub duration_ms: Option<f64>,
}

/// A persistent task definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
    pub id: String,
    pub name: String,
    pub prompt: String,
    pub trigger: TaskTrigger,
    /// Cron expression or interval string ("30", "5m", "1h").
    pub schedule: String,
    /// File path for FileWatch trigger.
    pub watch_path: String,
    /// Agent spec metadata (provider, model, etc. — opaque to scheduler).
    pub agent_metadata: HashMap<String, Value>,
    pub max_turns: u32,
    pub system_prompt: String,

    // State
    pub status: TaskStatus,
    pub created_at: DateTime<Utc>,
    pub last_run_at: Option<DateTime<Utc>>,
    pub run_count: u32,
    pub executions: Vec<TaskExecution>,
    pub enabled: bool,
}

impl Task {
    pub fn new(name: &str, prompt: &str) -> Self {
        Self {
            id: uuid::Uuid::new_v4().to_string()[..10].to_string(),
            name: name.to_string(),
            prompt: prompt.to_string(),
            trigger: TaskTrigger::Manual,
            schedule: String::new(),
            watch_path: String::new(),
            agent_metadata: HashMap::new(),
            max_turns: 10,
            system_prompt: String::new(),
            status: TaskStatus::Pending,
            created_at: Utc::now(),
            last_run_at: None,
            run_count: 0,
            executions: Vec::new(),
            enabled: true,
        }
    }

    pub fn with_trigger(mut self, trigger: TaskTrigger, schedule: &str) -> Self {
        self.trigger = trigger;
        self.schedule = schedule.to_string();
        self
    }

    pub fn with_file_watch(mut self, path: &str) -> Self {
        self.trigger = TaskTrigger::FileWatch;
        self.watch_path = path.to_string();
        self
    }

    pub fn with_system_prompt(mut self, prompt: &str) -> Self {
        self.system_prompt = prompt.to_string();
        self
    }

    pub fn with_metadata(mut self, key: &str, value: Value) -> Self {
        self.agent_metadata.insert(key.to_string(), value);
        self
    }
}

/// Persistent task store backed by JSON files.
pub struct TaskStore {
    dir: PathBuf,
}

impl TaskStore {
    pub fn new(dir: &Path) -> Self {
        Self {
            dir: dir.to_path_buf(),
        }
    }

    /// Default store at ~/.car/tasks/
    pub fn default_path() -> PathBuf {
        dirs_home().join(".car").join("tasks")
    }

    pub fn save(&self, task: &Task) -> Result<PathBuf, std::io::Error> {
        std::fs::create_dir_all(&self.dir)?;
        let path = self.dir.join(format!("{}.json", task.id));
        let json = serde_json::to_string_pretty(task)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
        std::fs::write(&path, json)?;
        Ok(path)
    }

    pub fn load(&self, task_id: &str) -> Option<Task> {
        let path = self.dir.join(format!("{}.json", task_id));
        let data = std::fs::read_to_string(path).ok()?;
        serde_json::from_str(&data).ok()
    }

    pub fn list(&self) -> Vec<Task> {
        let mut tasks = Vec::new();
        if let Ok(entries) = std::fs::read_dir(&self.dir) {
            for entry in entries.flatten() {
                if entry.path().extension().is_some_and(|e| e == "json") {
                    if let Ok(data) = std::fs::read_to_string(entry.path()) {
                        if let Ok(task) = serde_json::from_str::<Task>(&data) {
                            tasks.push(task);
                        }
                    }
                }
            }
        }
        tasks.sort_by(|a, b| a.created_at.cmp(&b.created_at));
        tasks
    }

    pub fn delete(&self, task_id: &str) -> bool {
        let path = self.dir.join(format!("{}.json", task_id));
        std::fs::remove_file(path).is_ok()
    }
}

fn dirs_home() -> PathBuf {
    std::env::var("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("/tmp"))
}

/// Parse interval string: "30" (seconds), "5m" (minutes), "1h" (hours).
pub fn parse_interval(schedule: &str) -> f64 {
    let s = schedule.trim().to_lowercase();
    if let Some(m) = s.strip_suffix('m') {
        m.parse::<f64>().unwrap_or(60.0) * 60.0
    } else if let Some(h) = s.strip_suffix('h') {
        h.parse::<f64>().unwrap_or(1.0) * 3600.0
    } else if let Some(sec) = s.strip_suffix('s') {
        sec.parse::<f64>().unwrap_or(60.0)
    } else {
        s.parse::<f64>().unwrap_or(60.0)
    }
}

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

    #[test]
    fn test_parse_interval() {
        assert_eq!(parse_interval("30"), 30.0);
        assert_eq!(parse_interval("5m"), 300.0);
        assert_eq!(parse_interval("1h"), 3600.0);
        assert_eq!(parse_interval("10s"), 10.0);
    }

    #[test]
    fn test_task_store_crud() {
        let dir = TempDir::new().unwrap();
        let store = TaskStore::new(dir.path());

        let task = Task::new("test_task", "do something");
        store.save(&task).unwrap();

        let loaded = store.load(&task.id).unwrap();
        assert_eq!(loaded.name, "test_task");
        assert_eq!(loaded.prompt, "do something");

        let all = store.list();
        assert_eq!(all.len(), 1);

        assert!(store.delete(&task.id));
        assert!(store.load(&task.id).is_none());
    }

    #[test]
    fn test_task_builder() {
        let task = Task::new("deploy", "deploy to staging")
            .with_trigger(TaskTrigger::Interval, "5m")
            .with_system_prompt("You are a deployment agent")
            .with_metadata("provider", Value::String("openai".to_string()));

        assert_eq!(task.trigger, TaskTrigger::Interval);
        assert_eq!(task.schedule, "5m");
        assert!(!task.system_prompt.is_empty());
        assert!(task.agent_metadata.contains_key("provider"));
    }
}