d2687643da
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
346 lines
13 KiB
Rust
346 lines
13 KiB
Rust
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<usize>) -> 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(())
|
|
}
|