Scheduler cleanup (#5571)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-11-08 13:19:18 -05:00
committed by GitHub
parent 25dfd768e5
commit e5a1474b4c
21 changed files with 1715 additions and 3902 deletions
+7 -19
View File
@@ -1,21 +1,10 @@
use anyhow::{bail, Context, Result};
use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine};
use goose::scheduler::{
get_default_scheduled_recipes_dir, get_default_scheduler_storage_path, ScheduledJob,
get_default_scheduled_recipes_dir, get_default_scheduler_storage_path, ScheduledJob, Scheduler,
SchedulerError,
};
use goose::scheduler_factory::SchedulerFactory;
use std::path::Path;
// Base64 decoding function - might be needed if recipe_source_arg can be base64
// For now, handle_schedule_add will assume it's a path.
async fn _decode_base64_recipe(source: &str) -> Result<String> {
let bytes = BASE64_STANDARD
.decode(source.as_bytes())
.with_context(|| "Recipe source is not a valid path and not valid Base64.")?;
String::from_utf8(bytes).with_context(|| "Decoded Base64 recipe source is not valid UTF-8.")
}
fn validate_cron_expression(cron: &str) -> Result<()> {
// Basic validation and helpful suggestions
if cron.trim().is_empty() {
@@ -84,7 +73,6 @@ pub async fn handle_schedule_add(
schedule_id, cron, recipe_source_arg
);
// Validate cron expression and provide helpful feedback
validate_cron_expression(&cron)?;
// The Scheduler's add_scheduled_job will handle copying the recipe from recipe_source_arg
@@ -102,7 +90,7 @@ pub async fn handle_schedule_add(
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = SchedulerFactory::create(scheduler_storage_path)
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
@@ -148,11 +136,11 @@ pub async fn handle_schedule_add(
pub async fn handle_schedule_list() -> Result<()> {
let scheduler_storage_path =
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
let scheduler = SchedulerFactory::create(scheduler_storage_path)
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
let jobs = scheduler.list_scheduled_jobs().await?;
let jobs = scheduler.list_scheduled_jobs().await;
if jobs.is_empty() {
println!("No scheduled jobs found.");
} else {
@@ -183,7 +171,7 @@ pub async fn handle_schedule_list() -> Result<()> {
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 scheduler = SchedulerFactory::create(scheduler_storage_path)
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
@@ -210,7 +198,7 @@ pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> {
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 scheduler = SchedulerFactory::create(scheduler_storage_path)
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
@@ -246,7 +234,7 @@ pub async fn handle_schedule_sessions(schedule_id: String, limit: Option<usize>)
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 scheduler = SchedulerFactory::create(scheduler_storage_path)
let scheduler = Scheduler::new(scheduler_storage_path)
.await
.context("Failed to initialize scheduler")?;
+35 -50
View File
@@ -16,8 +16,6 @@ pub struct CreateScheduleRequest {
id: String,
recipe_source: String,
cron: String,
#[serde(default)]
execution_mode: Option<String>, // "foreground" or "background"
}
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
@@ -36,7 +34,6 @@ pub struct KillJobResponse {
message: String,
}
// Response for the inspect endpoint
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct InspectJobResponse {
@@ -51,15 +48,9 @@ pub struct RunNowResponse {
session_id: String,
}
// Query parameters for the sessions endpoint
#[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
pub struct SessionsQuery {
#[serde(default = "default_limit")]
limit: u32,
}
fn default_limit() -> u32 {
50 // Default limit for sessions listed
limit: usize,
}
// Struct for the frontend session list
@@ -151,10 +142,7 @@ async fn list_schedules(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tracing::info!("Server: Calling scheduler.list_scheduled_jobs()");
let jobs = scheduler.list_scheduled_jobs().await.map_err(|e| {
eprintln!("Error listing schedules: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let jobs = scheduler.list_scheduled_jobs().await;
Ok(Json(ListSchedulesResponse { jobs }))
}
@@ -213,39 +201,40 @@ async fn run_now_handler(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let (recipe_display_name, recipe_version_opt) = match scheduler.list_scheduled_jobs().await {
Ok(jobs) => {
if let Some(job) = jobs.into_iter().find(|job| job.id == id) {
let recipe_display_name = std::path::Path::new(&job.source)
.file_name()
.and_then(|name| name.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| id.clone());
let (recipe_display_name, recipe_version_opt) = if let Some(job) = scheduler
.list_scheduled_jobs()
.await
.into_iter()
.find(|job| job.id == id)
{
let recipe_display_name = std::path::Path::new(&job.source)
.file_name()
.and_then(|name| name.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| id.clone());
let recipe_version_opt = tokio::fs::read_to_string(&job.source)
.await
let recipe_version_opt =
tokio::fs::read_to_string(&job.source)
.await
.ok()
.and_then(|content: String| {
goose::recipe::template_recipe::parse_recipe_content(
&content,
Some(
std::path::Path::new(&job.source)
.parent()
.unwrap_or_else(|| std::path::Path::new(""))
.to_string_lossy()
.to_string(),
),
)
.ok()
.and_then(|content| {
goose::recipe::template_recipe::parse_recipe_content(
&content,
Some(
std::path::Path::new(&job.source)
.parent()
.unwrap_or_else(|| std::path::Path::new(""))
.to_string_lossy()
.to_string(),
),
)
.ok()
.map(|(r, _)| r.version)
});
.map(|(r, _)| r.version)
});
(recipe_display_name, recipe_version_opt)
} else {
(id.clone(), None)
}
}
Err(_) => (id.clone(), None),
(recipe_display_name, recipe_version_opt)
} else {
(id.clone(), None)
};
let recipe_version_tag = recipe_version_opt.as_deref().unwrap_or("");
@@ -308,7 +297,7 @@ async fn sessions_handler(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match scheduler
.sessions(&schedule_id_param, query_params.limit as usize)
.sessions(&schedule_id_param, query_params.limit)
.await
{
Ok(session_tuples) => {
@@ -448,11 +437,7 @@ async fn update_schedule(
}
})?;
// Return the updated schedule
let jobs = scheduler.list_scheduled_jobs().await.map_err(|e| {
eprintln!("Error listing schedules after update: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let jobs = scheduler.list_scheduled_jobs().await;
let updated_job = jobs
.into_iter()
.find(|job| job.id == id)
+1 -2
View File
@@ -39,7 +39,6 @@ use crate::permission::PermissionConfirmation;
use crate::providers::base::Provider;
use crate::providers::errors::ProviderError;
use crate::recipe::{Author, Recipe, Response, Settings, SubRecipe};
use crate::scheduler_trait::SchedulerTrait;
use crate::security::security_inspector::SecurityInspector;
use crate::tool_inspection::ToolInspectionManager;
use crate::tool_monitor::RepetitionInspector;
@@ -60,6 +59,7 @@ use super::platform_tools;
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
use crate::agents::subagent_task_config::TaskConfig;
use crate::conversation::message::{Message, MessageContent, SystemNotificationType, ToolRequest};
use crate::scheduler_trait::SchedulerTrait;
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
use crate::session::{Session, SessionManager};
@@ -346,7 +346,6 @@ impl Agent {
Ok(tool_futures)
}
/// Set the scheduler service for this agent
pub async fn set_scheduler(&self, scheduler: Arc<dyn SchedulerTrait>) {
let mut scheduler_service = self.scheduler_service.lock().await;
*scheduler_service = Some(scheduler);
+11 -35
View File
@@ -9,11 +9,10 @@ use crate::mcp_utils::ToolResult;
use chrono::Utc;
use rmcp::model::{Content, ErrorCode, ErrorData};
use super::Agent;
use crate::recipe::Recipe;
use crate::scheduler_trait::SchedulerTrait;
use super::Agent;
impl Agent {
/// Handle schedule management tool calls
pub async fn handle_schedule_management(
@@ -62,34 +61,24 @@ impl Agent {
}
}
/// List all scheduled jobs
async fn handle_list_jobs(
&self,
scheduler: Arc<dyn SchedulerTrait>,
) -> ToolResult<Vec<Content>> {
match scheduler.list_scheduled_jobs().await {
Ok(jobs) => {
let jobs_json = serde_json::to_string_pretty(&jobs).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to serialize jobs: {}", e),
None,
)
})?;
Ok(vec![Content::text(format!(
"Scheduled Jobs:\n{}",
jobs_json
))])
}
Err(e) => Err(ErrorData::new(
let jobs = scheduler.list_scheduled_jobs().await;
let jobs_json = serde_json::to_string_pretty(&jobs).map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to list jobs: {}", e),
format!("Failed to serialize jobs: {}", e),
None,
)),
}
)
})?;
Ok(vec![Content::text(format!(
"Scheduled Jobs:\n{}",
jobs_json
))])
}
/// Create a new scheduled job from a recipe file
async fn handle_create_job(
&self,
scheduler: Arc<dyn SchedulerTrait>,
@@ -123,19 +112,6 @@ impl Agent {
.and_then(|v| v.as_str())
.unwrap_or("background");
// Validate execution_mode is either "foreground" or "background"
if execution_mode != "foreground" && execution_mode != "background" {
return Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!(
"Invalid execution_mode: {}. Must be 'foreground' or 'background'",
execution_mode
),
None,
));
}
// Validate recipe file exists and is readable
if !std::path::Path::new(recipe_path).exists() {
return Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
-56
View File
@@ -1,56 +0,0 @@
#[cfg(test)]
mod cron_parsing_tests {
use crate::scheduler::normalize_cron_expression;
use tokio_cron_scheduler::Job;
// Helper: drop the last field if we have 7 so tokio_cron_scheduler (6-field) can parse
fn to_tokio_spec(spec: &str) -> String {
let parts: Vec<&str> = spec.split_whitespace().collect();
if parts.len() == 7 {
parts[..6].join(" ")
} else {
spec.to_string()
}
}
#[test]
fn test_normalize_cron_expression() {
// 5-field → 7-field
assert_eq!(normalize_cron_expression("0 12 * * *"), "0 0 12 * * * *");
assert_eq!(normalize_cron_expression("*/5 * * * *"), "0 */5 * * * * *");
assert_eq!(normalize_cron_expression("0 0 * * 1"), "0 0 0 * * 1 *");
// 6-field → 7-field (append *)
assert_eq!(normalize_cron_expression("0 0 12 * * *"), "0 0 12 * * * *");
assert_eq!(
normalize_cron_expression("*/30 */5 * * * *"),
"*/30 */5 * * * * *"
);
// Weekday expressions (unchanged apart from 7-field format)
assert_eq!(normalize_cron_expression("0 * * * 1-5"), "0 0 * * * 1-5 *");
assert_eq!(
normalize_cron_expression("*/20 * * * 1-5"),
"0 */20 * * * 1-5 *"
);
}
#[tokio::test]
async fn test_cron_expression_formats() {
let samples = [
"0 0 * * *", // 5-field
"0 0 0 * * *", // 6-field
"*/5 * * * *", // 5-field
];
for expr in samples {
let norm = normalize_cron_expression(expr);
let tokio_spec = to_tokio_spec(&norm);
assert!(
Job::new_async(&tokio_spec, |_id, _l| Box::pin(async {})).is_ok(),
"failed to parse {} -> {}",
expr,
norm
);
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::Agent;
use crate::config::paths::Paths;
use crate::scheduler_factory::SchedulerFactory;
use crate::scheduler::Scheduler;
use crate::scheduler_trait::SchedulerTrait;
use anyhow::Result;
use lru::LruCache;
@@ -35,7 +35,7 @@ impl AgentManager {
async fn new(max_sessions: Option<usize>) -> Result<Self> {
let schedule_file_path = Paths::data_dir().join("schedule.json");
let scheduler = SchedulerFactory::create(schedule_file_path).await?;
let scheduler = Scheduler::new(schedule_file_path).await?;
let capacity = NonZeroUsize::new(max_sessions.unwrap_or(DEFAULT_MAX_SESSION))
.unwrap_or_else(|| NonZeroUsize::new(100).unwrap());
-4
View File
@@ -14,7 +14,6 @@ pub mod providers;
pub mod recipe;
pub mod recipe_deeplink;
pub mod scheduler;
pub mod scheduler_factory;
pub mod scheduler_trait;
pub mod security;
pub mod session;
@@ -25,6 +24,3 @@ pub mod tool_inspection;
pub mod tool_monitor;
pub mod tracing;
pub mod utils;
#[cfg(test)]
mod cron_test;
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
use std::path::PathBuf;
use std::sync::Arc;
use crate::scheduler::{Scheduler, SchedulerError};
use crate::scheduler_trait::SchedulerTrait;
/// Factory for creating scheduler instances
pub struct SchedulerFactory;
impl SchedulerFactory {
/// Create a scheduler instance
pub async fn create(storage_path: PathBuf) -> Result<Arc<dyn SchedulerTrait>, SchedulerError> {
tracing::info!("Creating scheduler");
let scheduler = Scheduler::new(storage_path).await?;
Ok(scheduler as Arc<dyn SchedulerTrait>)
}
/// Create a scheduler (for testing or explicit use)
pub async fn create_legacy(
storage_path: PathBuf,
) -> Result<Arc<dyn SchedulerTrait>, SchedulerError> {
tracing::info!("Creating scheduler (explicit)");
let scheduler = Scheduler::new(storage_path).await?;
Ok(scheduler as Arc<dyn SchedulerTrait>)
}
}
+1 -21
View File
@@ -4,42 +4,22 @@ use chrono::{DateTime, Utc};
use crate::scheduler::{ScheduledJob, SchedulerError};
use crate::session::Session;
/// Common trait for all scheduler implementations
#[async_trait]
pub trait SchedulerTrait: Send + Sync {
/// Add a new scheduled job
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError>;
/// List all scheduled jobs
async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, SchedulerError>;
/// Remove a scheduled job by ID
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob>;
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError>;
/// Pause a scheduled job
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError>;
/// Unpause a scheduled job
async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError>;
/// Run a job immediately
async fn run_now(&self, id: &str) -> Result<String, SchedulerError>;
/// Get sessions for a scheduled job
async fn sessions(
&self,
sched_id: &str,
limit: usize,
) -> Result<Vec<(String, Session)>, SchedulerError>;
/// Update a schedule's cron expression
async fn update_schedule(&self, sched_id: &str, new_cron: String)
-> Result<(), SchedulerError>;
/// Kill a running job
async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError>;
/// Get information about a running job
async fn get_running_job_info(
&self,
sched_id: &str,
+2 -2
View File
@@ -40,9 +40,9 @@ mod tests {
Ok(())
}
async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, SchedulerError> {
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob> {
let jobs = self.jobs.lock().await;
Ok(jobs.clone())
jobs.clone()
}
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
-401
View File
@@ -1,401 +0,0 @@
#![cfg(test)]
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use tempfile::TempDir;
use tokio::sync::Mutex;
use goose::agents::Agent;
use goose::scheduler::{ScheduledJob, SchedulerError};
use goose::scheduler_trait::SchedulerTrait;
use goose::session::Session;
#[derive(Debug, Clone)]
pub enum MockBehavior {
Success,
NotFound(String),
AlreadyExists(String),
InternalError(String),
JobCurrentlyRunning(String),
}
#[derive(Clone)]
pub struct ConfigurableMockScheduler {
jobs: Arc<Mutex<HashMap<String, ScheduledJob>>>,
running_jobs: Arc<Mutex<HashSet<String>>>,
call_log: Arc<Mutex<Vec<String>>>,
behaviors: Arc<Mutex<HashMap<String, MockBehavior>>>,
#[allow(clippy::type_complexity)]
sessions_data: Arc<Mutex<HashMap<String, Vec<(String, Session)>>>>,
}
#[allow(dead_code)]
impl Default for ConfigurableMockScheduler {
fn default() -> Self {
Self::new()
}
}
impl ConfigurableMockScheduler {
pub fn new() -> Self {
Self {
jobs: Arc::new(Mutex::new(HashMap::new())),
running_jobs: Arc::new(Mutex::new(HashSet::new())),
call_log: Arc::new(Mutex::new(Vec::new())),
behaviors: Arc::new(Mutex::new(HashMap::new())),
sessions_data: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn get_calls(&self) -> Vec<String> {
self.call_log.lock().await.clone()
}
async fn log_call(&self, method: &str) {
self.call_log.lock().await.push(method.to_string());
}
async fn get_behavior(&self, method: &str) -> MockBehavior {
self.behaviors
.lock()
.await
.get(method)
.cloned()
.unwrap_or(MockBehavior::Success)
}
}
#[async_trait]
impl SchedulerTrait for ConfigurableMockScheduler {
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> {
self.log_call("add_scheduled_job").await;
match self.get_behavior("add_scheduled_job").await {
MockBehavior::Success => {
let mut jobs = self.jobs.lock().await;
if jobs.contains_key(&job.id) {
return Err(SchedulerError::JobIdExists(job.id));
}
jobs.insert(job.id.clone(), job);
Ok(())
}
MockBehavior::AlreadyExists(id) => Err(SchedulerError::JobIdExists(id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, SchedulerError> {
self.log_call("list_scheduled_jobs").await;
match self.get_behavior("list_scheduled_jobs").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
Ok(jobs.values().cloned().collect())
}
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(vec![]),
}
}
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
self.log_call("remove_scheduled_job").await;
match self.get_behavior("remove_scheduled_job").await {
MockBehavior::Success => {
let mut jobs = self.jobs.lock().await;
if jobs.remove(id).is_some() {
Ok(())
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
self.log_call("pause_schedule").await;
match self.get_behavior("pause_schedule").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
if jobs.contains_key(id) {
Ok(())
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::JobCurrentlyRunning(job_id) => {
Err(SchedulerError::AnyhowError(anyhow::anyhow!(
"Cannot pause schedule '{}' while it's currently running",
job_id
)))
}
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
self.log_call("unpause_schedule").await;
match self.get_behavior("unpause_schedule").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
if jobs.contains_key(id) {
Ok(())
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn run_now(&self, id: &str) -> Result<String, SchedulerError> {
self.log_call("run_now").await;
match self.get_behavior("run_now").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
if jobs.contains_key(id) {
Ok(format!("{}_session_{}", id, chrono::Utc::now().timestamp()))
} else {
Err(SchedulerError::JobNotFound(id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok("mock_session_123".to_string()),
}
}
async fn sessions(
&self,
sched_id: &str,
limit: usize,
) -> Result<Vec<(String, Session)>, SchedulerError> {
self.log_call("sessions").await;
match self.get_behavior("sessions").await {
MockBehavior::Success => {
let sessions_data = self.sessions_data.lock().await;
let sessions = sessions_data.get(sched_id).cloned().unwrap_or_default();
Ok(sessions.into_iter().take(limit).collect())
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(vec![]),
}
}
async fn update_schedule(
&self,
sched_id: &str,
_new_cron: String,
) -> Result<(), SchedulerError> {
self.log_call("update_schedule").await;
match self.get_behavior("update_schedule").await {
MockBehavior::Success => {
let jobs = self.jobs.lock().await;
if jobs.contains_key(sched_id) {
Ok(())
} else {
Err(SchedulerError::JobNotFound(sched_id.to_string()))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> {
self.log_call("kill_running_job").await;
match self.get_behavior("kill_running_job").await {
MockBehavior::Success => {
let running_jobs = self.running_jobs.lock().await;
if running_jobs.contains(sched_id) {
Ok(())
} else {
Err(SchedulerError::AnyhowError(anyhow::anyhow!(
"Schedule '{}' is not currently running",
sched_id
)))
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(()),
}
}
async fn get_running_job_info(
&self,
sched_id: &str,
) -> Result<Option<(String, DateTime<Utc>)>, SchedulerError> {
self.log_call("get_running_job_info").await;
match self.get_behavior("get_running_job_info").await {
MockBehavior::Success => {
let running_jobs = self.running_jobs.lock().await;
if running_jobs.contains(sched_id) {
Ok(Some((format!("{}_session", sched_id), Utc::now())))
} else {
Ok(None)
}
}
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
_ => Ok(None),
}
}
}
// Helper for creating temp recipe files
pub struct TempRecipe {
pub path: PathBuf,
_temp_dir: TempDir, // Keep alive
}
pub fn create_temp_recipe(valid: bool, format: &str) -> TempRecipe {
let temp_dir = tempfile::tempdir().unwrap();
let filename = format!("test_recipe.{}", format);
let path = temp_dir.path().join(filename);
let content = if valid {
match format {
"json" => {
r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"prompt": "Hello world"
}"#
}
"yaml" | "yml" => {
r#"version: "1.0.0"
title: "Test Recipe"
description: "A test recipe"
prompt: "Hello world"
"#
}
_ => panic!("Unsupported format: {}", format),
}
} else {
match format {
"json" => r#"{"invalid": json syntax"#,
"yaml" | "yml" => "invalid:\n - yaml: syntax: error",
_ => "invalid content",
}
};
std::fs::write(&path, content).unwrap();
TempRecipe {
path,
_temp_dir: temp_dir,
}
}
// Test builder for easy setup
pub struct ScheduleToolTestBuilder {
scheduler: Arc<ConfigurableMockScheduler>,
}
impl Default for ScheduleToolTestBuilder {
fn default() -> Self {
Self::new()
}
}
impl ScheduleToolTestBuilder {
pub fn new() -> Self {
Self {
scheduler: Arc::new(ConfigurableMockScheduler::new()),
}
}
pub async fn with_scheduler_behavior(self, method: &str, behavior: MockBehavior) -> Self {
{
let mut behaviors = self.scheduler.behaviors.lock().await;
behaviors.insert(method.to_string(), behavior);
}
self
}
pub async fn with_existing_job(self, job_id: &str, cron: &str) -> Self {
let job = ScheduledJob {
id: job_id.to_string(),
source: "/tmp/test.json".to_string(),
cron: cron.to_string(),
last_run: None,
currently_running: false,
paused: false,
current_session_id: None,
process_start_time: None,
};
{
let mut jobs = self.scheduler.jobs.lock().await;
jobs.insert(job.id.clone(), job);
}
self
}
pub async fn with_running_job(self, job_id: &str) -> Self {
{
let mut running_jobs = self.scheduler.running_jobs.lock().await;
running_jobs.insert(job_id.to_string());
}
self
}
pub async fn with_sessions_data(self, job_id: &str, sessions: Vec<(String, Session)>) -> Self {
{
let mut sessions_data = self.scheduler.sessions_data.lock().await;
sessions_data.insert(job_id.to_string(), sessions);
}
self
}
pub async fn build(self) -> (Agent, Arc<ConfigurableMockScheduler>) {
let agent = Agent::new();
agent.set_scheduler(self.scheduler.clone()).await;
(agent, self.scheduler)
}
}
pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> Session {
Session {
id: "".to_string(),
working_dir: PathBuf::from(working_dir),
name: "Test session".to_string(),
user_set_name: false,
created_at: Default::default(),
schedule_id: Some("test_job".to_string()),
recipe: None,
total_tokens: Some(100),
input_tokens: Some(50),
output_tokens: Some(50),
accumulated_total_tokens: Some(100),
accumulated_input_tokens: Some(50),
accumulated_output_tokens: Some(50),
extension_data: Default::default(),
updated_at: Default::default(),
conversation: None,
message_count,
user_recipe_values: None,
session_type: Default::default(),
}
}
+4 -7
View File
@@ -1672,10 +1672,9 @@
{
"name": "limit",
"in": "query",
"required": false,
"required": true,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
@@ -2353,10 +2352,6 @@
"cron": {
"type": "string"
},
"execution_mode": {
"type": "string",
"nullable": true
},
"id": {
"type": "string"
},
@@ -4400,10 +4395,12 @@
},
"SessionsQuery": {
"type": "object",
"required": [
"limit"
],
"properties": {
"limit": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
+3 -4
View File
@@ -90,7 +90,6 @@ export type CreateRecipeResponse = {
export type CreateScheduleRequest = {
cron: string;
execution_mode?: string | null;
id: string;
recipe_source: string;
};
@@ -732,7 +731,7 @@ export type SessionListResponse = {
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden';
export type SessionsQuery = {
limit?: number;
limit: number;
};
export type SetProviderRequest = {
@@ -2209,8 +2208,8 @@ export type SessionsHandlerData = {
*/
id: string;
};
query?: {
limit?: number;
query: {
limit: number;
};
url: '/schedule/{id}/sessions';
};
@@ -1,894 +0,0 @@
import React, { useState, useEffect, FormEvent, useCallback } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Select } from '../ui/Select';
import cronstrue from 'cronstrue';
import * as yaml from 'yaml';
import { Recipe, decodeRecipe } from '../../recipe';
import { getStorageDirectory } from '../../recipe/recipe_management';
import ClockIcon from '../../assets/clock-icon.svg';
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
type CustomIntervalUnit = 'minute' | 'hour' | 'day';
interface FrequencyOption {
value: FrequencyValue;
label: string;
}
export interface NewSchedulePayload {
id: string;
recipe_source: string;
cron: string;
execution_mode?: string;
}
interface CreateScheduleModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (payload: NewSchedulePayload) => Promise<void>;
isLoadingExternally: boolean;
apiErrorExternally: string | null;
initialDeepLink?: string | null;
}
// Interface for clean extension in YAML
interface CleanExtension {
name: string;
type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http';
cmd?: string;
args?: string[];
uri?: string;
display_name?: string;
tools?: unknown[];
instructions?: string;
env_keys?: string[];
timeout?: number;
description?: string;
bundled?: boolean;
}
// TODO: This 'Recipe' interface should be converted to match the OpenAPI spec for Recipe
// once we have separated the recipe from the schedule in the frontend.
// Interface for clean recipe in YAML
interface CleanRecipe {
title: string;
description: string;
instructions?: string;
prompt?: string;
activities?: string[];
extensions?: CleanExtension[];
author?: {
contact?: string;
metadata?: string;
};
schedule?: {
foreground: boolean;
fallback_to_background: boolean;
window_title?: string;
working_directory?: string;
};
}
const frequencies: FrequencyOption[] = [
{ value: 'once', label: 'Once' },
{ value: 'every', label: 'Every...' },
{ value: 'daily', label: 'Daily (at specific time)' },
{ value: 'weekly', label: 'Weekly (at specific time/days)' },
{ value: 'monthly', label: 'Monthly (at specific time/day)' },
];
const customIntervalUnits: { value: CustomIntervalUnit; label: string }[] = [
{ value: 'minute', label: 'minute(s)' },
{ value: 'hour', label: 'hour(s)' },
{ value: 'day', label: 'day(s)' },
];
const daysOfWeekOptions: { value: string; label: string }[] = [
{ value: '1', label: 'Mon' },
{ value: '2', label: 'Tue' },
{ value: '3', label: 'Wed' },
{ value: '4', label: 'Thu' },
{ value: '5', label: 'Fri' },
{ value: '6', label: 'Sat' },
{ value: '0', label: 'Sun' },
];
const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1';
const cronPreviewTextColor = 'text-xs text-text-subtle mt-1';
const cronPreviewSpecialNoteColor = 'text-xs text-text-warning mt-1';
const checkboxLabelClassName = 'flex items-center text-sm text-text-default';
const checkboxInputClassName =
'h-4 w-4 text-accent-default border-border-subtle rounded focus:ring-accent-default mr-2';
type SourceType = 'file' | 'deeplink';
type ExecutionMode = 'background' | 'foreground';
// Function to parse deep link and extract recipe config
async function parseDeepLink(deepLink: string): Promise<Recipe | null> {
try {
const url = new URL(deepLink);
if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) {
return null;
}
const recipeParam = url.searchParams.get('config');
if (!recipeParam) {
return null;
}
return await decodeRecipe(recipeParam);
} catch (error) {
console.error('Failed to parse deep link:', error);
return null;
}
}
// Function to convert recipe to YAML with schedule configuration
function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
// Create a clean recipe object for YAML conversion
const cleanRecipe: CleanRecipe = {
title: recipe.title,
description: recipe.description,
};
if (recipe.instructions) {
cleanRecipe.instructions = recipe.instructions;
}
if (recipe.prompt) {
cleanRecipe.prompt = recipe.prompt;
}
if (recipe.activities && recipe.activities.length > 0) {
cleanRecipe.activities = recipe.activities;
}
if (recipe.extensions && recipe.extensions.length > 0) {
cleanRecipe.extensions = recipe.extensions.map((ext) => {
const cleanExt: CleanExtension = {
name: ext.name,
type: 'builtin', // Default type, will be overridden below
};
// Handle different extension types using type assertions
if ('type' in ext && ext.type) {
cleanExt.type = ext.type as CleanExtension['type'];
// Use type assertions to access properties safely
const extAny = ext as Record<string, unknown>;
if (ext.type === 'sse' && extAny.uri) {
cleanExt.uri = extAny.uri as string;
} else if (ext.type === 'streamable_http' && extAny.uri) {
cleanExt.uri = extAny.uri as string;
} else if (ext.type === 'stdio') {
if (extAny.cmd) {
cleanExt.cmd = extAny.cmd as string;
}
if (extAny.args) {
cleanExt.args = extAny.args as string[];
}
} else if (ext.type === 'builtin' && extAny.display_name) {
cleanExt.display_name = extAny.display_name as string;
}
// Handle frontend type separately to avoid TypeScript narrowing issues
if ((ext.type as string) === 'frontend') {
if (extAny.tools) {
cleanExt.tools = extAny.tools as unknown[];
}
if (extAny.instructions) {
cleanExt.instructions = extAny.instructions as string;
}
}
} else {
// Fallback: try to infer type from available fields
const extAny = ext as Record<string, unknown>;
if (extAny.cmd) {
cleanExt.type = 'stdio';
cleanExt.cmd = extAny.cmd as string;
if (extAny.args) {
cleanExt.args = extAny.args as string[];
}
} else if (extAny.command) {
// Handle legacy 'command' field by converting to 'cmd'
cleanExt.type = 'stdio';
cleanExt.cmd = extAny.command as string;
} else if (extAny.uri) {
// Default to streamable_http for URI-based extensions for forward compatibility
cleanExt.type = 'streamable_http';
cleanExt.uri = extAny.uri as string;
} else if (extAny.tools) {
cleanExt.type = 'frontend';
cleanExt.tools = extAny.tools as unknown[];
if (extAny.instructions) {
cleanExt.instructions = extAny.instructions as string;
}
} else {
// Default to builtin if we can't determine type
cleanExt.type = 'builtin';
}
}
// Add common optional fields
if ('env_keys' in ext && ext.env_keys && ext.env_keys.length > 0) {
cleanExt.env_keys = ext.env_keys;
}
if ('timeout' in ext && ext.timeout) {
cleanExt.timeout = ext.timeout as number;
}
if ('description' in ext && ext.description) {
cleanExt.description = ext.description as string;
}
if ('bundled' in ext && ext.bundled !== undefined) {
cleanExt.bundled = ext.bundled as boolean;
}
return cleanExt;
});
}
if (recipe.author) {
cleanRecipe.author = {
contact: recipe.author.contact || undefined,
metadata: recipe.author.metadata || undefined,
};
}
// Add schedule configuration based on execution mode
cleanRecipe.schedule = {
foreground: executionMode === 'foreground',
fallback_to_background: true, // Always allow fallback
window_title: executionMode === 'foreground' ? `${recipe.title} - Scheduled` : undefined,
};
return yaml.stringify(cleanRecipe);
}
export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
isOpen,
onClose,
onSubmit,
isLoadingExternally,
apiErrorExternally,
initialDeepLink,
}) => {
const [scheduleId, setScheduleId] = useState<string>('');
const [sourceType, setSourceType] = useState<SourceType>('file');
const [executionMode, setExecutionMode] = useState<ExecutionMode>('background');
const [recipeSourcePath, setRecipeSourcePath] = useState<string>('');
const [deepLinkInput, setDeepLinkInput] = useState<string>('');
const [parsedRecipe, setParsedRecipe] = useState<Recipe | null>(null);
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
const [customIntervalValue, setCustomIntervalValue] = useState<number>(1);
const [customIntervalUnit, setCustomIntervalUnit] = useState<CustomIntervalUnit>('minute');
const [selectedDate, setSelectedDate] = useState<string>(
() => new Date().toISOString().split('T')[0]
);
const [selectedTime, setSelectedTime] = useState<string>('09:00');
const [selectedMinute, setSelectedMinute] = useState<string>('0');
const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState<Set<string>>(new Set(['1']));
const [selectedDayOfMonth, setSelectedDayOfMonth] = useState<string>('1');
const [derivedCronExpression, setDerivedCronExpression] = useState<string>('');
const [readableCronExpression, setReadableCronExpression] = useState<string>('');
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
const handleDeepLinkChange = useCallback(
async (value: string) => {
setDeepLinkInput(value);
setInternalValidationError(null);
if (value.trim()) {
try {
const recipe = await parseDeepLink(value.trim());
if (recipe) {
setParsedRecipe(recipe);
// Auto-populate schedule ID from recipe title if available
if (recipe.title && !scheduleId) {
const cleanId = recipe.title
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-');
setScheduleId(cleanId);
}
} else {
setParsedRecipe(null);
setInternalValidationError(
'Invalid deep link format. Please use a goose://bot or goose://recipe link.'
);
}
} catch {
setParsedRecipe(null);
setInternalValidationError(
'Failed to parse deep link. Please ensure using a goose://bot or goose://recipe link and try again.'
);
}
} else {
setParsedRecipe(null);
}
},
[scheduleId]
);
useEffect(() => {
// Check for initial deep link from props when modal opens
if (isOpen && initialDeepLink) {
setSourceType('deeplink');
handleDeepLinkChange(initialDeepLink);
}
}, [isOpen, initialDeepLink, handleDeepLinkChange]);
const resetForm = () => {
setScheduleId('');
setSourceType('file');
setExecutionMode('background');
setRecipeSourcePath('');
setDeepLinkInput('');
setParsedRecipe(null);
setFrequency('daily');
setCustomIntervalValue(1);
setCustomIntervalUnit('minute');
setSelectedDate(new Date().toISOString().split('T')[0]);
setSelectedTime('09:00');
setSelectedMinute('0');
setSelectedDaysOfWeek(new Set(['1']));
setSelectedDayOfMonth('1');
setInternalValidationError(null);
setReadableCronExpression('');
};
const handleBrowseFile = async () => {
// Default to global recipes directory, but fallback to local if needed
const defaultPath = getStorageDirectory(true);
const filePath = await window.electron.selectFileOrDirectory(defaultPath);
if (filePath) {
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
setRecipeSourcePath(filePath);
setInternalValidationError(null);
} else {
setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)');
console.warn('Invalid file type: Please select a YAML file (.yaml or .yml)');
}
}
};
useEffect(() => {
const generateCronExpression = (): string => {
const timeParts = selectedTime.split(':');
const minutePart = timeParts.length > 1 ? String(parseInt(timeParts[1], 10)) : '0';
const hourPart = timeParts.length > 0 ? String(parseInt(timeParts[0], 10)) : '0';
if (isNaN(parseInt(minutePart)) || isNaN(parseInt(hourPart))) {
return 'Invalid time format.';
}
// Temporal uses 5-field cron: minute hour day month dayofweek (no seconds)
switch (frequency) {
case 'once':
if (selectedDate && selectedTime) {
try {
const dateObj = new Date(`${selectedDate}T${selectedTime}`);
if (isNaN(dateObj.getTime())) return "Invalid date/time for 'once'.";
return `${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${
dateObj.getMonth() + 1
} *`;
} catch {
return "Error parsing date/time for 'once'.";
}
}
return 'Date and Time are required for "Once" frequency.';
case 'every': {
if (customIntervalValue <= 0) {
return 'Custom interval value must be greater than 0.';
}
switch (customIntervalUnit) {
case 'minute':
return `*/${customIntervalValue} * * * *`;
case 'hour':
return `0 */${customIntervalValue} * * *`;
case 'day':
return `0 0 */${customIntervalValue} * *`;
default:
return 'Invalid custom interval unit.';
}
}
case 'daily':
return `${minutePart} ${hourPart} * * *`;
case 'weekly': {
if (selectedDaysOfWeek.size === 0) {
return 'Select at least one day for weekly frequency.';
}
const days = Array.from(selectedDaysOfWeek)
.sort((a, b) => parseInt(a) - parseInt(b))
.join(',');
return `${minutePart} ${hourPart} * * ${days}`;
}
case 'monthly': {
const sDayOfMonth = parseInt(selectedDayOfMonth, 10);
if (isNaN(sDayOfMonth) || sDayOfMonth < 1 || sDayOfMonth > 31) {
return 'Invalid day of month (1-31) for monthly frequency.';
}
return `${minutePart} ${hourPart} ${sDayOfMonth} * *`;
}
default:
return 'Invalid frequency selected.';
}
};
const cron = generateCronExpression();
setDerivedCronExpression(cron);
try {
if (
cron.includes('Invalid') ||
cron.includes('required') ||
cron.includes('Error') ||
cron.includes('Select at least one')
) {
setReadableCronExpression('Invalid cron details provided.');
} else {
setReadableCronExpression(cronstrue.toString(cron));
}
} catch {
setReadableCronExpression('Could not parse cron string.');
}
}, [
frequency,
customIntervalValue,
customIntervalUnit,
selectedDate,
selectedTime,
selectedMinute,
selectedDaysOfWeek,
selectedDayOfMonth,
]);
const handleDayOfWeekChange = (dayValue: string) => {
setSelectedDaysOfWeek((prev) => {
const newSet = new Set(prev);
if (newSet.has(dayValue)) {
newSet.delete(dayValue);
} else {
newSet.add(dayValue);
}
return newSet;
});
};
const handleLocalSubmit = async (event: FormEvent) => {
event.preventDefault();
setInternalValidationError(null);
if (!scheduleId.trim()) {
setInternalValidationError('Schedule ID is required.');
return;
}
let finalRecipeSource = '';
if (sourceType === 'file') {
if (!recipeSourcePath) {
setInternalValidationError('Recipe source file is required.');
return;
}
finalRecipeSource = recipeSourcePath;
} else if (sourceType === 'deeplink') {
if (!deepLinkInput.trim()) {
setInternalValidationError('Deep link is required.');
return;
}
if (!parsedRecipe) {
setInternalValidationError('Invalid deep link. Please check the format.');
return;
}
try {
// Convert recipe to YAML and save to a temporary file
const yamlContent = recipeToYaml(parsedRecipe, executionMode);
console.log('Generated YAML content:', yamlContent); // Debug log
const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`;
const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.';
const tempFilePath = `${tempDir}/${tempFileName}`;
// Write the YAML file
const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent);
if (!writeSuccess) {
setInternalValidationError('Failed to create temporary recipe file.');
return;
}
finalRecipeSource = tempFilePath;
} catch (error) {
console.error('Failed to convert recipe to YAML:', error);
setInternalValidationError('Failed to process the recipe from deep link.');
return;
}
}
if (
!derivedCronExpression ||
derivedCronExpression.includes('Invalid') ||
derivedCronExpression.includes('required') ||
derivedCronExpression.includes('Error') ||
derivedCronExpression.includes('Select at least one')
) {
setInternalValidationError(`Invalid cron expression: ${derivedCronExpression}`);
return;
}
if (frequency === 'weekly' && selectedDaysOfWeek.size === 0) {
setInternalValidationError('For weekly frequency, select at least one day.');
return;
}
const newSchedulePayload: NewSchedulePayload = {
id: scheduleId.trim(),
recipe_source: finalRecipeSource,
cron: derivedCronExpression,
execution_mode: executionMode,
};
await onSubmit(newSchedulePayload);
};
const handleClose = () => {
resetForm();
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-background-default shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden">
<div className="px-8 pt-8 pb-4 flex-shrink-0 text-center">
<div className="flex flex-col items-center">
<img src={ClockIcon} alt="Clock" className="w-11 h-11 mb-2" />
<h2 className="text-base font-semibold text-text-prominent">Create New Schedule</h2>
<p className="text-base text-text-subtle mt-2 max-w-sm">
Create a new schedule using the settings below to do things like automatically run
tasks or create files
</p>
</div>
</div>
<form
id="new-schedule-form"
onSubmit={handleLocalSubmit}
className="px-8 py-4 space-y-4 flex-grow overflow-y-auto"
>
{apiErrorExternally && (
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
{apiErrorExternally}
</p>
)}
{internalValidationError && (
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
{internalValidationError}
</p>
)}
<div>
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
Name:
</label>
<Input
type="text"
id="scheduleId-modal"
value={scheduleId}
onChange={(e) => setScheduleId(e.target.value)}
placeholder="e.g., daily-summary-job"
required
/>
</div>
<div>
<label className={modalLabelClassName}>Source:</label>
<div className="space-y-2">
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-full p-1">
<button
type="button"
onClick={() => setSourceType('file')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
sourceType === 'file'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
YAML
</button>
<button
type="button"
onClick={() => setSourceType('deeplink')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
sourceType === 'deeplink'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Deep link
</button>
</div>
{sourceType === 'file' && (
<div>
<Button
type="button"
variant="outline"
onClick={handleBrowseFile}
className="w-full justify-center rounded-full"
>
Browse for YAML file...
</Button>
{recipeSourcePath && (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">
Selected: {recipeSourcePath}
</p>
)}
{executionMode === 'foreground' && (
<div className="mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md border border-blue-200 dark:border-blue-800">
<p className="text-xs text-blue-700 dark:text-blue-300">
<strong>Note:</strong> For foreground execution with YAML files, add this to
your recipe:
</p>
<pre className="text-xs text-blue-600 dark:text-blue-400 mt-1 font-mono bg-blue-100 dark:bg-blue-900/40 p-1 rounded">
{`schedule:
foreground: true
fallback_to_background: true`}
</pre>
</div>
)}
</div>
)}
{sourceType === 'deeplink' && (
<div>
<Input
type="text"
value={deepLinkInput}
onChange={(e) => handleDeepLinkChange(e.target.value)}
placeholder="Paste goose://bot or goose://recipe link here..."
className="rounded-full"
/>
{parsedRecipe && (
<div className="mt-2 p-2 bg-green-100 dark:bg-green-900/30 rounded-md border border-green-500/50">
<p className="text-xs text-green-700 dark:text-green-300 font-medium">
Recipe parsed successfully
</p>
<p className="text-xs text-green-600 dark:text-green-400">
Title: {parsedRecipe.title}
</p>
<p className="text-xs text-green-600 dark:text-green-400">
Description: {parsedRecipe.description}
</p>
</div>
)}
</div>
)}
</div>
</div>
<div>
<label className={modalLabelClassName}>Execution Mode:</label>
<div className="space-y-2">
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-full p-1">
<button
type="button"
onClick={() => setExecutionMode('background')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
executionMode === 'background'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Background
</button>
<button
type="button"
onClick={() => setExecutionMode('foreground')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
executionMode === 'foreground'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Foreground
</button>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 px-2">
{executionMode === 'background' ? (
<p>
<strong>Background:</strong> Runs silently in the background without opening a
window. Results are saved to session storage.
</p>
) : (
<p>
<strong>Foreground:</strong> Opens in a desktop window when the Goose app is
running. Falls back to background if the app is not available.
</p>
)}
</div>
</div>
</div>
<div>
<label htmlFor="frequency-modal" className={modalLabelClassName}>
Frequency:
</label>
<Select
instanceId="frequency-select-modal"
options={frequencies}
value={frequencies.find((f) => f.value === frequency)}
onChange={(newValue: unknown) => {
const selectedOption = newValue as FrequencyOption | null;
if (selectedOption) setFrequency(selectedOption.value);
}}
placeholder="Select frequency..."
/>
</div>
{frequency === 'every' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="customIntervalValue-modal" className={modalLabelClassName}>
Every:
</label>
<Input
type="number"
id="customIntervalValue-modal"
min="1"
max="999"
value={customIntervalValue}
onChange={(e) => setCustomIntervalValue(parseInt(e.target.value) || 1)}
required
/>
</div>
<div>
<label htmlFor="customIntervalUnit-modal" className={modalLabelClassName}>
Unit:
</label>
<Select
instanceId="custom-interval-unit-select-modal"
options={customIntervalUnits}
value={customIntervalUnits.find((u) => u.value === customIntervalUnit)}
onChange={(newValue: unknown) => {
const selectedUnit = newValue as {
value: CustomIntervalUnit;
label: string;
} | null;
if (selectedUnit) setCustomIntervalUnit(selectedUnit.value);
}}
placeholder="Select unit..."
/>
</div>
</div>
)}
{frequency === 'once' && (
<>
<div>
<label htmlFor="onceDate-modal" className={modalLabelClassName}>
Date:
</label>
<Input
type="date"
id="onceDate-modal"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="onceTime-modal" className={modalLabelClassName}>
Time:
</label>
<Input
type="time"
id="onceTime-modal"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
required
/>
</div>
</>
)}
{(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
<div>
<label htmlFor="commonTime-modal" className={modalLabelClassName}>
Time:
</label>
<Input
type="time"
id="commonTime-modal"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
required
/>
</div>
)}
{frequency === 'weekly' && (
<div>
<label className={modalLabelClassName}>Days of Week:</label>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-1">
{daysOfWeekOptions.map((day) => (
<label key={day.value} className={checkboxLabelClassName}>
<input
type="checkbox"
value={day.value}
checked={selectedDaysOfWeek.has(day.value)}
onChange={() => handleDayOfWeekChange(day.value)}
className={checkboxInputClassName}
/>
{day.label}
</label>
))}
</div>
</div>
)}
{frequency === 'monthly' && (
<div>
<label htmlFor="monthlyDay-modal" className={modalLabelClassName}>
Day of Month (1-31):
</label>
<Input
type="number"
id="monthlyDay-modal"
min="1"
max="31"
value={selectedDayOfMonth}
onChange={(e) => setSelectedDayOfMonth(e.target.value)}
required
/>
</div>
)}
<div className="mt-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-md border border-gray-200 dark:border-gray-600">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Generated Cron:{' '}
<code className="text-xs bg-gray-200 dark:bg-gray-600 p-1 rounded">
{derivedCronExpression}
</code>
</p>
<p className={`${cronPreviewTextColor} mt-2`}>
<b>Human Readable:</b> {readableCronExpression}
</p>
<p className={cronPreviewTextColor}>
Syntax: M H D M DoW (M=minute, H=hour, D=day, M=month, DoW=day of week: 0/7=Sun)
</p>
{frequency === 'once' && (
<p className={cronPreviewSpecialNoteColor}>
Note: "Once" schedules recur annually. True one-time tasks may need backend deletion
after execution.
</p>
)}
</div>
</form>
{/* Actions */}
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
<Button
type="submit"
form="new-schedule-form"
variant="ghost"
disabled={isLoadingExternally}
className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
>
{isLoadingExternally ? 'Creating...' : 'Create Schedule'}
</Button>
<Button
type="button"
variant="ghost"
onClick={handleClose}
disabled={isLoadingExternally}
className="w-full h-[60px] rounded-none border-t text-gray-400 hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
>
Cancel
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,283 @@
import React, { useState, useEffect } from 'react';
import cronstrue from 'cronstrue';
import { ScheduledJob } from '../../schedule';
type Period = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
type ParsedCron = {
period: Period;
second: string;
minute: string;
hour: string;
dayOfMonth: string;
month: string;
dayOfWeek: string;
};
interface CronPickerProps {
schedule: ScheduledJob | null;
onChange: (cron: string) => void;
}
const parseCron = (cron: string): ParsedCron => {
const parts = cron.split(' ');
if (parts.length === 5) {
parts.unshift('0');
}
if (parts.length !== 6) {
return {
period: 'day',
second: '0',
minute: '0',
hour: '14',
dayOfMonth: '*',
month: '*',
dayOfWeek: '*',
};
}
const [second, minute, hour, dayOfMonth, month, dayOfWeek] = parts;
if (month !== '*' && dayOfMonth !== '*') {
return { period: 'year', second, minute, hour, dayOfMonth, month, dayOfWeek };
}
if (dayOfMonth !== '*') {
return { period: 'month', second, minute, hour, dayOfMonth, month, dayOfWeek };
}
if (dayOfWeek !== '*') {
return { period: 'week', second, minute, hour, dayOfMonth, month, dayOfWeek };
}
if (hour !== '*') {
return { period: 'day', second, minute, hour, dayOfMonth, month, dayOfWeek };
}
if (minute !== '*') {
return { period: 'hour', second, minute, hour, dayOfMonth, month, dayOfWeek };
}
return { period: 'minute', second, minute, hour, dayOfMonth, month, dayOfWeek };
};
const to24Hour = (hour12: number, isPM: boolean): number => {
if (hour12 === 12) {
return isPM ? 12 : 0;
}
return isPM ? hour12 + 12 : hour12;
};
const to12Hour = (hour24: number): { hour: number; isPM: boolean } => {
if (hour24 === 0) {
return { hour: 12, isPM: false };
}
if (hour24 === 12) {
return { hour: 12, isPM: true };
}
if (hour24 > 12) {
return { hour: hour24 - 12, isPM: true };
}
return { hour: hour24, isPM: false };
};
export const CronPicker: React.FC<CronPickerProps> = ({ schedule, onChange }) => {
const [period, setPeriod] = useState<Period>('day');
const [second, setSecond] = useState('0');
const [minute, setMinute] = useState('0');
const [hour12, setHour12] = useState(2);
const [currentCron, setCurrentCron] = useState('');
const [isPM, setIsPM] = useState(true);
const [dayOfWeek, setDayOfWeek] = useState('1');
const [dayOfMonth, setDayOfMonth] = useState('1');
const [month, setMonth] = useState('1');
useEffect(() => {
const parsed = parseCron(schedule?.cron || '');
setPeriod(parsed.period);
setSecond(parsed.second === '*' ? '0' : parsed.second);
setMinute(parsed.minute === '*' ? '0' : parsed.minute);
const hour24 = parsed.hour === '*' ? 14 : parseInt(parsed.hour, 10);
const { hour, isPM: pm } = to12Hour(hour24);
setHour12(hour);
setIsPM(pm);
setDayOfWeek(parsed.dayOfWeek === '*' ? '1' : parsed.dayOfWeek);
setDayOfMonth(parsed.dayOfMonth === '*' ? '1' : parsed.dayOfMonth);
setMonth(parsed.month === '*' ? '1' : parsed.month);
}, [schedule]);
useEffect(() => {
const hour24 = to24Hour(hour12, isPM);
let cron: string;
switch (period) {
case 'minute':
cron = `${second} * * * * *`;
break;
case 'hour':
cron = `${second} ${minute} * * * *`;
break;
case 'day':
cron = `${second} ${minute} ${hour24} * * *`;
break;
case 'week':
cron = `${second} ${minute} ${hour24} * * ${dayOfWeek}`;
break;
case 'month':
cron = `${second} ${minute} ${hour24} ${dayOfMonth} * *`;
break;
case 'year':
cron = `${second} ${minute} ${hour24} ${dayOfMonth} ${month} *`;
break;
default:
cron = '0 0 0 * * *';
}
onChange(cron);
setCurrentCron(cron);
}, [period, second, minute, hour12, isPM, dayOfWeek, dayOfMonth, month, onChange]);
const getReadable = () => {
if (!currentCron) {
return '';
}
const cronWithoutSeconds = currentCron.split(' ').slice(1).join(' ');
return cronstrue.toString(cronWithoutSeconds);
};
const selectClassName = 'px-2 py-1 border rounded bg-white dark:bg-gray-800 dark:border-gray-600';
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Every</span>
<select
value={period}
onChange={(e) => setPeriod(e.target.value as Period)}
className={selectClassName}
>
<option value="minute">Minute</option>
<option value="hour">Hour</option>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
<option value="year">Year</option>
</select>
</div>
<div className="space-y-3">
{period === 'year' && (
<div className="flex items-center gap-2">
<span className="text-sm">in</span>
<select
value={month}
onChange={(e) => setMonth(e.target.value)}
className={selectClassName}
>
<option value="1">January</option>
<option value="2">February</option>
<option value="3">March</option>
<option value="4">April</option>
<option value="5">May</option>
<option value="6">June</option>
<option value="7">July</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select>
</div>
)}
{(period === 'month' || period === 'year') && (
<div className="flex items-center gap-2">
<span className="text-sm">on day</span>
<input
type="number"
min="1"
max="31"
value={dayOfMonth}
onChange={(e) => setDayOfMonth(e.target.value)}
className="w-16 px-2 py-1 border rounded"
/>
</div>
)}
{period === 'week' && (
<div className="flex items-center gap-2">
<span className="text-sm">on</span>
<select
value={dayOfWeek}
onChange={(e) => setDayOfWeek(e.target.value)}
className={selectClassName}
>
<option value="0">Sunday</option>
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
</select>
</div>
)}
{(period === 'day' || period === 'week' || period === 'month' || period === 'year') && (
<div className="flex items-center gap-2">
<span className="text-sm">at</span>
<input
type="number"
min="1"
max="12"
value={hour12}
onChange={(e) => setHour12(parseInt(e.target.value) || 1)}
className="w-16 px-2 py-1 border rounded"
/>
<span className="text-sm">:</span>
<input
type="number"
min="0"
max="59"
value={minute}
onChange={(e) => setMinute(e.target.value.padStart(2, '0'))}
className="w-16 px-2 py-1 border rounded"
/>
<select
value={isPM ? 'PM' : 'AM'}
onChange={(e) => setIsPM(e.target.value === 'PM')}
className={selectClassName}
>
<option value="AM">AM</option>
<option value="PM">PM</option>
</select>
</div>
)}
{period === 'hour' && (
<div className="flex items-center gap-2">
<span className="text-sm">at minute</span>
<input
type="number"
min="0"
max="59"
value={minute}
onChange={(e) => setMinute(e.target.value)}
className="w-16 px-2 py-1 border rounded"
/>
</div>
)}
{period === 'minute' && (
<div className="flex items-center gap-2">
<span className="text-sm">at second</span>
<input
type="number"
min="0"
max="59"
value={second}
onChange={(e) => setSecond(e.target.value)}
className="w-16 px-2 py-1 border rounded"
/>
</div>
)}
</div>
<div className="text-xs text-gray-500 mt-2">{getReadable()}</div>
</div>
);
};
@@ -1,551 +0,0 @@
import React, { useState, useEffect, FormEvent } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Select } from '../ui/Select';
import { ScheduledJob } from '../../schedule';
import cronstrue from 'cronstrue';
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
type CustomIntervalUnit = 'minute' | 'hour' | 'day';
interface FrequencyOption {
value: FrequencyValue;
label: string;
}
interface EditScheduleModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (cron: string) => Promise<void>;
schedule: ScheduledJob | null;
isLoadingExternally?: boolean;
apiErrorExternally?: string | null;
}
const frequencies: FrequencyOption[] = [
{ value: 'once', label: 'Once' },
{ value: 'every', label: 'Every...' },
{ value: 'daily', label: 'Daily (at specific time)' },
{ value: 'weekly', label: 'Weekly (at specific time/days)' },
{ value: 'monthly', label: 'Monthly (at specific time/day)' },
];
const customIntervalUnits: { value: CustomIntervalUnit; label: string }[] = [
{ value: 'minute', label: 'minute(s)' },
{ value: 'hour', label: 'hour(s)' },
{ value: 'day', label: 'day(s)' },
];
const daysOfWeekOptions: { value: string; label: string }[] = [
{ value: '1', label: 'Mon' },
{ value: '2', label: 'Tue' },
{ value: '3', label: 'Wed' },
{ value: '4', label: 'Thu' },
{ value: '5', label: 'Fri' },
{ value: '6', label: 'Sat' },
{ value: '0', label: 'Sun' },
];
const modalLabelClassName = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
const cronPreviewTextColor = 'text-xs text-gray-500 dark:text-gray-400 mt-1';
const cronPreviewSpecialNoteColor = 'text-xs text-yellow-600 dark:text-yellow-500 mt-1';
const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark:text-gray-300';
const checkboxInputClassName =
'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2';
// Helper function to parse cron expression and determine frequency
const parseCronExpression = (cron: string) => {
const parts = cron.split(' ');
if (parts.length !== 5 && parts.length !== 6) return null;
// Handle both 5-field and 6-field cron expressions
const [minutes, hours, dayOfMonth, month, dayOfWeek] =
parts.length === 5 ? parts : parts.slice(1); // Skip seconds if present
// Check for custom intervals (every X minutes/hours/days)
if (
minutes.startsWith('*/') &&
hours === '*' &&
dayOfMonth === '*' &&
month === '*' &&
dayOfWeek === '*'
) {
const intervalValue = parseInt(minutes.substring(2));
return {
frequency: 'every' as FrequencyValue,
customIntervalValue: intervalValue,
customIntervalUnit: 'minute' as CustomIntervalUnit,
};
}
if (
minutes === '0' &&
hours.startsWith('*/') &&
dayOfMonth === '*' &&
month === '*' &&
dayOfWeek === '*'
) {
const intervalValue = parseInt(hours.substring(2));
return {
frequency: 'every' as FrequencyValue,
customIntervalValue: intervalValue,
customIntervalUnit: 'hour' as CustomIntervalUnit,
};
}
if (
minutes === '0' &&
hours === '0' &&
dayOfMonth.startsWith('*/') &&
month === '*' &&
dayOfWeek === '*'
) {
const intervalValue = parseInt(dayOfMonth.substring(2));
return {
frequency: 'every' as FrequencyValue,
customIntervalValue: intervalValue,
customIntervalUnit: 'day' as CustomIntervalUnit,
};
}
// Check for specific patterns
if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') {
return { frequency: 'once' as FrequencyValue, minutes, hours, dayOfMonth, month };
}
if (
minutes !== '*' &&
hours !== '*' &&
dayOfMonth === '*' &&
month === '*' &&
dayOfWeek === '*'
) {
return { frequency: 'daily' as FrequencyValue, minutes, hours };
}
if (
minutes !== '*' &&
hours !== '*' &&
dayOfMonth === '*' &&
month === '*' &&
dayOfWeek !== '*'
) {
return { frequency: 'weekly' as FrequencyValue, minutes, hours, dayOfWeek };
}
if (
minutes !== '*' &&
hours !== '*' &&
dayOfMonth !== '*' &&
month === '*' &&
dayOfWeek === '*'
) {
return { frequency: 'monthly' as FrequencyValue, minutes, hours, dayOfMonth };
}
return null;
};
export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
isOpen,
onClose,
onSubmit,
schedule,
isLoadingExternally = false,
apiErrorExternally = null,
}) => {
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
const [customIntervalValue, setCustomIntervalValue] = useState<number>(1);
const [customIntervalUnit, setCustomIntervalUnit] = useState<CustomIntervalUnit>('minute');
const [selectedDate, setSelectedDate] = useState<string>(
() => new Date().toISOString().split('T')[0]
);
const [selectedTime, setSelectedTime] = useState<string>('09:00');
const [selectedMinute] = useState<string>('0');
const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState<Set<string>>(new Set(['1']));
const [selectedDayOfMonth, setSelectedDayOfMonth] = useState<string>('1');
const [derivedCronExpression, setDerivedCronExpression] = useState<string>('');
const [readableCronExpression, setReadableCronExpression] = useState<string>('');
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
// Initialize form from existing schedule
useEffect(() => {
if (schedule && isOpen) {
const parsed = parseCronExpression(schedule.cron);
if (parsed) {
setFrequency(parsed.frequency);
switch (parsed.frequency) {
case 'once':
// For 'once', we'd need to reconstruct the date from cron parts
// This is complex, so we'll default to current date/time for now
setSelectedDate(new Date().toISOString().split('T')[0]);
setSelectedTime(
`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`
);
break;
case 'every':
if (parsed.customIntervalValue) {
setCustomIntervalValue(parsed.customIntervalValue);
}
if (parsed.customIntervalUnit) {
setCustomIntervalUnit(parsed.customIntervalUnit);
}
break;
case 'daily':
setSelectedTime(
`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`
);
break;
case 'weekly':
setSelectedTime(
`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`
);
if (parsed.dayOfWeek) {
const days = parsed.dayOfWeek.split(',').map((d) => d.trim());
setSelectedDaysOfWeek(new Set(days));
}
break;
case 'monthly':
setSelectedTime(
`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`
);
setSelectedDayOfMonth(parsed.dayOfMonth || '1');
break;
}
} else {
// If we can't parse the cron, default to daily at 9 AM
setFrequency('daily');
setSelectedTime('09:00');
}
setInternalValidationError(null);
}
}, [schedule, isOpen]);
useEffect(() => {
const generateCronExpression = (): string => {
const timeParts = selectedTime.split(':');
const minutePart = timeParts.length > 1 ? String(parseInt(timeParts[1], 10)) : '0';
const hourPart = timeParts.length > 0 ? String(parseInt(timeParts[0], 10)) : '0';
if (isNaN(parseInt(minutePart)) || isNaN(parseInt(hourPart))) {
return 'Invalid time format.';
}
switch (frequency) {
case 'once':
if (selectedDate && selectedTime) {
try {
const dateObj = new Date(`${selectedDate}T${selectedTime}`);
if (isNaN(dateObj.getTime())) return "Invalid date/time for 'once'.";
return `${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${
dateObj.getMonth() + 1
} *`;
} catch {
return "Error parsing date/time for 'once'.";
}
}
return 'Date and Time are required for "Once" frequency.';
case 'every': {
if (customIntervalValue <= 0) {
return 'Custom interval value must be greater than 0.';
}
switch (customIntervalUnit) {
case 'minute':
return `*/${customIntervalValue} * * * *`;
case 'hour':
return `0 */${customIntervalValue} * * *`;
case 'day':
return `0 0 */${customIntervalValue} * *`;
default:
return 'Invalid custom interval unit.';
}
}
case 'daily':
return `${minutePart} ${hourPart} * * *`;
case 'weekly': {
if (selectedDaysOfWeek.size === 0) {
return 'Select at least one day for weekly frequency.';
}
const days = Array.from(selectedDaysOfWeek)
.sort((a, b) => parseInt(a) - parseInt(b))
.join(',');
return `${minutePart} ${hourPart} * * ${days}`;
}
case 'monthly': {
const sDayOfMonth = parseInt(selectedDayOfMonth, 10);
if (isNaN(sDayOfMonth) || sDayOfMonth < 1 || sDayOfMonth > 31) {
return 'Invalid day of month (1-31) for monthly frequency.';
}
return `${minutePart} ${hourPart} ${sDayOfMonth} * *`;
}
default:
return 'Invalid frequency selected.';
}
};
const cron = generateCronExpression();
setDerivedCronExpression(cron);
try {
if (
cron.includes('Invalid') ||
cron.includes('required') ||
cron.includes('Error') ||
cron.includes('Select at least one')
) {
setReadableCronExpression('Invalid cron details provided.');
} else {
setReadableCronExpression(cronstrue.toString(cron));
}
} catch {
setReadableCronExpression('Could not parse cron string.');
}
}, [
frequency,
customIntervalValue,
customIntervalUnit,
selectedDate,
selectedTime,
selectedMinute,
selectedDaysOfWeek,
selectedDayOfMonth,
]);
const handleDayOfWeekChange = (dayValue: string) => {
setSelectedDaysOfWeek((prev) => {
const newSet = new Set(prev);
if (newSet.has(dayValue)) {
newSet.delete(dayValue);
} else {
newSet.add(dayValue);
}
return newSet;
});
};
const handleLocalSubmit = async (event: FormEvent) => {
event.preventDefault();
setInternalValidationError(null);
if (
!derivedCronExpression ||
derivedCronExpression.includes('Invalid') ||
derivedCronExpression.includes('required') ||
derivedCronExpression.includes('Error') ||
derivedCronExpression.includes('Select at least one')
) {
setInternalValidationError(`Invalid cron expression: ${derivedCronExpression}`);
return;
}
if (frequency === 'weekly' && selectedDaysOfWeek.size === 0) {
setInternalValidationError('For weekly frequency, select at least one day.');
return;
}
await onSubmit(derivedCronExpression);
};
const handleClose = () => {
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-background-default shadow-xl rounded-lg z-50 flex flex-col max-h-[90vh] overflow-hidden">
<div className="px-6 pt-6 pb-4 flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Edit Schedule: {schedule?.id || ''}
</h2>
</div>
<form
id="edit-schedule-form"
onSubmit={handleLocalSubmit}
className="px-6 py-4 space-y-4 flex-grow overflow-y-auto"
>
{apiErrorExternally && (
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
{apiErrorExternally}
</p>
)}
{internalValidationError && (
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
{internalValidationError}
</p>
)}
<div>
<label htmlFor="frequency-modal" className={modalLabelClassName}>
Frequency:
</label>
<Select
instanceId="frequency-select-modal"
options={frequencies}
value={frequencies.find((f) => f.value === frequency)}
onChange={(newValue: unknown) => {
const selectedOption = newValue as FrequencyOption | null;
if (selectedOption) setFrequency(selectedOption.value);
}}
placeholder="Select frequency..."
/>
</div>
{frequency === 'every' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="customIntervalValue-modal" className={modalLabelClassName}>
Every:
</label>
<Input
type="number"
id="customIntervalValue-modal"
min="1"
max="999"
value={customIntervalValue}
onChange={(e) => setCustomIntervalValue(parseInt(e.target.value) || 1)}
required
/>
</div>
<div>
<label htmlFor="customIntervalUnit-modal" className={modalLabelClassName}>
Unit:
</label>
<Select
instanceId="custom-interval-unit-select-modal"
options={customIntervalUnits}
value={customIntervalUnits.find((u) => u.value === customIntervalUnit)}
onChange={(newValue: unknown) => {
const selectedUnit = newValue as {
value: CustomIntervalUnit;
label: string;
} | null;
if (selectedUnit) setCustomIntervalUnit(selectedUnit.value);
}}
placeholder="Select unit..."
/>
</div>
</div>
)}
{frequency === 'once' && (
<>
<div>
<label htmlFor="onceDate-modal" className={modalLabelClassName}>
Date:
</label>
<Input
type="date"
id="onceDate-modal"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="onceTime-modal" className={modalLabelClassName}>
Time:
</label>
<Input
type="time"
id="onceTime-modal"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
required
/>
</div>
</>
)}
{(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
<div>
<label htmlFor="commonTime-modal" className={modalLabelClassName}>
Time:
</label>
<Input
type="time"
id="commonTime-modal"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
required
/>
</div>
)}
{frequency === 'weekly' && (
<div>
<label className={modalLabelClassName}>Days of Week:</label>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-1">
{daysOfWeekOptions.map((day) => (
<label key={day.value} className={checkboxLabelClassName}>
<input
type="checkbox"
value={day.value}
checked={selectedDaysOfWeek.has(day.value)}
onChange={() => handleDayOfWeekChange(day.value)}
className={checkboxInputClassName}
/>
{day.label}
</label>
))}
</div>
</div>
)}
{frequency === 'monthly' && (
<div>
<label htmlFor="monthlyDay-modal" className={modalLabelClassName}>
Day of Month (1-31):
</label>
<Input
type="number"
id="monthlyDay-modal"
min="1"
max="31"
value={selectedDayOfMonth}
onChange={(e) => setSelectedDayOfMonth(e.target.value)}
required
/>
</div>
)}
<div className="mt-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-md border border-gray-200 dark:border-gray-600">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Generated Cron:{' '}
<code className="text-xs bg-gray-200 dark:bg-gray-600 p-1 rounded">
{derivedCronExpression}
</code>
</p>
<p className={`${cronPreviewTextColor} mt-2`}>
<b>Human Readable:</b> {readableCronExpression}
</p>
<p className={cronPreviewTextColor}>
Syntax: M H D M DoW (M=minute, H=hour, D=day, M=month, DoW=day of week: 0/7=Sun)
</p>
{frequency === 'once' && (
<p className={cronPreviewSpecialNoteColor}>
Note: "Once" schedules recur annually. True one-time tasks may need backend deletion
after execution.
</p>
)}
</div>
</form>
{/* Actions */}
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
<Button
type="button"
variant="ghost"
onClick={handleClose}
disabled={isLoadingExternally}
className="w-full h-[60px] rounded-none border-t text-gray-400 hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
>
Cancel
</Button>
<Button
type="submit"
form="edit-schedule-form"
variant="ghost"
disabled={isLoadingExternally}
className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
>
{isLoadingExternally ? 'Updating...' : 'Update Schedule'}
</Button>
</div>
</Card>
</div>
);
};
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import BackButton from '../ui/BackButton';
@@ -15,7 +15,7 @@ import {
ScheduledJob,
} from '../../schedule';
import SessionHistoryView from '../sessions/SessionHistoryView';
import { EditScheduleModal } from './EditScheduleModal';
import { ScheduleModal, NewSchedulePayload } from './ScheduleModal';
import { toastError, toastSuccess } from '../../toasts';
import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react';
import cronstrue from 'cronstrue';
@@ -42,292 +42,125 @@ interface ScheduleDetailViewProps {
onNavigateBack: () => void;
}
// Memoized ScheduleInfoCard component to prevent unnecessary re-renders of static content
const ScheduleInfoCard = React.memo<{
scheduleDetails: ScheduledJob;
}>(({ scheduleDetails }) => {
const readableCron = useMemo(() => {
try {
return cronstrue.toString(scheduleDetails.cron);
} catch (e) {
console.warn(`Could not parse cron string "${scheduleDetails.cron}":`, e);
return scheduleDetails.cron;
}
}, [scheduleDetails.cron]);
const formattedLastRun = useMemo(() => {
return formatToLocalDateWithTimezone(scheduleDetails.last_run);
}, [scheduleDetails.last_run]);
const formattedProcessStartTime = useMemo(() => {
return scheduleDetails.process_start_time
? formatToLocalDateWithTimezone(scheduleDetails.process_start_time)
: null;
}, [scheduleDetails.process_start_time]);
return (
<Card className="p-4 bg-background-card shadow mb-6">
<div className="space-y-2">
<div className="flex flex-col md:flex-row md:items-center justify-between">
<h3 className="text-base font-semibold text-text-prominent">{scheduleDetails.id}</h3>
<div className="mt-2 md:mt-0 flex items-center gap-2">
{scheduleDetails.currently_running && (
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
Currently Running
</div>
)}
{scheduleDetails.paused && (
<div className="text-sm text-orange-500 dark:text-orange-400 font-semibold flex items-center">
<Pause className="w-3 h-3 mr-1" />
Paused
</div>
)}
</div>
</div>
<p className="text-sm text-text-default">
<span className="font-semibold">Schedule:</span> {readableCron}
</p>
<p className="text-sm text-text-default">
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
</p>
<p className="text-sm text-text-default">
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
</p>
<p className="text-sm text-text-default">
<span className="font-semibold">Last Run:</span> {formattedLastRun}
</p>
{scheduleDetails.execution_mode && (
<p className="text-sm text-text-default">
<span className="font-semibold">Execution Mode:</span>{' '}
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
scheduleDetails.execution_mode === 'foreground'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
}`}
>
{scheduleDetails.execution_mode === 'foreground' ? '🖥️ Foreground' : '⚡ Background'}
</span>
</p>
)}
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
<p className="text-sm text-text-default">
<span className="font-semibold">Current Session:</span>{' '}
{scheduleDetails.current_session_id}
</p>
)}
{scheduleDetails.currently_running && formattedProcessStartTime && (
<p className="text-sm text-text-default">
<span className="font-semibold">Process Started:</span> {formattedProcessStartTime}
</p>
)}
</div>
</Card>
);
});
ScheduleInfoCard.displayName = 'ScheduleInfoCard';
const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onNavigateBack }) => {
const [sessions, setSessions] = useState<ScheduleSessionMeta[]>([]);
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
const [sessionsError, setSessionsError] = useState<string | null>(null);
const [runNowLoading, setRunNowLoading] = useState(false);
const [scheduleDetails, setScheduleDetails] = useState<ScheduledJob | null>(null);
const [isLoadingSchedule, setIsLoadingSchedule] = useState(false);
const [scheduleError, setScheduleError] = useState<string | null>(null);
// Individual loading states for each action to prevent double-clicks
const [pauseUnpauseLoading, setPauseUnpauseLoading] = useState(false);
const [killJobLoading, setKillJobLoading] = useState(false);
const [inspectJobLoading, setInspectJobLoading] = useState(false);
const [isActionLoading, setIsActionLoading] = useState(false);
// Track if we explicitly killed a job to distinguish from natural completion
const [jobWasKilled, setJobWasKilled] = useState(false);
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
const [isLoadingSession, setIsLoadingSession] = useState(false);
const [sessionError, setSessionError] = useState<string | null>(null);
const [selectedSessionDetails, setSelectedSessionDetails] = useState<Session | null>(null);
const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false);
const [sessionDetailsError, setSessionDetailsError] = useState<string | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editApiError, setEditApiError] = useState<string | null>(null);
const [isEditSubmitting, setIsEditSubmitting] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const fetchScheduleSessions = useCallback(async (sId: string) => {
if (!sId) return;
const fetchSessions = async (sId: string) => {
setIsLoadingSessions(true);
setSessionsError(null);
try {
const fetchedSessions = await getScheduleSessions(sId, 20);
setSessions((prevSessions) => {
// Only update if sessions actually changed to prevent unnecessary re-renders
if (JSON.stringify(prevSessions) !== JSON.stringify(fetchedSessions)) {
return fetchedSessions as ScheduleSessionMeta[];
}
return prevSessions;
});
const data = await getScheduleSessions(sId, 20);
setSessions(data);
} catch (err) {
console.error('Failed to fetch schedule sessions:', err);
setSessionsError(err instanceof Error ? err.message : 'Failed to fetch schedule sessions');
setSessionsError(err instanceof Error ? err.message : 'Failed to fetch sessions');
} finally {
setIsLoadingSessions(false);
}
}, []);
};
const fetchScheduleDetails = useCallback(
async (sId: string, isRefresh = false) => {
if (!sId) return;
if (!isRefresh) setIsLoadingSchedule(true);
setScheduleError(null);
try {
const allSchedules = await listSchedules();
const schedule = allSchedules.find((s) => s.id === sId);
if (schedule) {
setScheduleDetails((prevDetails) => {
// Only update if schedule details actually changed
if (!prevDetails || JSON.stringify(prevDetails) !== JSON.stringify(schedule)) {
// Only reset runNowLoading if we explicitly killed the job
if (!schedule.currently_running && runNowLoading && jobWasKilled) {
setRunNowLoading(false);
setJobWasKilled(false);
}
return schedule;
}
return prevDetails;
});
} 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 {
if (!isRefresh) setIsLoadingSchedule(false);
const fetchSchedule = async (sId: string) => {
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');
}
},
[runNowLoading, jobWasKilled]
);
} catch (err) {
setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule');
} finally {
setIsLoadingSchedule(false);
}
};
useEffect(() => {
if (scheduleId && !selectedSessionDetails) {
fetchScheduleSessions(scheduleId);
fetchScheduleDetails(scheduleId);
} else if (!scheduleId) {
setSessions([]);
setSessionsError(null);
setRunNowLoading(false);
setSelectedSessionDetails(null);
setScheduleDetails(null);
setScheduleError(null);
setJobWasKilled(false); // Reset kill flag when changing schedules
if (scheduleId && !selectedSession) {
fetchSessions(scheduleId);
fetchSchedule(scheduleId);
}
}, [scheduleId, fetchScheduleSessions, fetchScheduleDetails, selectedSessionDetails]);
}, [scheduleId, selectedSession]);
const handleRunNow = async () => {
if (!scheduleId) return;
setRunNowLoading(true);
setIsActionLoading(true);
try {
const newSessionId = await runScheduleNow(scheduleId);
if (newSessionId === 'CANCELLED') {
toastSuccess({
title: 'Job Cancelled',
msg: 'The job was cancelled while starting up.',
});
toastSuccess({ title: 'Job Cancelled', msg: 'The job was cancelled while starting up.' });
} else {
toastSuccess({
title: 'Schedule Triggered',
msg: `Successfully triggered schedule. New session ID: ${newSessionId}`,
});
toastSuccess({ title: 'Schedule Triggered', msg: `New session: ${newSessionId}` });
}
setTimeout(() => {
if (scheduleId) {
fetchScheduleSessions(scheduleId);
fetchScheduleDetails(scheduleId);
}
}, 1000);
await fetchSessions(scheduleId);
await fetchSchedule(scheduleId);
} catch (err) {
console.error('Failed to run schedule now:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger schedule';
toastError({ title: 'Run Schedule Error', msg: errorMsg });
} finally {
setRunNowLoading(false);
}
};
const handlePauseSchedule = async () => {
if (!scheduleId) return;
setPauseUnpauseLoading(true);
try {
await pauseSchedule(scheduleId);
toastSuccess({
title: 'Schedule Paused',
msg: `Successfully paused schedule "${scheduleId}"`,
toastError({
title: 'Run Schedule Error',
msg: err instanceof Error ? err.message : 'Failed to trigger schedule',
});
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 });
} finally {
setPauseUnpauseLoading(false);
setIsActionLoading(false);
}
};
const handleUnpauseSchedule = async () => {
if (!scheduleId) return;
setPauseUnpauseLoading(true);
const handlePauseToggle = async () => {
if (!scheduleId || !scheduleDetails) return;
setIsActionLoading(true);
try {
await unpauseSchedule(scheduleId);
toastSuccess({
title: 'Schedule Unpaused',
msg: `Successfully unpaused schedule "${scheduleId}"`,
});
fetchScheduleDetails(scheduleId);
if (scheduleDetails.paused) {
await unpauseSchedule(scheduleId);
toastSuccess({ title: 'Schedule Unpaused', msg: `Unpaused "${scheduleId}"` });
} else {
await pauseSchedule(scheduleId);
toastSuccess({ title: 'Schedule Paused', msg: `Paused "${scheduleId}"` });
}
await fetchSchedule(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 });
toastError({
title: 'Pause/Unpause Error',
msg: err instanceof Error ? err.message : 'Operation failed',
});
} finally {
setPauseUnpauseLoading(false);
setIsActionLoading(false);
}
};
const handleOpenEditModal = () => {
setEditApiError(null);
setIsEditModalOpen(true);
};
const handleCloseEditModal = () => {
setIsEditModalOpen(false);
setEditApiError(null);
};
const handleKillRunningJob = async () => {
const handleKill = async () => {
if (!scheduleId) return;
setKillJobLoading(true);
setIsActionLoading(true);
try {
const result = await killRunningJob(scheduleId);
toastSuccess({
title: 'Job Killed',
msg: result.message,
});
// Mark that we explicitly killed this job
setJobWasKilled(true);
// Clear the runNowLoading state immediately when job is killed
setRunNowLoading(false);
fetchScheduleDetails(scheduleId);
toastSuccess({ title: 'Job Killed', msg: result.message });
await fetchSchedule(scheduleId);
} catch (err) {
console.error('Failed to kill running job:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to kill running job';
toastError({ title: 'Kill Job Error', msg: errorMsg });
toastError({
title: 'Kill Job Error',
msg: err instanceof Error ? err.message : 'Failed to kill job',
});
} finally {
setKillJobLoading(false);
setIsActionLoading(false);
}
};
const handleInspectRunningJob = async () => {
const handleInspect = async () => {
if (!scheduleId) return;
setInspectJobLoading(true);
setIsActionLoading(true);
try {
const result = await inspectRunningJob(scheduleId);
if (result.sessionId) {
@@ -339,130 +172,62 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
msg: `Session: ${result.sessionId}\nRunning for: ${duration}`,
});
} else {
toastSuccess({
title: 'Job Inspection',
msg: 'No detailed information available for this job',
});
toastSuccess({ title: 'Job Inspection', msg: 'No detailed information available' });
}
} catch (err) {
console.error('Failed to inspect running job:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to inspect running job';
toastError({ title: 'Inspect Job Error', msg: errorMsg });
} finally {
setInspectJobLoading(false);
}
};
const handleEditScheduleSubmit = async (cron: string) => {
if (!scheduleId) return;
setIsEditSubmitting(true);
setEditApiError(null);
try {
await updateSchedule(scheduleId, cron);
toastSuccess({
title: 'Schedule Updated',
msg: `Successfully updated schedule "${scheduleId}"`,
toastError({
title: 'Inspect Job Error',
msg: err instanceof Error ? err.message : 'Failed to inspect job',
});
fetchScheduleDetails(scheduleId);
setIsEditModalOpen(false);
} catch (err) {
console.error('Failed to update schedule:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to update schedule';
setEditApiError(errorMsg);
toastError({ title: 'Update Schedule Error', msg: errorMsg });
} finally {
setIsEditSubmitting(false);
setIsActionLoading(false);
}
};
// Optimized periodic refresh for schedule details to keep the running status up to date
useEffect(() => {
const handleModalSubmit = async (payload: NewSchedulePayload | string) => {
if (!scheduleId) return;
// Initial fetch
fetchScheduleDetails(scheduleId);
// Set up periodic refresh every 8 seconds (longer to reduce flashing)
const intervalId = setInterval(() => {
if (
scheduleId &&
!selectedSessionDetails &&
!runNowLoading &&
!pauseUnpauseLoading &&
!killJobLoading &&
!inspectJobLoading &&
!isEditSubmitting
) {
fetchScheduleDetails(scheduleId, true); // Pass true to indicate this is a refresh
}
}, 8000);
// Clean up on unmount or when scheduleId changes
return () => {
clearInterval(intervalId);
};
}, [
scheduleId,
fetchScheduleDetails,
selectedSessionDetails,
runNowLoading,
pauseUnpauseLoading,
killJobLoading,
inspectJobLoading,
isEditSubmitting,
]);
// Monitor schedule state changes and reset loading states appropriately
useEffect(() => {
if (scheduleDetails) {
// Only reset runNowLoading if we explicitly killed the job
// This prevents interfering with natural job completion
if (!scheduleDetails.currently_running && runNowLoading && jobWasKilled) {
setRunNowLoading(false);
setJobWasKilled(false); // Reset the flag
}
setIsActionLoading(true);
try {
await updateSchedule(scheduleId, payload as string);
toastSuccess({ title: 'Schedule Updated', msg: `Updated "${scheduleId}"` });
await fetchSchedule(scheduleId);
setIsModalOpen(false);
} catch (err) {
toastError({
title: 'Update Schedule Error',
msg: err instanceof Error ? err.message : 'Failed to update schedule',
});
} finally {
setIsActionLoading(false);
}
}, [scheduleDetails, runNowLoading, jobWasKilled]);
};
const loadAndShowSessionDetails = async (sessionId: string) => {
setIsLoadingSessionDetails(true);
setSessionDetailsError(null);
setSelectedSessionDetails(null);
const loadSession = async (sessionId: string) => {
setIsLoadingSession(true);
setSessionError(null);
try {
const response = await getSession<true>({
path: { session_id: sessionId },
throwOnError: true,
});
setSelectedSessionDetails(response.data);
setSelectedSession(response.data);
} catch (err) {
console.error(`Failed to load session details for ${sessionId}:`, err);
const errorMsg = err instanceof Error ? err.message : 'Failed to load session details.';
setSessionDetailsError(errorMsg);
toastError({
title: 'Failed to load session details',
msg: errorMsg,
});
const msg = err instanceof Error ? err.message : 'Failed to load session';
setSessionError(msg);
toastError({ title: 'Failed to load session', msg });
} finally {
setIsLoadingSessionDetails(false);
setIsLoadingSession(false);
}
};
const handleSessionCardClick = (sessionIdFromCard: string) => {
loadAndShowSessionDetails(sessionIdFromCard);
};
if (selectedSessionDetails) {
if (selectedSession) {
return (
<SessionHistoryView
session={selectedSessionDetails}
isLoading={isLoadingSessionDetails}
error={sessionDetailsError}
onBack={() => {
setSelectedSessionDetails(null);
setSessionDetailsError(null);
}}
onRetry={() => loadAndShowSessionDetails(selectedSessionDetails?.id)}
session={selectedSession}
isLoading={isLoadingSession}
error={sessionError}
onBack={() => setSelectedSession(null)}
onRetry={() => loadSession(selectedSession.id)}
showActionButtons={true}
/>
);
@@ -473,13 +238,21 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
<div className="h-screen w-full flex flex-col items-center justify-center bg-white dark:bg-gray-900 text-text-default p-8">
<BackButton onClick={onNavigateBack} />
<h1 className="text-2xl font-medium text-text-prominent mt-4">Schedule Not Found</h1>
<p className="text-text-subtle mt-2">
No schedule ID was provided. Please return to the schedules list and select a schedule.
</p>
<p className="text-text-subtle mt-2">No schedule ID provided. Return to schedules list.</p>
</div>
);
}
const readableCron = scheduleDetails
? (() => {
try {
return cronstrue.toString(scheduleDetails.cron);
} catch {
return scheduleDetails.cron;
}
})()
: '';
return (
<div className="h-screen w-full flex flex-col bg-background-default text-text-default">
<div className="px-8 pt-6 pb-4 border-b border-border-subtle flex-shrink-0">
@@ -494,7 +267,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
<h2 className="text-xl font-semibold text-text-prominent mb-3">Schedule Information</h2>
{isLoadingSchedule && (
<div className="flex items-center text-text-subtle">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading schedule details...
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading schedule...
</div>
)}
{scheduleError && (
@@ -502,8 +275,55 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
Error: {scheduleError}
</p>
)}
{!isLoadingSchedule && !scheduleError && scheduleDetails && (
<ScheduleInfoCard scheduleDetails={scheduleDetails} />
{scheduleDetails && (
<Card className="p-4 bg-background-card shadow mb-6">
<div className="space-y-2">
<div className="flex flex-col md:flex-row md:items-center justify-between">
<h3 className="text-base font-semibold text-text-prominent">
{scheduleDetails.id}
</h3>
<div className="mt-2 md:mt-0 flex items-center gap-2">
{scheduleDetails.currently_running && (
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
Currently Running
</div>
)}
{scheduleDetails.paused && (
<div className="text-sm text-orange-500 dark:text-orange-400 font-semibold flex items-center">
<Pause className="w-3 h-3 mr-1" />
Paused
</div>
)}
</div>
</div>
<p className="text-sm text-text-default">
<span className="font-semibold">Schedule:</span> {readableCron}
</p>
<p className="text-sm text-text-default">
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
</p>
<p className="text-sm text-text-default">
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
</p>
<p className="text-sm text-text-default">
<span className="font-semibold">Last Run:</span>{' '}
{formatToLocalDateWithTimezone(scheduleDetails.last_run)}
</p>
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
<p className="text-sm text-text-default">
<span className="font-semibold">Current Session:</span>{' '}
{scheduleDetails.current_session_id}
</p>
)}
{scheduleDetails.currently_running && scheduleDetails.process_start_time && (
<p className="text-sm text-text-default">
<span className="font-semibold">Process Started:</span>{' '}
{formatToLocalDateWithTimezone(scheduleDetails.process_start_time)}
</p>
)}
</div>
</Card>
)}
</section>
@@ -512,67 +332,67 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
<div className="flex flex-col md:flex-row gap-2">
<Button
onClick={handleRunNow}
disabled={runNowLoading || scheduleDetails?.currently_running === true}
disabled={isActionLoading || scheduleDetails?.currently_running}
className="w-full md:w-auto"
>
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
Run Schedule Now
</Button>
{scheduleDetails && !scheduleDetails.currently_running && (
<>
<Button
onClick={handleOpenEditModal}
onClick={() => setIsModalOpen(true)}
variant="outline"
className="w-full md:w-auto flex items-center gap-2 text-blue-600 dark:text-blue-400 border-blue-300 dark:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20"
disabled={runNowLoading || pauseUnpauseLoading || isEditSubmitting}
disabled={isActionLoading}
>
<Edit className="w-4 h-4" />
Edit Schedule
</Button>
<Button
onClick={scheduleDetails.paused ? handleUnpauseSchedule : handlePauseSchedule}
onClick={handlePauseToggle}
variant="outline"
className={`w-full md:w-auto flex items-center gap-2 ${
scheduleDetails.paused
? 'text-green-600 dark:text-green-400 border-green-300 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'
: 'text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-600 hover:bg-orange-50 dark:hover:bg-orange-900/20'
}`}
disabled={runNowLoading || pauseUnpauseLoading || isEditSubmitting}
disabled={isActionLoading}
>
{scheduleDetails.paused ? (
<>
<Play className="w-4 h-4" />
{pauseUnpauseLoading ? 'Unpausing...' : 'Unpause Schedule'}
Unpause Schedule
</>
) : (
<>
<Pause className="w-4 h-4" />
{pauseUnpauseLoading ? 'Pausing...' : 'Pause Schedule'}
Pause Schedule
</>
)}
</Button>
</>
)}
{scheduleDetails && scheduleDetails.currently_running && (
{scheduleDetails?.currently_running && (
<>
<Button
onClick={handleInspectRunningJob}
onClick={handleInspect}
variant="outline"
className="w-full md:w-auto flex items-center gap-2 text-blue-600 dark:text-blue-400 border-blue-300 dark:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20"
disabled={inspectJobLoading}
disabled={isActionLoading}
>
<Eye className="w-4 h-4" />
{inspectJobLoading ? 'Inspecting...' : 'Inspect Running Job'}
Inspect Running Job
</Button>
<Button
onClick={handleKillRunningJob}
onClick={handleKill}
variant="outline"
className="w-full md:w-auto flex items-center gap-2 text-red-600 dark:text-red-400 border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
disabled={killJobLoading}
disabled={isActionLoading}
>
<Square className="w-4 h-4" />
{killJobLoading ? 'Killing...' : 'Kill Running Job'}
Kill Running Job
</Button>
</>
)}
@@ -593,41 +413,32 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
</section>
<section>
<h2 className="text-xl font-semibold text-text-prominent mb-4">
Recent Sessions for this Schedule
</h2>
<h2 className="text-xl font-semibold text-text-prominent mb-4">Recent Sessions</h2>
{isLoadingSessions && <p className="text-text-subtle">Loading sessions...</p>}
{sessionsError && (
<p className="text-text-error text-sm p-3 bg-background-error border border-border-error rounded-md">
Error: {sessionsError}
</p>
)}
{!isLoadingSessions && !sessionsError && sessions.length === 0 && (
{!isLoadingSessions && sessions.length === 0 && (
<p className="text-text-subtle text-center py-4">
No sessions found for this schedule.
</p>
)}
{!isLoadingSessions && sessions.length > 0 && (
{sessions.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sessions.map((session) => (
<Card
key={session.id}
className="p-4 bg-background-card shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
onClick={() => handleSessionCardClick(session.id)}
role="button"
tabIndex={0}
onKeyPress={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleSessionCardClick(session.id);
}
}}
onClick={() => loadSession(session.id)}
>
<h3
className="text-sm font-semibold text-text-prominent truncate"
title={session.name || session.id}
>
{session.name || `Session ID: ${session.id}`}{' '}
{session.name || `Session ID: ${session.id}`}
</h3>
<p className="text-xs text-text-subtle mt-1">
Created:{' '}
@@ -662,13 +473,15 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
</section>
</div>
</ScrollArea>
<EditScheduleModal
isOpen={isEditModalOpen}
onClose={handleCloseEditModal}
onSubmit={handleEditScheduleSubmit}
<ScheduleModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleModalSubmit}
schedule={scheduleDetails}
isLoadingExternally={isEditSubmitting}
apiErrorExternally={editApiError}
isLoadingExternally={isActionLoading}
apiErrorExternally={null}
initialDeepLink={null}
/>
</div>
);
@@ -0,0 +1,502 @@
import React, { useState, useEffect, FormEvent, useCallback } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { ScheduledJob } from '../../schedule';
import { CronPicker } from './CronPicker';
import { Recipe, decodeRecipe } from '../../recipe';
import { getStorageDirectory } from '../../recipe/recipe_management';
import ClockIcon from '../../assets/clock-icon.svg';
import * as yaml from 'yaml';
export interface NewSchedulePayload {
id: string;
recipe_source: string;
cron: string;
execution_mode?: string;
}
interface ScheduleModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (payload: NewSchedulePayload | string) => Promise<void>;
schedule: ScheduledJob | null;
isLoadingExternally: boolean;
apiErrorExternally: string | null;
initialDeepLink: string | null;
}
type SourceType = 'file' | 'deeplink';
interface CleanExtension {
name: string;
type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http';
cmd?: string;
args?: string[];
uri?: string;
display_name?: string;
tools?: unknown[];
instructions?: string;
env_keys?: string[];
timeout?: number;
description?: string;
bundled?: boolean;
}
interface CleanRecipe {
title: string;
description: string;
instructions?: string;
prompt?: string;
activities?: string[];
extensions?: CleanExtension[];
author?: {
contact?: string;
metadata?: string;
};
schedule?: {
window_title?: string;
working_directory?: string;
};
}
async function parseDeepLink(deepLink: string): Promise<Recipe | null> {
try {
const url = new URL(deepLink);
if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) {
return null;
}
const recipeParam = url.searchParams.get('config');
if (!recipeParam) {
return null;
}
return await decodeRecipe(recipeParam);
} catch (error) {
console.error('Failed to parse deep link:', error);
return null;
}
}
function recipeToYaml(recipe: Recipe): string {
const cleanRecipe: CleanRecipe = {
title: recipe.title,
description: recipe.description,
};
if (recipe.instructions) {
cleanRecipe.instructions = recipe.instructions;
}
if (recipe.prompt) {
cleanRecipe.prompt = recipe.prompt;
}
if (recipe.activities && recipe.activities.length > 0) {
cleanRecipe.activities = recipe.activities;
}
if (recipe.extensions && recipe.extensions.length > 0) {
cleanRecipe.extensions = recipe.extensions.map((ext) => {
const cleanExt: CleanExtension = {
name: ext.name,
type: 'builtin',
};
if ('type' in ext && ext.type) {
cleanExt.type = ext.type as CleanExtension['type'];
const extAny = ext as Record<string, unknown>;
if (ext.type === 'sse' && extAny.uri) {
cleanExt.uri = extAny.uri as string;
} else if (ext.type === 'streamable_http' && extAny.uri) {
cleanExt.uri = extAny.uri as string;
} else if (ext.type === 'stdio') {
if (extAny.cmd) {
cleanExt.cmd = extAny.cmd as string;
}
if (extAny.args) {
cleanExt.args = extAny.args as string[];
}
} else if (ext.type === 'builtin' && extAny.display_name) {
cleanExt.display_name = extAny.display_name as string;
}
if ((ext.type as string) === 'frontend') {
if (extAny.tools) {
cleanExt.tools = extAny.tools as unknown[];
}
if (extAny.instructions) {
cleanExt.instructions = extAny.instructions as string;
}
}
} else {
const extAny = ext as Record<string, unknown>;
if (extAny.cmd) {
cleanExt.type = 'stdio';
cleanExt.cmd = extAny.cmd as string;
if (extAny.args) {
cleanExt.args = extAny.args as string[];
}
} else if (extAny.command) {
cleanExt.type = 'stdio';
cleanExt.cmd = extAny.command as string;
} else if (extAny.uri) {
cleanExt.type = 'streamable_http';
cleanExt.uri = extAny.uri as string;
} else if (extAny.tools) {
cleanExt.type = 'frontend';
cleanExt.tools = extAny.tools as unknown[];
if (extAny.instructions) {
cleanExt.instructions = extAny.instructions as string;
}
} else {
cleanExt.type = 'builtin';
}
}
if ('env_keys' in ext && ext.env_keys && ext.env_keys.length > 0) {
cleanExt.env_keys = ext.env_keys;
}
if ('timeout' in ext && ext.timeout) {
cleanExt.timeout = ext.timeout as number;
}
if ('description' in ext && ext.description) {
cleanExt.description = ext.description as string;
}
if ('bundled' in ext && ext.bundled !== undefined) {
cleanExt.bundled = ext.bundled as boolean;
}
return cleanExt;
});
}
if (recipe.author) {
cleanRecipe.author = {
contact: recipe.author.contact || undefined,
metadata: recipe.author.metadata || undefined,
};
}
cleanRecipe.schedule = {
window_title: `${recipe.title} - Scheduled`,
};
return yaml.stringify(cleanRecipe);
}
const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1';
export const ScheduleModal: React.FC<ScheduleModalProps> = ({
isOpen,
onClose,
onSubmit,
schedule,
isLoadingExternally,
apiErrorExternally,
initialDeepLink,
}) => {
const isEditMode = !!schedule;
const [scheduleId, setScheduleId] = useState<string>('');
const [sourceType, setSourceType] = useState<SourceType>('file');
const [recipeSourcePath, setRecipeSourcePath] = useState<string>('');
const [deepLinkInput, setDeepLinkInput] = useState<string>('');
const [parsedRecipe, setParsedRecipe] = useState<Recipe | null>(null);
const [cronExpression, setCronExpression] = useState<string>('0 0 14 * * *');
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
const handleDeepLinkChange = useCallback(async (value: string) => {
setDeepLinkInput(value);
setInternalValidationError(null);
if (value.trim()) {
try {
const recipe = await parseDeepLink(value.trim());
if (recipe) {
setParsedRecipe(recipe);
if (recipe.title) {
const cleanId = recipe.title
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-');
setScheduleId(cleanId);
}
} else {
setParsedRecipe(null);
setInternalValidationError(
'Invalid deep link format. Please use a goose://bot or goose://recipe link.'
);
}
} catch {
setParsedRecipe(null);
setInternalValidationError(
'Failed to parse deep link. Please ensure using a goose://bot or goose://recipe link and try again.'
);
}
} else {
setParsedRecipe(null);
}
}, []);
useEffect(() => {
if (isOpen) {
if (schedule) {
setScheduleId(schedule.id);
setCronExpression(schedule.cron);
} else {
setScheduleId('');
setSourceType('file');
setRecipeSourcePath('');
setDeepLinkInput('');
setParsedRecipe(null);
setCronExpression('0 0 14 * * *');
setInternalValidationError(null);
if (initialDeepLink) {
setSourceType('deeplink');
handleDeepLinkChange(initialDeepLink);
}
}
}
}, [isOpen, schedule, initialDeepLink, handleDeepLinkChange]);
const handleBrowseFile = async () => {
const defaultPath = getStorageDirectory(true);
const filePath = await window.electron.selectFileOrDirectory(defaultPath);
if (filePath) {
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
setRecipeSourcePath(filePath);
setInternalValidationError(null);
} else {
setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)');
}
}
};
const handleLocalSubmit = async (event: FormEvent) => {
event.preventDefault();
setInternalValidationError(null);
if (isEditMode) {
await onSubmit(cronExpression);
return;
}
if (!scheduleId.trim()) {
setInternalValidationError('Schedule ID is required.');
return;
}
let finalRecipeSource = '';
if (sourceType === 'file') {
if (!recipeSourcePath) {
setInternalValidationError('Recipe source file is required.');
return;
}
finalRecipeSource = recipeSourcePath;
} else if (sourceType === 'deeplink') {
if (!deepLinkInput.trim()) {
setInternalValidationError('Deep link is required.');
return;
}
if (!parsedRecipe) {
setInternalValidationError('Invalid deep link. Please check the format.');
return;
}
try {
const yamlContent = recipeToYaml(parsedRecipe);
const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`;
const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.';
const tempFilePath = `${tempDir}/${tempFileName}`;
const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent);
if (!writeSuccess) {
setInternalValidationError('Failed to create temporary recipe file.');
return;
}
finalRecipeSource = tempFilePath;
} catch (error) {
console.error('Failed to convert recipe to YAML:', error);
setInternalValidationError('Failed to process the recipe from deep link.');
return;
}
}
const newSchedulePayload: NewSchedulePayload = {
id: scheduleId.trim(),
recipe_source: finalRecipeSource,
cron: cronExpression,
};
await onSubmit(newSchedulePayload);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-background-default shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden">
<div className="px-8 pt-6 pb-4 flex-shrink-0">
<div className="flex items-center gap-3">
<img src={ClockIcon} alt="Clock" className="w-8 h-8" />
<div className="flex-1">
<h2 className="text-base font-semibold text-text-prominent">
{isEditMode ? 'Edit Schedule' : 'Create New Schedule'}
</h2>
{isEditMode && <p className="text-sm text-text-subtle">{schedule.id}</p>}
</div>
</div>
</div>
<form
id="schedule-form"
onSubmit={handleLocalSubmit}
className="px-8 py-4 space-y-4 flex-grow overflow-y-auto"
>
{apiErrorExternally && (
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
{apiErrorExternally}
</p>
)}
{internalValidationError && (
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
{internalValidationError}
</p>
)}
{!isEditMode && (
<>
<div>
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
Name:
</label>
<Input
type="text"
id="scheduleId-modal"
value={scheduleId}
onChange={(e) => setScheduleId(e.target.value)}
placeholder="e.g., daily-summary-job"
required
/>
</div>
<div>
<label className={modalLabelClassName}>Source:</label>
<div className="space-y-2">
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-full p-1">
<button
type="button"
onClick={() => setSourceType('file')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
sourceType === 'file'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
YAML
</button>
<button
type="button"
onClick={() => setSourceType('deeplink')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
sourceType === 'deeplink'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
Deep link
</button>
</div>
{sourceType === 'file' && (
<div>
<Button
type="button"
variant="outline"
onClick={handleBrowseFile}
className="w-full justify-center rounded-full"
>
Browse for YAML file...
</Button>
{recipeSourcePath && (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">
Selected: {recipeSourcePath}
</p>
)}
</div>
)}
{sourceType === 'deeplink' && (
<div>
<Input
type="text"
value={deepLinkInput}
onChange={(e) => handleDeepLinkChange(e.target.value)}
placeholder="Paste goose://bot or goose://recipe link here..."
className="rounded-full"
/>
{parsedRecipe && (
<div className="mt-2 p-2 bg-green-100 dark:bg-green-900/30 rounded-md border border-green-500/50">
<p className="text-xs text-green-700 dark:text-green-300 font-medium">
Recipe parsed successfully
</p>
<p className="text-xs text-green-600 dark:text-green-400">
Title: {parsedRecipe.title}
</p>
<p className="text-xs text-green-600 dark:text-green-400">
Description: {parsedRecipe.description}
</p>
</div>
)}
</div>
)}
</div>
</div>
</>
)}
<div>
<label className={modalLabelClassName}>Schedule:</label>
<CronPicker schedule={schedule} onChange={setCronExpression} />
</div>
</form>
<div className="flex gap-2 px-8 py-4 border-t border-border-subtle">
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={isLoadingExternally}
className="flex-1 text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
>
Cancel
</Button>
<Button
type="submit"
form="schedule-form"
disabled={isLoadingExternally}
className="flex-1"
>
{isLoadingExternally
? isEditMode
? 'Updating...'
: 'Creating...'
: isEditMode
? 'Update Schedule'
: 'Create Schedule'}
</Button>
</div>
</Card>
</div>
);
};
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import {
listSchedules,
@@ -16,8 +16,7 @@ import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { TrashIcon } from '../icons/TrashIcon';
import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye, CircleDotDashed } from 'lucide-react';
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
import { EditScheduleModal } from './EditScheduleModal';
import { NewSchedulePayload, ScheduleModal } from './ScheduleModal';
import ScheduleDetailView from './ScheduleDetailView';
import { toastError, toastSuccess } from '../../toasts';
import cronstrue from 'cronstrue';
@@ -29,8 +28,7 @@ interface SchedulesViewProps {
onClose?: () => void;
}
// Memoized ScheduleCard component to prevent unnecessary re-renders
const ScheduleCard = React.memo<{
const ScheduleCard: React.FC<{
job: ScheduledJob;
onNavigateToDetail: (id: string) => void;
onEdit: (job: ScheduledJob) => void;
@@ -39,177 +37,150 @@ const ScheduleCard = React.memo<{
onKill: (id: string) => void;
onInspect: (id: string) => void;
onDelete: (id: string) => void;
isPausing: boolean;
isDeleting: boolean;
isKilling: boolean;
isInspecting: boolean;
isSubmitting: boolean;
}>(
({
job,
onNavigateToDetail,
onEdit,
onPause,
onUnpause,
onKill,
onInspect,
onDelete,
isPausing,
isDeleting,
isKilling,
isInspecting,
isSubmitting,
}) => {
const readableCron = useMemo(() => {
try {
return cronstrue.toString(job.cron);
} catch (e) {
console.warn(`Could not parse cron string "${job.cron}":`, e);
return job.cron;
}
}, [job.cron]);
actionInProgress: boolean;
}> = ({
job,
onNavigateToDetail,
onEdit,
onPause,
onUnpause,
onKill,
onInspect,
onDelete,
actionInProgress,
}) => {
let readableCron: string;
try {
readableCron = cronstrue.toString(job.cron);
} catch {
readableCron = job.cron;
}
const formattedLastRun = useMemo(() => {
return formatToLocalDateWithTimezone(job.last_run);
}, [job.last_run]);
const formattedLastRun = formatToLocalDateWithTimezone(job.last_run);
return (
<Card
className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150"
onClick={() => onNavigateToDetail(job.id)}
>
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base truncate max-w-[50vw]" title={job.id}>
{job.id}
</h3>
{job.execution_mode && (
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
job.execution_mode === 'foreground'
? 'bg-background-accent text-text-on-accent'
: 'bg-background-medium text-text-default'
}`}
>
{job.execution_mode === 'foreground' ? '🖥️' : '⚡'}
</span>
)}
{job.currently_running && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Running
</span>
)}
{job.paused && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
<Pause className="w-3 h-3 mr-1" />
Paused
</span>
)}
</div>
<p className="text-text-muted text-sm mb-2 line-clamp-2" title={readableCron}>
{readableCron}
</p>
<div className="flex items-center text-xs text-text-muted">
<span>Last run: {formattedLastRun}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{!job.currently_running && (
<>
<Button
onClick={(e) => {
e.stopPropagation();
onEdit(job);
}}
disabled={isPausing || isDeleting || isSubmitting}
variant="outline"
size="sm"
className="h-8"
>
<Edit className="w-4 h-4 mr-1" />
Edit
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
if (job.paused) {
onUnpause(job.id);
} else {
onPause(job.id);
}
}}
disabled={isPausing || isDeleting}
variant="outline"
size="sm"
className="h-8"
>
{job.paused ? (
<>
<Play className="w-4 h-4 mr-1" />
Resume
</>
) : (
<>
<Pause className="w-4 h-4 mr-1" />
Pause
</>
)}
</Button>
</>
)}
return (
<Card
className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150"
onClick={() => onNavigateToDetail(job.id)}
>
<div className="flex justify-between items-start gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base truncate max-w-[50vw]" title={job.id}>
{job.id}
</h3>
{job.currently_running && (
<>
<Button
onClick={(e) => {
e.stopPropagation();
onInspect(job.id);
}}
disabled={isInspecting || isKilling}
variant="outline"
size="sm"
className="h-8"
>
<Eye className="w-4 h-4 mr-1" />
Inspect
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
onKill(job.id);
}}
disabled={isKilling || isInspecting}
variant="outline"
size="sm"
className="h-8"
>
<Square className="w-4 h-4 mr-1" />
Kill
</Button>
</>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
Running
</span>
)}
<Button
onClick={(e) => {
e.stopPropagation();
onDelete(job.id);
}}
disabled={isPausing || isDeleting || isKilling || isInspecting}
variant="ghost"
size="sm"
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<TrashIcon className="w-4 h-4" />
</Button>
{job.paused && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
<Pause className="w-3 h-3 mr-1" />
Paused
</span>
)}
</div>
<p className="text-text-muted text-sm mb-2 line-clamp-2" title={readableCron}>
{readableCron}
</p>
<div className="flex items-center text-xs text-text-muted">
<span>Last run: {formattedLastRun}</span>
</div>
</div>
</Card>
);
}
);
ScheduleCard.displayName = 'ScheduleCard';
<div className="flex items-center gap-2 shrink-0">
{!job.currently_running && (
<>
<Button
onClick={(e) => {
e.stopPropagation();
onEdit(job);
}}
disabled={actionInProgress}
variant="outline"
size="sm"
className="h-8"
>
<Edit className="w-4 h-4 mr-1" />
Edit
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
if (job.paused) {
onUnpause(job.id);
} else {
onPause(job.id);
}
}}
disabled={actionInProgress}
variant="outline"
size="sm"
className="h-8"
>
{job.paused ? (
<>
<Play className="w-4 h-4 mr-1" />
Resume
</>
) : (
<>
<Pause className="w-4 h-4 mr-1" />
Pause
</>
)}
</Button>
</>
)}
{job.currently_running && (
<>
<Button
onClick={(e) => {
e.stopPropagation();
onInspect(job.id);
}}
disabled={actionInProgress}
variant="outline"
size="sm"
className="h-8"
>
<Eye className="w-4 h-4 mr-1" />
Inspect
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
onKill(job.id);
}}
disabled={actionInProgress}
variant="outline"
size="sm"
className="h-8"
>
<Square className="w-4 h-4 mr-1" />
Kill
</Button>
</>
)}
<Button
onClick={(e) => {
e.stopPropagation();
onDelete(job.id);
}}
disabled={actionInProgress}
variant="ghost"
size="sm"
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
};
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
const location = useLocation();
@@ -218,33 +189,19 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const [submitApiError, setSubmitApiError] = useState<string | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<ScheduledJob | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [pendingDeepLink, setPendingDeepLink] = useState<string | null>(null);
// Individual loading states for each action to prevent double-clicks
const [pausingScheduleIds, setPausingScheduleIds] = useState<Set<string>>(new Set());
const [deletingScheduleIds, setDeletingScheduleIds] = useState<Set<string>>(new Set());
const [killingScheduleIds, setKillingScheduleIds] = useState<Set<string>>(new Set());
const [inspectingScheduleIds, setInspectingScheduleIds] = useState<Set<string>>(new Set());
const [actionsInProgress, setActionsInProgress] = useState<Set<string>>(new Set());
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
// Memoized fetch function to prevent unnecessary re-creation
const fetchSchedules = useCallback(async (isRefresh = false) => {
if (!isRefresh) setIsLoading(true);
const fetchSchedules = async () => {
setIsLoading(true);
setApiError(null);
try {
const fetchedSchedules = await listSchedules();
setSchedules((prevSchedules) => {
// Only update if schedules actually changed to prevent unnecessary re-renders
if (JSON.stringify(prevSchedules) !== JSON.stringify(fetchedSchedules)) {
return fetchedSchedules;
}
return prevSchedules;
});
setSchedules(fetchedSchedules);
} catch (error) {
console.error('Failed to fetch schedules:', error);
setApiError(
@@ -253,266 +210,181 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
: 'An unknown error occurred while fetching schedules.'
);
} finally {
if (!isRefresh) setIsLoading(false);
setIsLoading(false);
}
}, []);
};
useEffect(() => {
if (viewingScheduleId === null) {
fetchSchedules();
// Check for pending deep link from navigation state
const locationState = location.state as ViewOptions | null;
if (locationState?.pendingScheduleDeepLink) {
setPendingDeepLink(locationState.pendingScheduleDeepLink);
setIsCreateModalOpen(true);
// Clear the state after reading it
setIsModalOpen(true);
window.history.replaceState({}, document.title);
}
}
}, [viewingScheduleId, fetchSchedules, location.state]);
}, [viewingScheduleId, location.state]);
// Optimized periodic refresh - only refresh if not actively doing something
useEffect(() => {
if (viewingScheduleId !== null) return;
if (viewingScheduleId !== null || actionsInProgress.size > 0) return;
// Set up periodic refresh every 15 seconds (increased from 8 to reduce flashing)
const intervalId = setInterval(() => {
if (
viewingScheduleId === null &&
!isRefreshing &&
!isLoading &&
!isSubmitting &&
pausingScheduleIds.size === 0 &&
deletingScheduleIds.size === 0 &&
killingScheduleIds.size === 0 &&
inspectingScheduleIds.size === 0
) {
fetchSchedules(true); // Pass true to indicate this is a refresh
if (viewingScheduleId === null && !isRefreshing && !isLoading && !isSubmitting) {
fetchSchedules();
}
}, 15000); // Increased from 8000 to 15000 (15 seconds)
}, 15000);
// Clean up on unmount
return () => {
clearInterval(intervalId);
};
}, [
viewingScheduleId,
isRefreshing,
isLoading,
isSubmitting,
pausingScheduleIds.size,
deletingScheduleIds.size,
killingScheduleIds.size,
inspectingScheduleIds.size,
fetchSchedules,
]);
return () => clearInterval(intervalId);
}, [viewingScheduleId, isRefreshing, isLoading, isSubmitting, actionsInProgress.size]);
const handleOpenCreateModal = () => {
setSubmitApiError(null);
setIsCreateModalOpen(true);
};
const handleRefresh = useCallback(async () => {
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await fetchSchedules();
} finally {
setIsRefreshing(false);
}
}, [fetchSchedules]);
const handleCloseCreateModal = () => {
setIsCreateModalOpen(false);
setSubmitApiError(null);
setPendingDeepLink(null);
};
const handleOpenEditModal = (schedule: ScheduledJob) => {
setEditingSchedule(schedule);
setSubmitApiError(null);
setIsEditModalOpen(true);
};
const handleCloseEditModal = () => {
setIsEditModalOpen(false);
setEditingSchedule(null);
setSubmitApiError(null);
};
const handleCreateScheduleSubmit = async (payload: NewSchedulePayload) => {
const handleModalSubmit = async (payload: NewSchedulePayload | string) => {
setIsSubmitting(true);
setSubmitApiError(null);
try {
await createSchedule(payload);
if (editingSchedule) {
await updateSchedule(editingSchedule.id, payload as string);
toastSuccess({
title: 'Schedule Updated',
msg: `Successfully updated schedule "${editingSchedule.id}"`,
});
} else {
await createSchedule(payload as NewSchedulePayload);
}
await fetchSchedules();
setIsCreateModalOpen(false);
} catch (error) {
console.error('Failed to create schedule:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error creating schedule.';
setSubmitApiError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleEditScheduleSubmit = async (cron: string) => {
if (!editingSchedule) return;
setIsSubmitting(true);
setSubmitApiError(null);
try {
await updateSchedule(editingSchedule.id, cron);
toastSuccess({
title: 'Schedule Updated',
msg: `Successfully updated schedule "${editingSchedule.id}"`,
});
await fetchSchedules();
setIsEditModalOpen(false);
setIsModalOpen(false);
setEditingSchedule(null);
} catch (error) {
console.error('Failed to update schedule:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error updating schedule.';
setSubmitApiError(errorMessage);
toastError({
title: 'Update Schedule Error',
msg: errorMessage,
});
console.error('Failed to save schedule:', error);
setSubmitApiError(error instanceof Error ? error.message : 'Unknown error saving schedule.');
} finally {
setIsSubmitting(false);
}
};
const handleDeleteSchedule = async (idToDelete: string) => {
if (!window.confirm(`Are you sure you want to delete schedule "${idToDelete}"?`)) return;
const handleDeleteSchedule = async (id: string) => {
if (!window.confirm(`Are you sure you want to delete schedule "${id}"?`)) return;
// Immediately add to deleting set to disable button
setDeletingScheduleIds((prev) => new Set(prev).add(idToDelete));
if (viewingScheduleId === idToDelete) {
setViewingScheduleId(null);
}
setActionsInProgress((prev) => new Set(prev).add(id));
if (viewingScheduleId === id) setViewingScheduleId(null);
setApiError(null);
try {
await deleteSchedule(idToDelete);
await deleteSchedule(id);
await fetchSchedules();
} catch (error) {
console.error(`Failed to delete schedule "${idToDelete}":`, error);
setApiError(
error instanceof Error ? error.message : `Unknown error deleting "${idToDelete}".`
);
console.error(`Failed to delete schedule "${id}":`, error);
setApiError(error instanceof Error ? error.message : `Unknown error deleting "${id}".`);
} finally {
// Remove from deleting set
setDeletingScheduleIds((prev) => {
setActionsInProgress((prev) => {
const newSet = new Set(prev);
newSet.delete(idToDelete);
newSet.delete(id);
return newSet;
});
}
};
const handlePauseSchedule = async (idToPause: string) => {
// Immediately add to pausing set to disable button
setPausingScheduleIds((prev) => new Set(prev).add(idToPause));
const handlePauseSchedule = async (id: string) => {
setActionsInProgress((prev) => new Set(prev).add(id));
setApiError(null);
try {
await pauseSchedule(idToPause);
await pauseSchedule(id);
toastSuccess({
title: 'Schedule Paused',
msg: `Successfully paused schedule "${idToPause}"`,
msg: `Successfully paused schedule "${id}"`,
});
await fetchSchedules();
} catch (error) {
console.error(`Failed to pause schedule "${idToPause}":`, error);
const errorMsg =
error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`;
console.error(`Failed to pause schedule "${id}":`, error);
const errorMsg = error instanceof Error ? error.message : `Unknown error pausing "${id}".`;
setApiError(errorMsg);
toastError({
title: 'Pause Schedule Error',
msg: errorMsg,
});
} finally {
// Remove from pausing set
setPausingScheduleIds((prev) => {
setActionsInProgress((prev) => {
const newSet = new Set(prev);
newSet.delete(idToPause);
newSet.delete(id);
return newSet;
});
}
};
const handleUnpauseSchedule = async (idToUnpause: string) => {
// Immediately add to pausing set to disable button
setPausingScheduleIds((prev) => new Set(prev).add(idToUnpause));
const handleUnpauseSchedule = async (id: string) => {
setActionsInProgress((prev) => new Set(prev).add(id));
setApiError(null);
try {
await unpauseSchedule(idToUnpause);
await unpauseSchedule(id);
toastSuccess({
title: 'Schedule Unpaused',
msg: `Successfully unpaused schedule "${idToUnpause}"`,
msg: `Successfully unpaused schedule "${id}"`,
});
await fetchSchedules();
} catch (error) {
console.error(`Failed to unpause schedule "${idToUnpause}":`, error);
const errorMsg =
error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`;
console.error(`Failed to unpause schedule "${id}":`, error);
const errorMsg = error instanceof Error ? error.message : `Unknown error unpausing "${id}".`;
setApiError(errorMsg);
toastError({
title: 'Unpause Schedule Error',
msg: errorMsg,
});
} finally {
// Remove from pausing set
setPausingScheduleIds((prev) => {
setActionsInProgress((prev) => {
const newSet = new Set(prev);
newSet.delete(idToUnpause);
newSet.delete(id);
return newSet;
});
}
};
const handleKillRunningJob = async (scheduleId: string) => {
// Immediately add to killing set to disable button
setKillingScheduleIds((prev) => new Set(prev).add(scheduleId));
const handleKillRunningJob = async (id: string) => {
setActionsInProgress((prev) => new Set(prev).add(id));
setApiError(null);
try {
const result = await killRunningJob(scheduleId);
const result = await killRunningJob(id);
toastSuccess({
title: 'Job Killed',
msg: result.message,
});
await fetchSchedules();
} catch (error) {
console.error(`Failed to kill running job "${scheduleId}":`, error);
console.error(`Failed to kill running job "${id}":`, error);
const errorMsg =
error instanceof Error ? error.message : `Unknown error killing job "${scheduleId}".`;
error instanceof Error ? error.message : `Unknown error killing job "${id}".`;
setApiError(errorMsg);
toastError({
title: 'Kill Job Error',
msg: errorMsg,
});
} finally {
// Remove from killing set
setKillingScheduleIds((prev) => {
setActionsInProgress((prev) => {
const newSet = new Set(prev);
newSet.delete(scheduleId);
newSet.delete(id);
return newSet;
});
}
};
const handleInspectRunningJob = async (scheduleId: string) => {
// Immediately add to inspecting set to disable button
setInspectingScheduleIds((prev) => new Set(prev).add(scheduleId));
const handleInspectRunningJob = async (id: string) => {
setActionsInProgress((prev) => new Set(prev).add(id));
setApiError(null);
try {
const result = await inspectRunningJob(scheduleId);
const result = await inspectRunningJob(id);
if (result.sessionId) {
const duration = result.runningDurationSeconds
? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s`
@@ -528,37 +400,28 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
});
}
} catch (error) {
console.error(`Failed to inspect running job "${scheduleId}":`, error);
console.error(`Failed to inspect running job "${id}":`, error);
const errorMsg =
error instanceof Error ? error.message : `Unknown error inspecting job "${scheduleId}".`;
error instanceof Error ? error.message : `Unknown error inspecting job "${id}".`;
setApiError(errorMsg);
toastError({
title: 'Inspect Job Error',
msg: errorMsg,
});
} finally {
// Remove from inspecting set
setInspectingScheduleIds((prev) => {
setActionsInProgress((prev) => {
const newSet = new Set(prev);
newSet.delete(scheduleId);
newSet.delete(id);
return newSet;
});
}
};
const handleNavigateToScheduleDetail = (scheduleId: string) => {
setViewingScheduleId(scheduleId);
};
const handleNavigateBackFromDetail = () => {
setViewingScheduleId(null);
};
if (viewingScheduleId) {
return (
<ScheduleDetailView
scheduleId={viewingScheduleId}
onNavigateBack={handleNavigateBackFromDetail}
onNavigateBack={() => setViewingScheduleId(null)}
/>
);
}
@@ -583,7 +446,10 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
<Button
onClick={handleOpenCreateModal}
onClick={() => {
setSubmitApiError(null);
setIsModalOpen(true);
}}
size="sm"
className="flex items-center gap-2"
>
@@ -626,18 +492,18 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
<ScheduleCard
key={job.id}
job={job}
onNavigateToDetail={handleNavigateToScheduleDetail}
onEdit={handleOpenEditModal}
onNavigateToDetail={setViewingScheduleId}
onEdit={(schedule) => {
setEditingSchedule(schedule);
setSubmitApiError(null);
setIsModalOpen(true);
}}
onPause={handlePauseSchedule}
onUnpause={handleUnpauseSchedule}
onKill={handleKillRunningJob}
onInspect={handleInspectRunningJob}
onDelete={handleDeleteSchedule}
isPausing={pausingScheduleIds.has(job.id)}
isDeleting={deletingScheduleIds.has(job.id)}
isKilling={killingScheduleIds.has(job.id)}
isInspecting={inspectingScheduleIds.has(job.id)}
isSubmitting={isSubmitting}
actionInProgress={actionsInProgress.has(job.id) || isSubmitting}
/>
))}
</div>
@@ -648,21 +514,19 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
</div>
</MainPanelLayout>
<CreateScheduleModal
isOpen={isCreateModalOpen}
onClose={handleCloseCreateModal}
onSubmit={handleCreateScheduleSubmit}
isLoadingExternally={isSubmitting}
apiErrorExternally={submitApiError}
initialDeepLink={pendingDeepLink}
/>
<EditScheduleModal
isOpen={isEditModalOpen}
onClose={handleCloseEditModal}
onSubmit={handleEditScheduleSubmit}
<ScheduleModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setEditingSchedule(null);
setSubmitApiError(null);
setPendingDeepLink(null);
}}
onSubmit={handleModalSubmit}
schedule={editingSchedule}
isLoadingExternally={isSubmitting}
apiErrorExternally={submitApiError}
initialDeepLink={pendingDeepLink}
/>
</>
);
+9 -17
View File
@@ -9,6 +9,7 @@ import {
runNowHandler as apiRunScheduleNow,
killRunningJob as apiKillRunningJob,
inspectRunningJob as apiInspectRunningJob,
SessionDisplayInfo,
} from './api';
export interface ScheduledJob {
@@ -20,7 +21,6 @@ export interface ScheduledJob {
paused?: boolean;
current_session_id?: string | null;
process_start_time?: string | null;
execution_mode?: string | null; // "foreground" or "background"
}
export interface ScheduleSession {
@@ -82,23 +82,15 @@ export async function deleteSchedule(id: string): Promise<void> {
export async function getScheduleSessions(
scheduleId: string,
limit?: number
): Promise<ScheduleSession[]> {
try {
const response = await apiGetScheduleSessions<true>({
path: { id: scheduleId },
query: { limit },
});
limit: number
): Promise<Array<SessionDisplayInfo>> {
const response = await apiGetScheduleSessions<true>({
path: { id: scheduleId },
query: { limit },
throwOnError: true,
});
if (response && response.data) {
return response.data as ScheduleSession[];
}
console.error('Unexpected response format from apiGetScheduleSessions', response);
throw new Error('Failed to get schedule sessions: Unexpected response format');
} catch (error) {
console.error(`Error fetching sessions for schedule ${scheduleId}:`, error);
throw error;
}
return response.data;
}
export async function runScheduleNow(scheduleId: string): Promise<string> {