use chrono::{DateTime, Utc}; use comfy_table::Cell; use futures::future; use galaxy_cli::schedule::{ CreateScheduleArgs, DeleteScheduleArgs, GetScheduleArgs, PauseScheduleArgs, ScheduleCommand, ScheduleSubcommand, UnpauseScheduleArgs, UpdateScheduleArgs, }; use galaxy_cli::{agent::OutputFormat, GlobalOptions}; use galaxy_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory; use galaxyui::platform::TerminationMode; use galaxyui::{AppContext, SingletonEntity}; use serde::Serialize; use crate::ai::ambient_agents::scheduled::{ CloudScheduledAmbientAgent, ScheduledAgentManager, ScheduledAmbientAgent, UpdateScheduleParams, }; use crate::ai::ambient_agents::AgentConfigSnapshot; use crate::cloud_object::CloudObject; use crate::server::ids::{ServerId, SyncId}; use crate::util::time_format::format_approx_duration_from_now_utc; use super::common::{EnvironmentChoice, ResolveConfigurationError}; use super::output::{self, TableFormat}; /// Run a scheduled agent command. pub fn run( ctx: &mut AppContext, global_options: GlobalOptions, command: ScheduleCommand, ) -> anyhow::Result<()> { let output_format = global_options.output_format; match command.into_subcommand() { ScheduleSubcommand::Create(args) => create(ctx, args), ScheduleSubcommand::List => list(ctx, output_format), ScheduleSubcommand::Get(args) => get(ctx, output_format, args), ScheduleSubcommand::Pause(args) => pause(ctx, args), ScheduleSubcommand::Unpause(args) => unpause(ctx, args), ScheduleSubcommand::Update(args) => update(ctx, args), ScheduleSubcommand::Delete(args) => delete(ctx, args), } } fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> { ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { let refresh_future = super::common::refresh_workspace_metadata(ctx); let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); let setup_future = future::try_join(refresh_future, warp_drive_sync_future); ctx.spawn(setup_future, move |manager, setup_result, ctx| { if let Err(err) = setup_result { super::report_fatal_error(err, ctx); return; } let loaded_file = match args.config_file.file.as_deref() { Some(path) => match super::config_file::load_config_file(path) { Ok(file) => Some(file), Err(err) => { super::report_fatal_error(err, ctx); return; } }, None => None, }; let mut environment_args = args.environment; if environment_args.environment.is_none() && !environment_args.no_environment { if let Some(environment_id) = loaded_file .as_ref() .and_then(|f| f.file.environment_id.clone()) { environment_args.environment = Some(environment_id); } } let environment_id = match EnvironmentChoice::resolve_for_create(environment_args, ctx) { Ok(EnvironmentChoice::None) => { eprintln!("Scheduling agent to run without an environment."); None } Ok(EnvironmentChoice::Environment { id, .. }) => Some(id), Err(ResolveConfigurationError::Canceled) => { ctx.terminate_app(TerminationMode::ForceTerminate, None); return; } Err(err) => { super::report_fatal_error(anyhow::anyhow!(err), ctx); return; } }; let owner = match super::common::resolve_owner(args.scope.team, args.scope.personal, ctx) { Ok(owner) => owner, Err(err) => { super::report_fatal_error(err, ctx); return; } }; let cli_mcp_servers = match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) { Ok(mcp_servers) => mcp_servers, Err(err) => { super::report_fatal_error(err, ctx); return; } }; let merged_config = super::config_file::merge_with_precedence( loaded_file.as_ref(), crate::ai::ambient_agents::AgentConfigSnapshot { name: None, environment_id, model_id: args.model.model.clone(), base_prompt: None, mcp_servers: cli_mcp_servers, profile_id: None, worker_host: args.worker_host, skill_spec: args.skill.map(|s| s.to_string()), // TODO(QUALITY-294): Support computer use flag in scheduled agents. computer_use_enabled: None, // TODO(REMOTE-1134): Support harness flag for scheduled agents. harness: None, harness_auth_secrets: None, }, ); // We must wait until after workspace metadata is refreshed to check available LLMs. let model_id = match merged_config .model_id .as_deref() .map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx)) .transpose() { Ok(id) => id.map(|id| id.to_string()), Err(err) => { super::report_fatal_error(anyhow::anyhow!(err), ctx); return; } }; let mut agent_config = merged_config; agent_config.model_id = model_id; let prompt = args.prompt.unwrap_or_default(); let mut config = ScheduledAmbientAgent::new(args.name, args.cron, true, prompt); config.agent_config = agent_config; // Print something here because scheduling an agent can take a while. println!("Scheduling agent {}...", config.name); let create_future = manager.create_schedule(config, owner, ctx); ctx.spawn(create_future, |_manager, result, ctx| match result { Ok(sync_id) => { println!("Scheduled agent: {sync_id}"); ctx.terminate_app(TerminationMode::ForceTerminate, None); } Err(err) => { super::report_fatal_error(err, ctx); } }); }); }); Ok(()) } #[derive(Serialize)] struct ScheduleInfo { id: String, name: String, cron_schedule: String, paused: bool, last_ran: Option>, next_run: Option>, scope: String, prompt: String, last_spawn_error: Option, agent_config: AgentConfigSnapshot, } impl ScheduleInfo { fn new( id: String, scope: String, config: ScheduledAmbientAgent, history: Option<&ScheduledAgentHistory>, ) -> Self { let last_ran = history.and_then(|h| h.last_ran.as_ref().map(|t| t.utc())); let next_run = history.and_then(|h| h.next_run.as_ref().map(|t| t.utc())); ScheduleInfo { id, name: config.name, cron_schedule: config.cron_schedule, paused: !config.enabled, last_ran, next_run, scope, prompt: config.prompt, last_spawn_error: config.last_spawn_error, agent_config: config.agent_config, } } fn last_ran_display(&self) -> String { let timestamp = self .last_ran .map(format_approx_duration_from_now_utc) .unwrap_or("-".to_string()); if self.last_spawn_error.is_some() { format!("❌ {}", timestamp) } else { timestamp } } fn next_run_display(&self) -> String { // We can't use format_approx_duration_from_now_utc because the date is in the future. // We use RTC 2822 rather than RFC 3339 because it's more human-readable. self.next_run .map(|dt| dt.to_rfc2822()) .unwrap_or("-".to_string()) } } impl TableFormat for ScheduleInfo { fn header() -> Vec { vec![ Cell::new("ID"), Cell::new("Name"), Cell::new("Schedule"), Cell::new("Paused"), Cell::new("Last ran"), Cell::new("Next run"), Cell::new("Scope"), ] } fn row(&self) -> Vec { let paused_display = if self.paused { "Yes" } else { "No" }; vec![ Cell::new(&self.id), Cell::new(&self.name), Cell::new(&self.cron_schedule), Cell::new(paused_display), Cell::new(self.last_ran_display()), Cell::new(self.next_run_display()), Cell::new(&self.scope), ] } } fn print_schedule_info(info: &ScheduleInfo, output_format: OutputFormat) -> anyhow::Result<()> { let paused_display = if info.paused { "Yes" } else { "No" }; match output_format { OutputFormat::Json => { serde_json::to_writer(std::io::stdout(), info)?; Ok(()) } OutputFormat::Ndjson => output::write_json_line(info, std::io::stdout()), OutputFormat::Text => { println!("Name: {}", info.name); println!("Cron schedule: {}", info.cron_schedule); println!("Paused: {paused_display}"); let last_ran = info.last_ran_display(); let next_run = info.next_run_display(); println!("Last ran: {last_ran}"); if let Some(error) = &info.last_spawn_error { println!("Last error: {error}"); } println!("Next run: {next_run}"); println!("Prompt: {}", info.prompt); if let Some(environment_id) = &info.agent_config.environment_id { println!("Environment ID: {environment_id}"); } if let Some(model_id) = &info.agent_config.model_id { println!("Model ID: {model_id}"); } if let Some(agent_name) = &info.agent_config.name { println!("Agent name: {agent_name}"); } if let Some(skill_spec) = &info.agent_config.skill_spec { println!("Skill: {skill_spec}"); } if let Some(worker_host) = &info.agent_config.worker_host { println!("Host: {worker_host}"); } Ok(()) } OutputFormat::Pretty => { let mut table = output::standard_table(); table.add_row(vec![Cell::new("Name"), Cell::new(&info.name)]); table.add_row(vec![ Cell::new("Cron schedule"), Cell::new(&info.cron_schedule), ]); table.add_row(vec![Cell::new("Paused"), Cell::new(paused_display)]); let last_ran = info.last_ran_display(); let next_run = info.next_run_display(); table.add_row(vec![Cell::new("Last ran"), Cell::new(last_ran)]); if let Some(error) = &info.last_spawn_error { table.add_row(vec![Cell::new("Last error"), Cell::new(error)]); } table.add_row(vec![Cell::new("Next run"), Cell::new(next_run)]); table.add_row(vec![Cell::new("Prompt"), Cell::new(&info.prompt)]); if let Some(environment_id) = &info.agent_config.environment_id { table.add_row(vec![Cell::new("Environment ID"), Cell::new(environment_id)]); } if let Some(model_id) = &info.agent_config.model_id { table.add_row(vec![Cell::new("Model ID"), Cell::new(model_id)]); } if let Some(agent_name) = &info.agent_config.name { table.add_row(vec![Cell::new("Agent name"), Cell::new(agent_name)]); } if let Some(skill_spec) = &info.agent_config.skill_spec { table.add_row(vec![Cell::new("Skill"), Cell::new(skill_spec)]); } if let Some(worker_host) = &info.agent_config.worker_host { table.add_row(vec![Cell::new("Host"), Cell::new(worker_host)]); } println!("{table}"); Ok(()) } } } fn pause(ctx: &mut AppContext, args: PauseScheduleArgs) -> anyhow::Result<()> { let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?); ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| { if let Err(err) = result { super::report_fatal_error(err, ctx); return; } println!("Pausing agent..."); let pause_future = manager.pause_schedule(schedule_id, ctx); ctx.spawn(pause_future, |_manager, result, ctx| match result { Ok(()) => { println!("Schedule paused"); ctx.terminate_app(TerminationMode::ForceTerminate, None); } Err(err) => { super::report_fatal_error(err, ctx); } }); }); }); Ok(()) } fn unpause(ctx: &mut AppContext, args: UnpauseScheduleArgs) -> anyhow::Result<()> { let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?); ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| { if let Err(err) = result { super::report_fatal_error(err, ctx); return; } println!("Resuming agent..."); let unpause_future = manager.unpause_schedule(schedule_id, ctx); ctx.spawn(unpause_future, |_manager, result, ctx| match result { Ok(()) => { println!("Schedule unpaused"); ctx.terminate_app(TerminationMode::ForceTerminate, None); } Err(err) => { super::report_fatal_error(err, ctx); } }); }); }); Ok(()) } fn update(ctx: &mut AppContext, args: UpdateScheduleArgs) -> anyhow::Result<()> { let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?); ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { let refresh_future = super::common::refresh_workspace_metadata(ctx); let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); let setup_future = future::try_join(refresh_future, warp_drive_sync_future); ctx.spawn(setup_future, move |manager, setup_result, ctx| { if let Err(err) = setup_result { super::report_fatal_error(err, ctx); return; } let loaded_file = match args.config_file.file.as_deref() { Some(path) => match super::config_file::load_config_file(path) { Ok(file) => Some(file), Err(err) => { super::report_fatal_error(err, ctx); return; } }, None => None, }; let file_config = loaded_file.as_ref().map(|f| &f.file); // We must wait until after workspace metadata is refreshed to check available LLMs. let model_id = match args .model .model .as_deref() .or_else(|| file_config.and_then(|f| f.model_id.as_deref())) .map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx)) .transpose() { Ok(id) => id.map(|id| id.to_string()), Err(err) => { super::report_fatal_error(anyhow::anyhow!(err), ctx); return; } }; let mut environment_args = args.environment; if environment_args.environment.is_none() && !environment_args.remove_environment { if let Some(environment_id) = file_config.and_then(|f| f.environment_id.clone()) { environment_args.environment = Some(environment_id); } } let environment_id = match EnvironmentChoice::resolve_for_update(environment_args, ctx) { Ok(choice) => choice.map(|c| match c { EnvironmentChoice::None => None, EnvironmentChoice::Environment { id, .. } => Some(id), }), Err(ResolveConfigurationError::Canceled) => { ctx.terminate_app(TerminationMode::ForceTerminate, None); return; } Err(err) => { super::report_fatal_error(anyhow::anyhow!(err), ctx); return; } }; // MCP update semantics are patch-only: // - file and CLI MCP servers are treated as upserts (CLI wins on key conflicts) // - `--remove-mcp` removes keys // If both are present, removals win by filtering removed names out of the upsert payload. let mut mcp_servers_upsert = match file_config.and_then(|f| f.mcp_servers.clone()) { Some(map) if map.is_empty() => None, Some(map) => Some(map), None => None, }; let cli_mcp_servers_upsert = match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) { Ok(mcp_servers) => mcp_servers, Err(err) => { super::report_fatal_error(err, ctx); return; } }; if let Some(cli_map) = cli_mcp_servers_upsert { let merged = mcp_servers_upsert.get_or_insert_with(serde_json::Map::new); for (name, config_value) in cli_map { merged.insert(name, config_value); } } if let Some(map) = mcp_servers_upsert.as_mut() { for name in &args.remove_mcp { map.remove(name); } if map.is_empty() { mcp_servers_upsert = None; } } // Handle skill update semantics: --skill sets it, --remove-skill clears it let skill_spec = if args.remove_skill { Some(None) } else { args.skill.map(|s| Some(s.to_string())) }; println!("Updating agent..."); let update_future = manager.update_schedule( schedule_id, UpdateScheduleParams { name: args.name, cron: args.cron, model_id, environment_id, base_prompt: file_config.and_then(|f| f.base_prompt.clone()), prompt: args.prompt, mcp_servers_upsert, remove_mcp_server_names: args.remove_mcp, skill_spec, worker_host: args.worker_host, }, ctx, ); ctx.spawn(update_future, |_manager, result, ctx| match result { Ok(()) => { println!("Schedule updated"); ctx.terminate_app(TerminationMode::ForceTerminate, None); } Err(err) => { super::report_fatal_error(err, ctx); } }); }); }); Ok(()) } /// List all scheduled agents available to the current user. fn list(ctx: &mut AppContext, output_format: OutputFormat) -> anyhow::Result<()> { ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| { if let Err(err) = result { super::report_fatal_error(err, ctx); return; } let mut schedules = manager.list_schedules(ctx); schedules.sort_by_key(|schedule| schedule.model().string_model.name.clone()); let futures = schedules.into_iter().map(|schedule| { let config = schedule.model().string_model.clone(); let sync_id = schedule.sync_id(); let scope = super::common::format_owner(&schedule.permissions().owner).to_string(); // TODO(ben): Consider a bulk lookup API for scheduled agent history. let history_future = manager.fetch_schedule_history(sync_id, ctx); async move { // Try to fetch the scheduled agent history, but still show output if this fails. let history = match history_future.await { Ok(v) => v, Err(err) => { log::warn!("Failed to fetch scheduled agent history: {err:#}"); None } }; let id = match sync_id { SyncId::ServerId(server_id) => server_id.to_string(), SyncId::ClientId(_) => "Unsynced".to_string(), }; ScheduleInfo::new(id, scope, config, history.as_ref()) } }); let output_format = output_format; ctx.spawn( futures::future::join_all(futures), move |_manager, infos, ctx| { output::print_list(infos, output_format); ctx.terminate_app(TerminationMode::ForceTerminate, None); }, ); }); }); Ok(()) } fn get( ctx: &mut AppContext, output_format: OutputFormat, args: GetScheduleArgs, ) -> anyhow::Result<()> { let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?); ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| { if let Err(err) = result { super::report_fatal_error(err, ctx); return; } let Some(schedule) = CloudScheduledAmbientAgent::get_by_id(&schedule_id, ctx) else { super::report_fatal_error(anyhow::anyhow!("Schedule not found"), ctx); return; }; let id = match &schedule_id { SyncId::ServerId(server_id) => server_id.to_string(), SyncId::ClientId(_) => "Unsynced".to_string(), }; let scope = super::common::format_owner(&schedule.permissions().owner).to_string(); let config = schedule.model().string_model.clone(); // Don't hold references into the CloudObject store across an async spawn. let history_future = manager.fetch_schedule_history(schedule_id, ctx); ctx.spawn(history_future, move |_manager, history, ctx| { let history = match history { Ok(v) => v, Err(err) => { log::warn!("Failed to fetch scheduled agent history: {err:#}"); None } }; let info = ScheduleInfo::new(id, scope, config, history.as_ref()); if let Err(err) = print_schedule_info(&info, output_format) { super::report_fatal_error(err, ctx); return; } ctx.terminate_app(TerminationMode::ForceTerminate, None); }); }); }); Ok(()) } fn delete(ctx: &mut AppContext, args: DeleteScheduleArgs) -> anyhow::Result<()> { let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?); ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| { if let Err(err) = result { super::report_fatal_error(err, ctx); return; } println!("Deleting agent..."); let delete_future = manager.delete_schedule(schedule_id, ctx); ctx.spawn(delete_future, |_manager, result, ctx| match result { Ok(()) => { println!("Schedule deleted"); ctx.terminate_app(TerminationMode::ForceTerminate, None); } Err(err) => { super::report_fatal_error(err, ctx); } }); }); }); Ok(()) }