diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index a3e5fae1..cfe27a47 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -33,6 +33,7 @@ pub async fn handle_schedule_add( cron, last_run: None, currently_running: false, + paused: false, }; let scheduler_storage_path = diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 0711e3a3..aa59e60a 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -42,6 +42,8 @@ use utoipa::OpenApi; super::routes::schedule::list_schedules, super::routes::schedule::delete_schedule, super::routes::schedule::run_now_handler, + super::routes::schedule::pause_schedule, + super::routes::schedule::unpause_schedule, super::routes::schedule::sessions_handler ), components(schemas( diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index 02f81174..69ac8c2e 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -94,6 +94,7 @@ async fn create_schedule( cron: req.cron, last_run: None, currently_running: false, + paused: false, }; scheduler .add_scheduled_job(job.clone()) @@ -260,12 +261,86 @@ async fn sessions_handler( } } +#[utoipa::path( + post, + path = "/schedule/{id}/pause", + params( + ("id" = String, Path, description = "ID of the schedule to pause") + ), + responses( + (status = 204, description = "Scheduled job paused successfully"), + (status = 404, description = "Scheduled job not found"), + (status = 400, description = "Cannot pause a currently running job"), + (status = 500, description = "Internal server error") + ), + tag = "schedule" +)] +#[axum::debug_handler] +async fn pause_schedule( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + verify_secret_key(&headers, &state)?; + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + scheduler.pause_schedule(&id).await.map_err(|e| { + eprintln!("Error pausing schedule '{}': {:?}", id, e); + match e { + goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND, + goose::scheduler::SchedulerError::AnyhowError(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/schedule/{id}/unpause", + params( + ("id" = String, Path, description = "ID of the schedule to unpause") + ), + responses( + (status = 204, description = "Scheduled job unpaused successfully"), + (status = 404, description = "Scheduled job not found"), + (status = 500, description = "Internal server error") + ), + tag = "schedule" +)] +#[axum::debug_handler] +async fn unpause_schedule( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result { + verify_secret_key(&headers, &state)?; + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + scheduler.unpause_schedule(&id).await.map_err(|e| { + eprintln!("Error unpausing schedule '{}': {:?}", id, e); + match e { + goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + Ok(StatusCode::NO_CONTENT) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/schedule/create", post(create_schedule)) .route("/schedule/list", get(list_schedules)) .route("/schedule/delete/{id}", delete(delete_schedule)) // Corrected .route("/schedule/{id}/run_now", post(run_now_handler)) // Corrected + .route("/schedule/{id}/pause", post(pause_schedule)) + .route("/schedule/{id}/unpause", post(unpause_schedule)) .route("/schedule/{id}/sessions", get(sessions_handler)) // Corrected .with_state(state) } diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index a07acbf8..6d111226 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -109,6 +109,8 @@ pub struct ScheduledJob { pub last_run: Option>, #[serde(default)] pub currently_running: bool, + #[serde(default)] + pub paused: bool, } async fn persist_jobs_from_arc( @@ -219,6 +221,21 @@ impl Scheduler { let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal Box::pin(async move { + // Check if the job is paused before executing + let should_execute = { + let jobs_map_guard = current_jobs_arc.lock().await; + if let Some((_, current_job_in_map)) = jobs_map_guard.get(&task_job_id) { + !current_job_in_map.paused + } else { + false + } + }; + + if !should_execute { + tracing::info!("Skipping execution of paused job '{}'", &task_job_id); + return; + } + let current_time = Utc::now(); let mut needs_persist = false; { @@ -319,6 +336,21 @@ impl Scheduler { let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal Box::pin(async move { + // Check if the job is paused before executing + let should_execute = { + let jobs_map_guard = current_jobs_arc.lock().await; + if let Some((_, stored_job)) = jobs_map_guard.get(&task_job_id) { + !stored_job.paused + } else { + false + } + }; + + if !should_execute { + tracing::info!("Skipping execution of paused job '{}'", &task_job_id); + return; + } + let current_time = Utc::now(); let mut needs_persist = false; { @@ -515,6 +547,36 @@ impl Scheduler { ))), } } + + pub async fn pause_schedule(&self, sched_id: &str) -> Result<(), SchedulerError> { + let mut jobs_guard = self.jobs.lock().await; + match jobs_guard.get_mut(sched_id) { + Some((_, job_def)) => { + if job_def.currently_running { + return Err(SchedulerError::AnyhowError(anyhow!( + "Cannot pause schedule '{}' while it's currently running", + sched_id + ))); + } + job_def.paused = true; + self.persist_jobs_to_storage_with_guard(&jobs_guard).await?; + Ok(()) + } + None => Err(SchedulerError::JobNotFound(sched_id.to_string())), + } + } + + pub async fn unpause_schedule(&self, sched_id: &str) -> Result<(), SchedulerError> { + let mut jobs_guard = self.jobs.lock().await; + match jobs_guard.get_mut(sched_id) { + Some((_, job_def)) => { + job_def.paused = false; + self.persist_jobs_to_storage_with_guard(&jobs_guard).await?; + Ok(()) + } + None => Err(SchedulerError::JobNotFound(sched_id.to_string())), + } + } } #[derive(Debug)] @@ -858,6 +920,7 @@ mod tests { cron: "* * * * * * ".to_string(), // Runs every second for quick testing last_run: None, currently_running: false, + paused: false, }; // Create the mock provider instance for the test diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index c48e3251..79005c90 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -539,6 +539,39 @@ } } }, + "/schedule/{id}/pause": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "pause_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to pause", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Scheduled job paused successfully" + }, + "400": { + "description": "Cannot pause a currently running job" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/schedule/{id}/run_now": { "post": { "tags": [ @@ -623,6 +656,36 @@ } } }, + "/schedule/{id}/unpause": { + "post": { + "tags": [ + "schedule" + ], + "operationId": "unpause_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to unpause", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Scheduled job unpaused successfully" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/sessions": { "get": { "tags": [ @@ -1703,6 +1766,9 @@ "format": "date-time", "nullable": true }, + "paused": { + "type": "boolean" + }, "source": { "type": "string" } diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 4fde2c78..75b7e9e6 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; -import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; +import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -169,6 +169,13 @@ export const listSchedules = (options?: Op }); }; +export const pauseSchedule = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/schedule/{id}/pause', + ...options + }); +}; + export const runNowHandler = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/schedule/{id}/run_now', @@ -183,6 +190,13 @@ export const sessionsHandler = (options: O }); }; +export const unpauseSchedule = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/schedule/{id}/unpause', + ...options + }); +}; + export const listSessions = (options?: Options) => { return (options?.client ?? _heyApiClient).get({ url: '/sessions', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 1dccafdd..0916830f 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -307,6 +307,7 @@ export type ScheduledJob = { currently_running?: boolean; id: string; last_run?: string | null; + paused?: boolean; source: string; }; @@ -963,6 +964,42 @@ export type ListSchedulesResponses = { export type ListSchedulesResponse2 = ListSchedulesResponses[keyof ListSchedulesResponses]; +export type PauseScheduleData = { + body?: never; + path: { + /** + * ID of the schedule to pause + */ + id: string; + }; + query?: never; + url: '/schedule/{id}/pause'; +}; + +export type PauseScheduleErrors = { + /** + * Cannot pause a currently running job + */ + 400: unknown; + /** + * Scheduled job not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type PauseScheduleResponses = { + /** + * Scheduled job paused successfully + */ + 204: void; +}; + +export type PauseScheduleResponse = PauseScheduleResponses[keyof PauseScheduleResponses]; + export type RunNowHandlerData = { body?: never; path: { @@ -1025,6 +1062,38 @@ export type SessionsHandlerResponses = { export type SessionsHandlerResponse = SessionsHandlerResponses[keyof SessionsHandlerResponses]; +export type UnpauseScheduleData = { + body?: never; + path: { + /** + * ID of the schedule to unpause + */ + id: string; + }; + query?: never; + url: '/schedule/{id}/unpause'; +}; + +export type UnpauseScheduleErrors = { + /** + * Scheduled job not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type UnpauseScheduleResponses = { + /** + * Scheduled job unpaused successfully + */ + 204: void; +}; + +export type UnpauseScheduleResponse = UnpauseScheduleResponses[keyof UnpauseScheduleResponses]; + export type ListSessionsData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx index 645e458b..764a3a09 100644 --- a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx +++ b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx @@ -5,10 +5,10 @@ import BackButton from '../ui/BackButton'; import { Card } from '../ui/card'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { fetchSessionDetails, SessionDetails } from '../../sessions'; -import { getScheduleSessions, runScheduleNow, listSchedules, ScheduledJob } from '../../schedule'; +import { getScheduleSessions, runScheduleNow, pauseSchedule, unpauseSchedule, listSchedules, ScheduledJob } from '../../schedule'; import SessionHistoryView from '../sessions/SessionHistoryView'; import { toastError, toastSuccess } from '../../toasts'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Pause, Play } from 'lucide-react'; import cronstrue from 'cronstrue'; interface ScheduleSessionMeta { @@ -128,6 +128,38 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN } }; + const handlePauseSchedule = async () => { + if (!scheduleId) return; + try { + await pauseSchedule(scheduleId); + toastSuccess({ + title: 'Schedule Paused', + msg: `Successfully paused schedule "${scheduleId}"`, + }); + fetchScheduleDetails(scheduleId); + } catch (err) { + console.error('Failed to pause schedule:', err); + const errorMsg = err instanceof Error ? err.message : 'Failed to pause schedule'; + toastError({ title: 'Pause Schedule Error', msg: errorMsg }); + } + }; + + const handleUnpauseSchedule = async () => { + if (!scheduleId) return; + try { + await unpauseSchedule(scheduleId); + toastSuccess({ + title: 'Schedule Unpaused', + msg: `Successfully unpaused schedule "${scheduleId}"`, + }); + fetchScheduleDetails(scheduleId); + } catch (err) { + console.error('Failed to unpause schedule:', err); + const errorMsg = err instanceof Error ? err.message : 'Failed to unpause schedule'; + toastError({ title: 'Unpause Schedule Error', msg: errorMsg }); + } + }; + // Add a periodic refresh for schedule details to keep the running status up to date useEffect(() => { if (!scheduleId) return; @@ -255,12 +287,20 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN

{scheduleDetails.id}

- {scheduleDetails.currently_running && ( -
- - Currently Running -
- )} +
+ {scheduleDetails.currently_running && ( +
+ + Currently Running +
+ )} + {scheduleDetails.paused && ( +
+ + Paused +
+ )} +

Schedule:{' '} @@ -285,16 +325,49 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN

Actions

- +
+ + + {scheduleDetails && !scheduleDetails.currently_running && ( + + )} +
+ {scheduleDetails?.currently_running && (

- Cannot trigger a schedule while it's already running. + Cannot trigger or modify a schedule while it's already running. +

+ )} + + {scheduleDetails?.paused && ( +

+ This schedule is paused and will not run automatically. Use "Run Schedule Now" to trigger it manually or unpause to resume automatic execution.

)}
diff --git a/ui/desktop/src/components/schedule/SchedulesView.tsx b/ui/desktop/src/components/schedule/SchedulesView.tsx index 58befbcb..10f3a7f6 100644 --- a/ui/desktop/src/components/schedule/SchedulesView.tsx +++ b/ui/desktop/src/components/schedule/SchedulesView.tsx @@ -1,14 +1,15 @@ import React, { useState, useEffect } from 'react'; -import { listSchedules, createSchedule, deleteSchedule, ScheduledJob } from '../../schedule'; +import { listSchedules, createSchedule, deleteSchedule, pauseSchedule, unpauseSchedule, ScheduledJob } from '../../schedule'; import BackButton from '../ui/BackButton'; import { ScrollArea } from '../ui/scroll-area'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { TrashIcon } from '../icons/TrashIcon'; -import { Plus, RefreshCw } from 'lucide-react'; +import { Plus, RefreshCw, Pause, Play } from 'lucide-react'; import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal'; import ScheduleDetailView from './ScheduleDetailView'; +import { toastError, toastSuccess } from '../../toasts'; import cronstrue from 'cronstrue'; interface SchedulesViewProps { @@ -123,6 +124,52 @@ const SchedulesView: React.FC = ({ onClose }) => { } }; + const handlePauseSchedule = async (idToPause: string) => { + setIsLoading(true); + setApiError(null); + try { + await pauseSchedule(idToPause); + toastSuccess({ + title: 'Schedule Paused', + msg: `Successfully paused schedule "${idToPause}"`, + }); + await fetchSchedules(); + } catch (error) { + console.error(`Failed to pause schedule "${idToPause}":`, error); + const errorMsg = error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`; + setApiError(errorMsg); + toastError({ + title: 'Pause Schedule Error', + msg: errorMsg, + }); + } finally { + setIsLoading(false); + } + }; + + const handleUnpauseSchedule = async (idToUnpause: string) => { + setIsLoading(true); + setApiError(null); + try { + await unpauseSchedule(idToUnpause); + toastSuccess({ + title: 'Schedule Unpaused', + msg: `Successfully unpaused schedule "${idToUnpause}"`, + }); + await fetchSchedules(); + } catch (error) { + console.error(`Failed to unpause schedule "${idToUnpause}":`, error); + const errorMsg = error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`; + setApiError(errorMsg); + toastError({ + title: 'Unpause Schedule Error', + msg: errorMsg, + }); + } finally { + setIsLoading(false); + } + }; + const handleNavigateToScheduleDetail = (scheduleId: string) => { setViewingScheduleId(scheduleId); }; @@ -237,8 +284,37 @@ const SchedulesView: React.FC = ({ onClose }) => { Currently Running

)} + {job.paused && ( +

+ + Paused +

+ )} -
+
+ {!job.currently_running && ( + + )}