diff --git a/crates/goose-cli/src/commands/schedule.rs b/crates/goose-cli/src/commands/schedule.rs index 044980996c..a3e5fae1e0 100644 --- a/crates/goose-cli/src/commands/schedule.rs +++ b/crates/goose-cli/src/commands/schedule.rs @@ -32,6 +32,7 @@ pub async fn handle_schedule_add( source: recipe_source_arg.clone(), // Pass the original user-provided path cron, last_run: None, + currently_running: false, }; let scheduler_storage_path = diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index b32df60d31..02f8117415 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -93,6 +93,7 @@ async fn create_schedule( source: req.recipe_source, cron: req.cron, last_run: None, + currently_running: false, }; scheduler .add_scheduled_job(job.clone()) diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index f0763f8d4b..a07acbf8b3 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -107,6 +107,8 @@ pub struct ScheduledJob { pub source: String, pub cron: String, pub last_run: Option>, + #[serde(default)] + pub currently_running: bool, } async fn persist_jobs_from_arc( @@ -223,6 +225,7 @@ impl Scheduler { let mut jobs_map_guard = current_jobs_arc.lock().await; if let Some((_, current_job_in_map)) = jobs_map_guard.get_mut(&task_job_id) { current_job_in_map.last_run = Some(current_time); + current_job_in_map.currently_running = true; needs_persist = true; } } @@ -239,7 +242,30 @@ impl Scheduler { } } // Pass None for provider_override in normal execution - if let Err(e) = run_scheduled_job_internal(job_to_execute, None).await { + let result = run_scheduled_job_internal(job_to_execute, None).await; + + // Update the job status after execution + { + let mut jobs_map_guard = current_jobs_arc.lock().await; + if let Some((_, current_job_in_map)) = jobs_map_guard.get_mut(&task_job_id) { + current_job_in_map.currently_running = false; + needs_persist = true; + } + } + + if needs_persist { + if let Err(e) = + persist_jobs_from_arc(&local_storage_path, ¤t_jobs_arc).await + { + tracing::error!( + "Failed to persist running status update for job {}: {}", + &task_job_id, + e + ); + } + } + + if let Err(e) = result { tracing::error!( "Scheduled job '{}' execution failed: {}", &e.job_id, @@ -299,6 +325,7 @@ impl Scheduler { let mut jobs_map_guard = current_jobs_arc.lock().await; if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) { stored_job.last_run = Some(current_time); + stored_job.currently_running = true; needs_persist = true; } } @@ -315,7 +342,30 @@ impl Scheduler { } } // Pass None for provider_override in normal execution - if let Err(e) = run_scheduled_job_internal(job_to_execute, None).await { + let result = run_scheduled_job_internal(job_to_execute, None).await; + + // Update the job status after execution + { + let mut jobs_map_guard = current_jobs_arc.lock().await; + if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) { + stored_job.currently_running = false; + needs_persist = true; + } + } + + if needs_persist { + if let Err(e) = + persist_jobs_from_arc(&local_storage_path, ¤t_jobs_arc).await + { + tracing::error!( + "Failed to persist running status update for job {}: {}", + &task_job_id, + e + ); + } + } + + if let Err(e) = result { tracing::error!( "Scheduled job '{}' execution failed: {}", &e.job_id, @@ -424,33 +474,46 @@ impl Scheduler { pub async fn run_now(&self, sched_id: &str) -> Result { let job_to_run: ScheduledJob = { - let jobs_guard = self.jobs.lock().await; - match jobs_guard.get(sched_id) { - Some((_, job_def)) => job_def.clone(), + let mut jobs_guard = self.jobs.lock().await; + match jobs_guard.get_mut(sched_id) { + Some((_, job_def)) => { + // Set the currently_running flag before executing + job_def.currently_running = true; + let job_clone = job_def.clone(); + // Drop the guard before persisting to avoid borrow issues + drop(jobs_guard); + + // Persist the change immediately + self.persist_jobs().await?; + job_clone + } None => return Err(SchedulerError::JobNotFound(sched_id.to_string())), } }; - // Pass None for provider_override in normal execution - let session_id = run_scheduled_job_internal(job_to_run.clone(), None) - .await - .map_err(|e| { - SchedulerError::AnyhowError(anyhow!( - "Failed to execute job '{}' immediately: {}", - sched_id, - e.error - )) - })?; + // Pass None for provider_override in normal execution + let run_result = run_scheduled_job_internal(job_to_run.clone(), None).await; + + // Clear the currently_running flag after execution { let mut jobs_guard = self.jobs.lock().await; if let Some((_tokio_job_id, job_in_map)) = jobs_guard.get_mut(sched_id) { + job_in_map.currently_running = false; job_in_map.last_run = Some(Utc::now()); } // MutexGuard is dropped here } + // Persist after the lock is released and update is made. self.persist_jobs().await?; - Ok(session_id) + match run_result { + Ok(session_id) => Ok(session_id), + Err(e) => Err(SchedulerError::AnyhowError(anyhow!( + "Failed to execute job '{}' immediately: {}", + sched_id, + e.error + ))), + } } } @@ -794,6 +857,7 @@ mod tests { source: recipe_filename.to_string_lossy().into_owned(), cron: "* * * * * * ".to_string(), // Runs every second for quick testing last_run: None, + currently_running: false, }; // Create the mock provider instance for the test diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index ee1cc170fe..c48e3251a8 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1692,6 +1692,9 @@ "cron": { "type": "string" }, + "currently_running": { + "type": "boolean" + }, "id": { "type": "string" }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 5ffb382de3..1dccafddc7 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -304,6 +304,7 @@ export type RunNowResponse = { export type ScheduledJob = { cron: string; + currently_running?: boolean; id: string; last_run?: string | null; source: string; diff --git a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx index 4ae3a86dc3..645e458bdf 100644 --- a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx +++ b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx @@ -5,9 +5,11 @@ import BackButton from '../ui/BackButton'; import { Card } from '../ui/card'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { fetchSessionDetails, SessionDetails } from '../../sessions'; -import { getScheduleSessions, runScheduleNow } from '../../schedule'; +import { getScheduleSessions, runScheduleNow, listSchedules, ScheduledJob } from '../../schedule'; import SessionHistoryView from '../sessions/SessionHistoryView'; import { toastError, toastSuccess } from '../../toasts'; +import { Loader2 } from 'lucide-react'; +import cronstrue from 'cronstrue'; interface ScheduleSessionMeta { id: string; @@ -34,6 +36,9 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN const [isLoadingSessions, setIsLoadingSessions] = useState(false); const [sessionsError, setSessionsError] = useState(null); const [runNowLoading, setRunNowLoading] = useState(false); + const [scheduleDetails, setScheduleDetails] = useState(null); + const [isLoadingSchedule, setIsLoadingSchedule] = useState(false); + const [scheduleError, setScheduleError] = useState(null); const [selectedSessionDetails, setSelectedSessionDetails] = useState(null); const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false); @@ -56,16 +61,48 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN } }, []); + const fetchScheduleDetails = useCallback(async (sId: string) => { + if (!sId) return; + setIsLoadingSchedule(true); + setScheduleError(null); + try { + const allSchedules = await listSchedules(); + const schedule = allSchedules.find((s) => s.id === sId); + if (schedule) { + setScheduleDetails(schedule); + } else { + setScheduleError('Schedule not found'); + } + } catch (err) { + console.error('Failed to fetch schedule details:', err); + setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule details'); + } finally { + setIsLoadingSchedule(false); + } + }, []); + + const getReadableCron = (cronString: string) => { + try { + return cronstrue.toString(cronString); + } catch (e) { + console.warn(`Could not parse cron string "${cronString}":`, e); + return cronString; + } + }; + useEffect(() => { if (scheduleId && !selectedSessionDetails) { fetchScheduleSessions(scheduleId); + fetchScheduleDetails(scheduleId); } else if (!scheduleId) { setSessions([]); setSessionsError(null); setRunNowLoading(false); setSelectedSessionDetails(null); + setScheduleDetails(null); + setScheduleError(null); } - }, [scheduleId, fetchScheduleSessions, selectedSessionDetails]); + }, [scheduleId, fetchScheduleSessions, fetchScheduleDetails, selectedSessionDetails]); const handleRunNow = async () => { if (!scheduleId) return; @@ -77,7 +114,10 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN msg: `Successfully triggered schedule. New session ID: ${newSessionId}`, }); setTimeout(() => { - if (scheduleId) fetchScheduleSessions(scheduleId); + if (scheduleId) { + fetchScheduleSessions(scheduleId); + fetchScheduleDetails(scheduleId); + } }, 1000); } catch (err) { console.error('Failed to run schedule now:', err); @@ -88,6 +128,26 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN } }; + // Add a periodic refresh for schedule details to keep the running status up to date + useEffect(() => { + if (!scheduleId) return; + + // Initial fetch + fetchScheduleDetails(scheduleId); + + // Set up periodic refresh every 5 seconds + const intervalId = setInterval(() => { + if (scheduleId) { + fetchScheduleDetails(scheduleId); + } + }, 5000); + + // Clean up on unmount or when scheduleId changes + return () => { + clearInterval(intervalId); + }; + }, [scheduleId, fetchScheduleDetails]); + const loadAndShowSessionDetails = async (sessionId: string) => { setIsLoadingSessionDetails(true); setSessionDetailsError(null); @@ -174,11 +234,69 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN
+
+

+ Schedule Information +

+ {isLoadingSchedule && ( +
+ Loading schedule details... +
+ )} + {scheduleError && ( +

+ Error: {scheduleError} +

+ )} + {!isLoadingSchedule && !scheduleError && scheduleDetails && ( + +
+
+

+ {scheduleDetails.id} +

+ {scheduleDetails.currently_running && ( +
+ + Currently Running +
+ )} +
+

+ Schedule:{' '} + {getReadableCron(scheduleDetails.cron)} +

+

+ Cron Expression: {scheduleDetails.cron} +

+

+ Recipe Source: {scheduleDetails.source} +

+

+ Last Run:{' '} + {scheduleDetails.last_run + ? new Date(scheduleDetails.last_run).toLocaleString() + : 'Never'} +

+
+
+ )} +
+

Actions

- + {scheduleDetails?.currently_running && ( +

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

+ )}
diff --git a/ui/desktop/src/components/schedule/SchedulesView.tsx b/ui/desktop/src/components/schedule/SchedulesView.tsx index ca7f9cf7c1..58befbcb79 100644 --- a/ui/desktop/src/components/schedule/SchedulesView.tsx +++ b/ui/desktop/src/components/schedule/SchedulesView.tsx @@ -6,7 +6,7 @@ import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { TrashIcon } from '../icons/TrashIcon'; -import { Plus } from 'lucide-react'; +import { Plus, RefreshCw } from 'lucide-react'; import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal'; import ScheduleDetailView from './ScheduleDetailView'; import cronstrue from 'cronstrue'; @@ -22,6 +22,7 @@ const SchedulesView: React.FC = ({ onClose }) => { const [apiError, setApiError] = useState(null); const [submitApiError, setSubmitApiError] = useState(null); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); const [viewingScheduleId, setViewingScheduleId] = useState(null); @@ -49,11 +50,37 @@ const SchedulesView: React.FC = ({ onClose }) => { } }, [viewingScheduleId]); + // Add a periodic refresh for schedules list to keep the running status up to date + useEffect(() => { + if (viewingScheduleId !== null) return; + + // Set up periodic refresh every 10 seconds + const intervalId = setInterval(() => { + if (viewingScheduleId === null && !isRefreshing && !isLoading) { + fetchSchedules(); + } + }, 10000); + + // Clean up on unmount + return () => { + clearInterval(intervalId); + }; + }, [viewingScheduleId, isRefreshing, isLoading]); + const handleOpenCreateModal = () => { setSubmitApiError(null); setIsCreateModalOpen(true); }; + const handleRefresh = async () => { + setIsRefreshing(true); + try { + await fetchSchedules(); + } finally { + setIsRefreshing(false); + } + }; + const handleCloseCreateModal = () => { setIsCreateModalOpen(false); setSubmitApiError(null); @@ -134,12 +161,24 @@ const SchedulesView: React.FC = ({ onClose }) => {
- +
+ + + +
{apiError && (

@@ -192,6 +231,12 @@ const SchedulesView: React.FC = ({ onClose }) => { Last Run:{' '} {job.last_run ? new Date(job.last_run).toLocaleString() : 'Never'}

+ {job.currently_running && ( +

+ + Currently Running +

+ )}