use anyhow::{bail, Context, Result}; use goose::scheduler::{ get_default_scheduled_recipes_dir, get_default_scheduler_storage_path, ScheduledJob, Scheduler, SchedulerError, }; use goose::session::SessionManager; use std::path::Path; use std::sync::Arc; fn validate_cron_expression(cron: &str) -> Result<()> { // Basic validation and helpful suggestions if cron.trim().is_empty() { bail!("Cron expression cannot be empty"); } // Check for common mistakes and provide helpful suggestions let parts: Vec<&str> = cron.split_whitespace().collect(); match parts.len() { 5 => { // Standard 5-field cron (minute hour day month weekday) println!("✅ Using standard 5-field cron format: {}", cron); } 6 => { // 6-field cron with seconds (second minute hour day month weekday) println!("✅ Using 6-field cron format with seconds: {}", cron); } 1 if cron.starts_with('@') => { // Shorthand expressions like @hourly, @daily, etc. let valid_shorthands = [ "@yearly", "@annually", "@monthly", "@weekly", "@daily", "@midnight", "@hourly", ]; if valid_shorthands.contains(&cron) { println!("✅ Using cron shorthand: {}", cron); } else { println!( "⚠️ Unknown cron shorthand '{}'. Valid options: {}", cron, valid_shorthands.join(", ") ); } } _ => { println!("⚠️ Unusual cron format detected: '{}'", cron); println!(" Common formats:"); println!(" - 5 fields: '0 * * * *' (minute hour day month weekday)"); println!(" - 6 fields: '0 0 * * * *' (second minute hour day month weekday)"); println!(" - Shorthand: '@hourly', '@daily', '@weekly', '@monthly'"); } } // Provide examples for common scheduling needs if cron == "* * * * *" { println!("⚠️ This will run every minute! Did you mean:"); println!(" - '0 * * * *' for every hour?"); println!(" - '0 0 * * *' for every day?"); } Ok(()) } pub async fn handle_schedule_add( schedule_id: String, cron: String, recipe_source_arg: String, // This is expected to be a file path by the Scheduler params: Vec<(String, String)>, ) -> Result<()> { println!( "[CLI Debug] Scheduling job ID: {}, Cron: {}, Recipe Source Path: {}", schedule_id, cron, recipe_source_arg ); validate_cron_expression(&cron)?; // The Scheduler's add_scheduled_job will handle copying the recipe from recipe_source_arg // to its internal storage and validating the path. let job = ScheduledJob { id: schedule_id.clone(), source: recipe_source_arg.clone(), // Pass the original user-provided path cron, last_run: None, currently_running: false, paused: false, current_session_id: None, process_start_time: None, parameters: params, recipe_base_dir: None, }; let scheduler_storage_path = get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?; let session_manager = Arc::new(SessionManager::instance()); let scheduler = Scheduler::new(scheduler_storage_path, session_manager) .await .context("Failed to initialize scheduler")?; match scheduler.add_scheduled_job(job, true).await { Ok(_) => { // The scheduler has copied the recipe to its internal directory. // We can reconstruct the likely path for display if needed, or adjust success message. let scheduled_recipes_dir = get_default_scheduled_recipes_dir() .unwrap_or_else(|_| Path::new("./.goose_scheduled_recipes").to_path_buf()); // Fallback for display let extension = Path::new(&recipe_source_arg) .extension() .and_then(|ext| ext.to_str()) .unwrap_or("yaml"); let final_recipe_path = scheduled_recipes_dir.join(format!("{}.{}", schedule_id, extension)); println!( "Scheduled job '{}' added. Recipe expected at {:?}", schedule_id, final_recipe_path ); Ok(()) } Err(e) => { // No local file to clean up by the CLI in this revised flow. match e { SchedulerError::JobIdExists(job_id) => { bail!("Error: Job with ID '{}' already exists.", job_id); } SchedulerError::RecipeLoadError(msg) => { bail!( "Error with recipe source: {}. Path: {}", msg, recipe_source_arg ); } _ => Err(anyhow::Error::new(e)) .context(format!("Failed to add job '{}' to scheduler", schedule_id)), } } } } pub async fn handle_schedule_list() -> Result<()> { let scheduler_storage_path = get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?; let session_manager = Arc::new(SessionManager::instance()); let scheduler = Scheduler::new(scheduler_storage_path, session_manager) .await .context("Failed to initialize scheduler")?; let jobs = scheduler.list_scheduled_jobs().await; if jobs.is_empty() { println!("No scheduled jobs found."); } else { println!("Scheduled Jobs:"); for job in jobs { let status = if job.currently_running { "🟢 RUNNING" } else if job.paused { "⏸️ PAUSED" } else { "⏹️ IDLE" }; println!( "- ID: {}\n Status: {}\n Cron: {}\n Recipe Source (in store): {}\n Last Run: {}", job.id, status, job.cron, job.source, // This source is now the path within scheduled_recipes_dir job.last_run .map_or_else(|| "Never".to_string(), |dt| dt.to_rfc3339()) ); } } Ok(()) } pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> { let scheduler_storage_path = get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?; let session_manager = Arc::new(SessionManager::instance()); let scheduler = Scheduler::new(scheduler_storage_path, session_manager) .await .context("Failed to initialize scheduler")?; match scheduler.remove_scheduled_job(&schedule_id, false).await { Ok(_) => { println!( "Scheduled job '{}' removed. Associated recipe was kept.", schedule_id ); Ok(()) } Err(e) => match e { SchedulerError::JobNotFound(job_id) => { bail!("Error: Job with ID '{}' not found.", job_id); } _ => Err(anyhow::Error::new(e)).context(format!( "Failed to remove job '{}' from scheduler", schedule_id )), }, } } pub async fn handle_schedule_sessions(schedule_id: String, limit: Option) -> Result<()> { let scheduler_storage_path = get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?; let session_manager = Arc::new(SessionManager::instance()); let scheduler = Scheduler::new(scheduler_storage_path, session_manager) .await .context("Failed to initialize scheduler")?; match scheduler.sessions(&schedule_id, limit.unwrap_or(50)).await { Ok(sessions) => { if sessions.is_empty() { println!("No sessions found for schedule ID '{}'.", schedule_id); } else { println!("Sessions for schedule ID '{}':", schedule_id); for (session_name, metadata) in sessions { println!( " - Session ID: {}, Messages: {}, Working Dir: {}, Description: \"{}\", Schedule ID: {:?}", session_name, metadata.message_count, metadata.working_dir.display(), metadata.name, metadata.schedule_id.as_deref().unwrap_or("N/A") ); } } } Err(e) => { bail!( "Failed to get sessions for schedule '{}': {:?}", schedule_id, e ); } } Ok(()) } pub async fn handle_schedule_run_now(schedule_id: String) -> Result<()> { let scheduler_storage_path = get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?; let session_manager = Arc::new(SessionManager::instance()); let scheduler = Scheduler::new(scheduler_storage_path, session_manager) .await .context("Failed to initialize scheduler")?; match scheduler.run_now(&schedule_id).await { Ok(session_id) => { println!( "Successfully triggered schedule '{}'. New session ID: {}", schedule_id, session_id ); } Err(e) => match e { SchedulerError::JobNotFound(job_id) => { bail!("Error: Job with ID '{}' not found.", job_id); } _ => bail!("Failed to run schedule '{}' now: {:?}", schedule_id, e), }, } Ok(()) } pub async fn handle_schedule_services_status() -> Result<()> { println!("Service management has been removed as Temporal scheduler is no longer supported."); println!( "The built-in scheduler runs within the goose process and requires no external services." ); Ok(()) } pub async fn handle_schedule_services_stop() -> Result<()> { println!("Service management has been removed as Temporal scheduler is no longer supported."); println!( "The built-in scheduler runs within the goose process and requires no external services." ); Ok(()) } pub async fn handle_schedule_cron_help() -> Result<()> { println!("📅 Cron Expression Guide for goose Scheduler"); println!("===========================================\\n"); println!("🕐 HOURLY SCHEDULES (Most Common Request):"); println!(" 0 * * * * - Every hour at minute 0 (e.g., 1:00, 2:00, 3:00...)"); println!(" 30 * * * * - Every hour at minute 30 (e.g., 1:30, 2:30, 3:30...)"); println!(" 0 */2 * * * - Every 2 hours at minute 0 (e.g., 2:00, 4:00, 6:00...)"); println!(" 0 */3 * * * - Every 3 hours at minute 0 (e.g., 3:00, 6:00, 9:00...)"); println!(" @hourly - Every hour (same as \"0 * * * *\")\\n"); println!("📅 DAILY SCHEDULES:"); println!(" 0 9 * * * - Every day at 9:00 AM"); println!(" 30 14 * * * - Every day at 2:30 PM"); println!(" 0 0 * * * - Every day at midnight"); println!(" @daily - Every day at midnight\\n"); println!("📆 WEEKLY SCHEDULES:"); println!(" 0 9 * * 1 - Every Monday at 9:00 AM"); println!(" 0 17 * * 5 - Every Friday at 5:00 PM"); println!(" 0 0 * * 0 - Every Sunday at midnight"); println!(" @weekly - Every Sunday at midnight\\n"); println!("🗓️ MONTHLY SCHEDULES:"); println!(" 0 9 1 * * - First day of every month at 9:00 AM"); println!(" 0 0 15 * * - 15th of every month at midnight"); println!(" @monthly - First day of every month at midnight\\n"); println!("📝 CRON FORMAT:"); println!(" Standard 5-field: minute hour day month weekday"); println!(" ┌───────────── minute (0 - 59)"); println!(" │ ┌─────────── hour (0 - 23)"); println!(" │ │ ┌───────── day of month (1 - 31)"); println!(" │ │ │ ┌─────── month (1 - 12)"); println!(" │ │ │ │ ┌───── day of week (0 - 7, Sunday = 0 or 7)"); println!(" │ │ │ │ │"); println!(" * * * * *\\n"); println!("🔧 SPECIAL CHARACTERS:"); println!(" * - Any value (every minute, hour, day, etc.)"); println!(" */n - Every nth interval (*/5 = every 5 minutes)"); println!(" n-m - Range (1-5 = 1,2,3,4,5)"); println!(" n,m - List (1,3,5 = 1 or 3 or 5)\\n"); println!("⚡ SHORTHAND EXPRESSIONS:"); println!(" @yearly - Once a year (0 0 1 1 *)"); println!(" @monthly - Once a month (0 0 1 * *)"); println!(" @weekly - Once a week (0 0 * * 0)"); println!(" @daily - Once a day (0 0 * * *)"); println!(" @hourly - Once an hour (0 * * * *)\\n"); println!("💡 EXAMPLES:"); println!( " goose schedule add --schedule-id hourly-report --cron \"0 * * * *\" --recipe-source report.yaml" ); println!( " goose schedule add --schedule-id daily-backup --cron \"@daily\" --recipe-source backup.yaml" ); println!(" goose schedule add --schedule-id weekly-summary --cron \"0 9 * * 1\" --recipe-source summary.yaml"); Ok(()) }