From f49d461ea9077eba7f6a2d2018f117ae6998c195 Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 3 Jun 2026 23:06:31 +0800 Subject: [PATCH] feat: unify scheduled job management across sessions and workspaces - add scheduled job entry points to both session and workspace menus in the frontend - replace the assistant-only schedule view with a shared scheduled jobs management surface - support workspace-targeted scheduled jobs by creating a new session before dispatching the scheduled prompt - migrate cron job persistence to v2 with legacy compatibility and safer recovery handling --- src/apps/desktop/src/api/cron_api.rs | 20 +- src/apps/desktop/src/lib.rs | 11 +- .../src/agentic/coordination/coordinator.rs | 2 + .../tools/implementations/cron_tool.rs | 35 +- src/crates/core/src/service/cron/mod.rs | 5 +- src/crates/core/src/service/cron/schedule.rs | 13 +- src/crates/core/src/service/cron/service.rs | 322 ++++- src/crates/core/src/service/cron/store.rs | 280 +++- src/crates/core/src/service/cron/types.rs | 112 +- src/crates/events/src/agentic.rs | 6 + src/crates/transport/src/adapters/tauri.rs | 4 + .../sections/sessions/SessionsSection.tsx | 35 +- .../sections/workspaces/WorkspaceItem.tsx | 65 +- .../scheduled-jobs/ScheduledJobsModal.scss | 5 + .../scheduled-jobs/ScheduledJobsModal.tsx | 68 + .../scheduled-jobs/ScheduledJobsView.scss} | 317 ++++- .../scheduled-jobs/ScheduledJobsView.tsx | 1174 +++++++++++++++++ .../scenes/my-agent/AssistantScheduleView.tsx | 636 --------- .../profile/views/AssistantConfigPage.tsx | 9 +- .../flow-chat-manager/EventHandlerModule.ts | 10 +- .../infrastructure/api/service-api/CronAPI.ts | 37 +- src/web-ui/src/locales/en-US/common.json | 22 +- src/web-ui/src/locales/zh-CN/common.json | 22 +- src/web-ui/src/locales/zh-TW/common.json | 22 +- 24 files changed, 2419 insertions(+), 813 deletions(-) create mode 100644 src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.scss create mode 100644 src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx rename src/web-ui/src/app/{scenes/my-agent/AssistantScheduleView.scss => components/scheduled-jobs/ScheduledJobsView.scss} (51%) create mode 100644 src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx delete mode 100644 src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.tsx diff --git a/src/apps/desktop/src/api/cron_api.rs b/src/apps/desktop/src/api/cron_api.rs index 4e086ba560..a02e3d03e3 100644 --- a/src/apps/desktop/src/api/cron_api.rs +++ b/src/apps/desktop/src/api/cron_api.rs @@ -1,7 +1,7 @@ //! Scheduled jobs API. use bitfun_core::service::cron::{ - get_global_cron_service, CreateCronJobRequest, CronJob, UpdateCronJobRequest, + get_global_cron_service, CreateCronJobRequest, CronJob, CronJobTargetKind, UpdateCronJobRequest, }; use log::{debug, error}; use serde::Deserialize; @@ -10,7 +10,10 @@ use serde::Deserialize; #[serde(rename_all = "camelCase")] pub struct ListCronJobsRequest { pub workspace_path: Option, + pub workspace_id: Option, + pub remote_connection_id: Option, pub session_id: Option, + pub target_kind: Option, } #[derive(Debug, Deserialize)] @@ -34,15 +37,22 @@ fn cron_service() -> Result Result, String> { debug!( - "Listing scheduled jobs: workspace_path={:?}, session_id={:?}", - request.workspace_path, request.session_id + "Listing scheduled jobs: workspace_path={:?}, workspace_id={:?}, remote_connection_id={:?}, session_id={:?}, target_kind={:?}", + request.workspace_path, + request.workspace_id, + request.remote_connection_id, + request.session_id, + request.target_kind ); let service = cron_service()?; Ok(service .list_jobs_filtered( request.workspace_path.as_deref(), + request.workspace_id.as_deref(), + request.remote_connection_id.as_deref(), request.session_id.as_deref(), + request.target_kind, ) .await) } @@ -50,8 +60,8 @@ pub async fn list_cron_jobs(request: ListCronJobsRequest) -> Result #[tauri::command] pub async fn create_cron_job(request: CreateCronJobRequest) -> Result { debug!( - "Creating scheduled job: name={}, session_id={}, workspace_path={}", - request.name, request.session_id, request.workspace_path + "Creating scheduled job: name={}, target={:?}", + request.name, request.target ); let service = cron_service()?; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index ef128c8a7c..bf9485fef5 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1371,10 +1371,13 @@ async fn init_agentic_system() -> anyhow::Result<( coordinator.set_round_injection_source(scheduler.round_injection_monitor()); coordination::set_global_scheduler(scheduler.clone()); - let cron_service = - bitfun_core::service::cron::CronService::new(path_manager.clone(), scheduler.clone()) - .await - .map_err(|e| anyhow::anyhow!("Failed to initialize cron service: {}", e))?; + let cron_service = bitfun_core::service::cron::CronService::new( + path_manager.clone(), + coordinator.clone(), + scheduler.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to initialize cron service: {}", e))?; bitfun_core::service::cron::set_global_cron_service(cron_service.clone()); let cron_subscriber = Arc::new(bitfun_core::service::cron::CronEventSubscriber::new( cron_service.clone(), diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 2886246f34..b55faf5dd4 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -1207,6 +1207,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), workspace_path: Some(workspace_path), + remote_connection_id: session.config.remote_connection_id.clone(), + remote_ssh_host: session.config.remote_ssh_host.clone(), }) .await; Ok(session) diff --git a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs index 6279e5fc1b..ec929034f3 100644 --- a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs @@ -6,8 +6,8 @@ use crate::agentic::tools::framework::{ use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; use crate::service::{ cron::{ - CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, CronSchedule, - UpdateCronJobRequest, + CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, CronJobTarget, + CronJobTargetKind, CronSchedule, CronWorkspaceRef, UpdateCronJobRequest, }, get_global_cron_service, }; @@ -544,8 +544,8 @@ impl TryFrom<&CronJob> for CronToolJobOutput { schedule: CronToolScheduleOutput::try_from(&job.schedule)?, payload: job.payload.text.clone(), enabled: job.enabled, - session_id: job.session_id.clone(), - workspace_path: job.workspace_path.clone(), + session_id: job.session_id().unwrap_or_default().to_string(), + workspace_path: job.workspace().workspace_path.clone(), created_at_ms: job.created_at_ms, config_updated_at_ms: job.config_updated_at_ms, updated_at_ms: job.updated_at_ms, @@ -975,7 +975,13 @@ Patch schema for "update": .resolve_effective_workspace_for_session(&session_id, context) .await?; let mut jobs = cron_service - .list_jobs_filtered(Some(&workspace), Some(&session_id)) + .list_jobs_filtered( + Some(&workspace), + None, + None, + Some(&session_id), + Some(CronJobTargetKind::Session), + ) .await; jobs.sort_by(|left, right| { left.created_at_ms @@ -1021,14 +1027,24 @@ Patch schema for "update": schedule: job.schedule.to_service_schedule("job.schedule")?, payload: Self::into_service_payload(job.payload), enabled: job.enabled.unwrap_or(true), - session_id: session_id.clone(), - workspace_path: workspace.clone(), + target: CronJobTarget::Session { + session_id: session_id.clone(), + workspace: CronWorkspaceRef { + workspace_id: None, + workspace_path: workspace.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }, + }, }) .await?; let serialized_job = Self::serialize_job(&created)?; let result_for_assistant = format!( "Created scheduled job '{}' ({}) for session '{}' in workspace '{}'.", - created.name, created.id, created.session_id, created.workspace_path + created.name, + created.id, + created.session_id().unwrap_or(""), + created.workspace().workspace_path ); Ok(vec![ToolResult::Result { @@ -1074,8 +1090,7 @@ Patch schema for "update": .transpose()?, payload: patch.payload.map(Self::into_service_payload), enabled: patch.enabled, - session_id: None, - workspace_path: None, + target: None, }, ) .await?; diff --git a/src/crates/core/src/service/cron/mod.rs b/src/crates/core/src/service/cron/mod.rs index 000ea19fea..f460513a9d 100644 --- a/src/crates/core/src/service/cron/mod.rs +++ b/src/crates/core/src/service/cron/mod.rs @@ -9,6 +9,7 @@ mod types; pub use service::{get_global_cron_service, set_global_cron_service, CronService}; pub use subscriber::CronEventSubscriber; pub use types::{ - CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, CronJobState, CronJobsFile, - CronSchedule, UpdateCronJobRequest, CRON_JOBS_VERSION, DEFAULT_RETRY_DELAY_MS, + CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, CronJobState, CronJobTarget, + CronJobTargetKind, CronJobsFile, CronLaunchSpec, CronSchedule, CronWorkspaceRef, + UpdateCronJobRequest, CRON_JOBS_VERSION, DEFAULT_RETRY_DELAY_MS, }; diff --git a/src/crates/core/src/service/cron/schedule.rs b/src/crates/core/src/service/cron/schedule.rs index 08ac697e52..7cd96abc31 100644 --- a/src/crates/core/src/service/cron/schedule.rs +++ b/src/crates/core/src/service/cron/schedule.rs @@ -160,7 +160,7 @@ fn normalize_cron_expr(expr: &str) -> BitFunResult { #[cfg(test)] mod tests { use super::*; - use crate::service::cron::{CronJobPayload, CronJobState}; + use crate::service::cron::{CronJobPayload, CronJobState, CronJobTarget, CronWorkspaceRef}; fn sample_job(schedule: CronSchedule) -> CronJob { CronJob { @@ -171,8 +171,15 @@ mod tests { text: "hello".to_string(), }, enabled: true, - session_id: "session_1".to_string(), - workspace_path: "E:/workspace".to_string(), + target: CronJobTarget::Session { + session_id: "session_1".to_string(), + workspace: CronWorkspaceRef { + workspace_id: None, + workspace_path: "E:/workspace".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }, + }, created_at_ms: 1_700_000_000_000, config_updated_at_ms: 1_700_000_000_000, updated_at_ms: 1_700_000_000_000, diff --git a/src/crates/core/src/service/cron/service.rs b/src/crates/core/src/service/cron/service.rs index 95fd71cc48..e242f00a5a 100644 --- a/src/crates/core/src/service/cron/service.rs +++ b/src/crates/core/src/service/cron/service.rs @@ -5,13 +5,15 @@ use super::schedule::{ }; use super::store::CronJobStore; use super::types::{ - CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, CronSchedule, - UpdateCronJobRequest, DEFAULT_RETRY_DELAY_MS, + CreateCronJobRequest, CronJob, CronJobPayload, CronJobRunStatus, CronJobTarget, + CronJobTargetKind, CronLaunchSpec, CronSchedule, CronWorkspaceRef, UpdateCronJobRequest, + DEFAULT_RETRY_DELAY_MS, }; use crate::agentic::coordination::{ - DialogQueuePriority, DialogScheduler, DialogSubmissionPolicy, DialogTriggerSource, + ConversationCoordinator, DialogQueuePriority, DialogScheduler, DialogSubmissionPolicy, + DialogTriggerSource, }; -use crate::agentic::core::{InternalReminderKind, Message}; +use crate::agentic::core::{InternalReminderKind, Message, SessionConfig}; use crate::infrastructure::PathManager; use crate::util::errors::{BitFunError, BitFunResult}; use chrono::{Local, SecondsFormat, TimeZone, Utc}; @@ -26,6 +28,7 @@ use uuid::Uuid; static GLOBAL_CRON_SERVICE: OnceLock> = OnceLock::new(); pub struct CronService { + coordinator: Arc, scheduler: Arc, store: Arc, jobs: Arc>>, @@ -37,6 +40,7 @@ pub struct CronService { impl CronService { pub async fn new( path_manager: Arc, + coordinator: Arc, scheduler: Arc, ) -> BitFunResult> { let store = Arc::new(CronJobStore::new(path_manager).await?); @@ -59,6 +63,7 @@ impl CronService { } let service = Arc::new(Self { + coordinator, scheduler, store, jobs: Arc::new(RwLock::new(jobs)), @@ -97,16 +102,24 @@ impl CronService { pub async fn list_jobs_filtered( &self, workspace_path: Option<&str>, + workspace_id: Option<&str>, + remote_connection_id: Option<&str>, session_id: Option<&str>, + target_kind: Option, ) -> Vec { let jobs = self.jobs.read().await; jobs.values() .filter(|job| { - workspace_path - .map(|workspace_path| job.workspace_path == workspace_path) + matches_workspace_filter( + job.workspace(), + workspace_path, + workspace_id, + remote_connection_id, + ) && session_id + .map(|session_id| job.session_id() == Some(session_id)) .unwrap_or(true) - && session_id - .map(|session_id| job.session_id == session_id) + && target_kind + .map(|target_kind| job.target_kind() == target_kind) .unwrap_or(true) }) .cloned() @@ -122,12 +135,8 @@ impl CronService { let mut jobs = self.jobs.write().await; let current_ms = now_ms(); let schedule = materialize_schedule(request.schedule, current_ms); - validate_request_fields( - &request.name, - &request.payload, - &request.session_id, - &request.workspace_path, - )?; + let target = materialize_target(request.target); + validate_request_fields(&request.name, &request.payload, &target)?; validate_schedule(&schedule, current_ms)?; let mut job = CronJob { @@ -136,8 +145,7 @@ impl CronService { schedule, payload: request.payload, enabled: request.enabled, - session_id: request.session_id.trim().to_string(), - workspace_path: request.workspace_path.trim().to_string(), + target, created_at_ms: current_ms, config_updated_at_ms: current_ms, updated_at_ms: current_ms, @@ -174,11 +182,8 @@ impl CronService { if let Some(payload) = request.payload { job.payload = payload; } - if let Some(session_id) = request.session_id { - job.session_id = session_id.trim().to_string(); - } - if let Some(workspace_path) = request.workspace_path { - job.workspace_path = workspace_path.trim().to_string(); + if let Some(target) = request.target { + job.target = materialize_target(target); } if let Some(enabled) = request.enabled { job.enabled = enabled; @@ -187,12 +192,7 @@ impl CronService { job.schedule = materialize_schedule(schedule, current_ms); } - validate_request_fields( - &job.name, - &job.payload, - &job.session_id, - &job.workspace_path, - )?; + validate_request_fields(&job.name, &job.payload, &job.target)?; validate_schedule(&job.schedule, job.created_at_ms)?; job.config_updated_at_ms = current_ms; @@ -253,7 +253,7 @@ impl CronService { let _guard = self.mutation_lock.lock().await; let mut jobs = self.jobs.write().await; let before = jobs.len(); - jobs.retain(|_, job| job.session_id.trim() != session_id); + jobs.retain(|_, job| job.session_id() != Some(session_id)); let removed = before - jobs.len(); if removed > 0 { self.persist_jobs_locked(&jobs).await?; @@ -479,9 +479,10 @@ impl CronService { let (user_input, prepended_messages) = format_scheduled_job_user_input(&job.payload.text, current_ms); enqueue_input = Some(EnqueueInput { + job_id: job.id.clone(), + job_name: job.name.clone(), turn_id, - session_id: job.session_id.clone(), - workspace_path: job.workspace_path.clone(), + target: job.target.clone(), user_input, prepended_messages, }); @@ -510,22 +511,7 @@ impl CronService { )) })?; - let submit_result = self - .scheduler - .submit_with_prepended_messages( - enqueue_input.session_id.clone(), - enqueue_input.user_input.clone(), - Some(enqueue_input.user_input), - Some(enqueue_input.turn_id.clone()), - String::new(), - Some(enqueue_input.workspace_path), - scheduled_job_policy(), - None, - None, - enqueue_input.prepended_messages, - None, - ) - .await; + let submit_result = self.submit_enqueue_input(&enqueue_input).await; let now_after_submit = now_ms(); let Some(job) = jobs.get_mut(job_id) else { @@ -534,7 +520,7 @@ impl CronService { match submit_result { Ok(_) => { - job.state.active_turn_id = Some(enqueue_input.turn_id); + job.state.active_turn_id = Some(enqueue_input.turn_id.clone()); job.state.pending_trigger_at_ms = None; job.state.retry_at_ms = None; job.state.last_enqueued_at_ms = Some(now_after_submit); @@ -548,8 +534,11 @@ impl CronService { } debug!( - "Scheduled job enqueued: job_id={}, session_id={}, scheduled_at_ms={}", - job.id, job.session_id, scheduled_at_ms + "Scheduled job enqueued: job_id={}, target_kind={:?}, target_session_id={}, scheduled_at_ms={}", + job.id, + job.target_kind(), + submit_target_session_id(&enqueue_input), + scheduled_at_ms ); } Err(error) => { @@ -558,7 +547,9 @@ impl CronService { job.state.last_run_finished_at_ms = Some(now_after_submit); job.updated_at_ms = now_after_submit; - if cron_enqueue_error_is_missing_session(&error) { + if matches!(job.target_kind(), CronJobTargetKind::Session) + && cron_enqueue_error_is_missing_session(&error) + { job.enabled = false; job.state.next_run_at_ms = None; job.state.pending_trigger_at_ms = None; @@ -567,15 +558,19 @@ impl CronService { job.state.consecutive_failures.saturating_add(1); info!( "Scheduled job auto-disabled (session no longer exists): job_id={}, session_id={}", - job.id, job.session_id + job.id, + submit_target_session_id(&enqueue_input) ); } else { job.state.retry_at_ms = Some(now_after_submit + DEFAULT_RETRY_DELAY_MS); job.state.consecutive_failures = job.state.consecutive_failures.saturating_add(1); warn!( - "Failed to enqueue scheduled job: job_id={}, session_id={}, error={}", - job.id, job.session_id, error + "Failed to enqueue scheduled job: job_id={}, target_kind={:?}, target_session_id={}, error={}", + job.id, + job.target_kind(), + submit_target_session_id(&enqueue_input), + error ); } } @@ -592,6 +587,81 @@ impl CronService { self.persist_jobs_locked(&jobs).await } + async fn submit_enqueue_input(&self, enqueue_input: &EnqueueInput) -> Result<(), String> { + let resolved = self.resolve_enqueue_submission(enqueue_input).await?; + self.scheduler + .submit_with_prepended_messages( + resolved.session_id, + enqueue_input.user_input.clone(), + Some(enqueue_input.user_input.clone()), + Some(enqueue_input.turn_id.clone()), + resolved.agent_type, + Some(resolved.workspace_path), + scheduled_job_policy(), + None, + None, + enqueue_input.prepended_messages.clone(), + None, + ) + .await + .map(|_| ()) + } + + async fn resolve_enqueue_submission( + &self, + enqueue_input: &EnqueueInput, + ) -> Result { + match &enqueue_input.target { + CronJobTarget::Session { + session_id, + workspace, + } => { + let agent_type = self + .coordinator + .get_session_manager() + .get_session(session_id) + .map(|session| session.agent_type) + .unwrap_or_default(); + Ok(ResolvedEnqueueSubmission { + session_id: session_id.clone(), + workspace_path: workspace.workspace_path.clone(), + agent_type, + }) + } + CronJobTarget::Workspace { workspace, launch } => { + let created = self + .coordinator + .create_session_with_workspace( + None, + format!("Scheduled: {}", enqueue_input.job_name.trim()), + launch.agent_type.clone(), + SessionConfig { + workspace_path: Some(workspace.workspace_path.clone()), + workspace_id: workspace.workspace_id.clone(), + remote_connection_id: workspace.remote_connection_id.clone(), + remote_ssh_host: workspace.remote_ssh_host.clone(), + model_id: launch.model_id.clone(), + ..Default::default() + }, + workspace.workspace_path.clone(), + ) + .await + .map_err(|error| { + format!( + "Failed to create session for scheduled job {}: {}", + enqueue_input.job_id, error + ) + })?; + + Ok(ResolvedEnqueueSubmission { + session_id: created.session_id, + workspace_path: workspace.workspace_path.clone(), + agent_type: created.agent_type, + }) + } + } + } + async fn persist_jobs_locked(&self, jobs: &HashMap) -> BitFunResult<()> { self.store .save_jobs(jobs.values().cloned().collect::>()) @@ -610,12 +680,8 @@ pub fn set_global_cron_service(service: Arc) { fn reconcile_loaded_job(job: &mut CronJob, now_ms: i64) -> BitFunResult { let original = job.clone(); - validate_request_fields( - &job.name, - &job.payload, - &job.session_id, - &job.workspace_path, - )?; + job.target = materialize_target(job.target.clone()); + validate_request_fields(&job.name, &job.payload, &job.target)?; validate_schedule(&job.schedule, job.created_at_ms)?; if job.updated_at_ms < job.created_at_ms { @@ -658,8 +724,7 @@ fn reconcile_loaded_job(job: &mut CronJob, now_ms: i64) -> BitFunResult { fn validate_request_fields( name: &str, payload: &CronJobPayload, - session_id: &str, - workspace_path: &str, + target: &CronJobTarget, ) -> BitFunResult<()> { if name.trim().is_empty() { return Err(BitFunError::validation( @@ -671,16 +736,8 @@ fn validate_request_fields( "Scheduled job payload.text must not be empty", )); } - if session_id.trim().is_empty() { - return Err(BitFunError::validation( - "Scheduled job sessionId must not be empty", - )); - } - if workspace_path.trim().is_empty() { - return Err(BitFunError::validation( - "Scheduled job workspacePath must not be empty", - )); - } + + validate_target(target)?; Ok(()) } @@ -698,6 +755,111 @@ fn materialize_schedule(schedule: CronSchedule, anchor_ms: i64) -> CronSchedule } } +fn materialize_target(target: CronJobTarget) -> CronJobTarget { + match target { + CronJobTarget::Session { + session_id, + workspace, + } => CronJobTarget::Session { + session_id: session_id.trim().to_string(), + workspace: materialize_workspace_ref(workspace), + }, + CronJobTarget::Workspace { workspace, launch } => CronJobTarget::Workspace { + workspace: materialize_workspace_ref(workspace), + launch: materialize_launch_spec(launch), + }, + } +} + +fn materialize_workspace_ref(workspace: CronWorkspaceRef) -> CronWorkspaceRef { + CronWorkspaceRef { + workspace_id: workspace + .workspace_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + workspace_path: workspace.workspace_path.trim().to_string(), + remote_connection_id: workspace + .remote_connection_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + remote_ssh_host: workspace + .remote_ssh_host + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + } +} + +fn materialize_launch_spec(launch: CronLaunchSpec) -> CronLaunchSpec { + CronLaunchSpec { + agent_type: normalize_agent_type(&launch.agent_type), + model_id: launch + .model_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + } +} + +fn normalize_agent_type(agent_type: &str) -> String { + if agent_type.trim().is_empty() { + "agentic".to_string() + } else { + agent_type.trim().to_string() + } +} + +fn validate_target(target: &CronJobTarget) -> BitFunResult<()> { + validate_workspace_ref(target.workspace())?; + + match target { + CronJobTarget::Session { session_id, .. } => { + if session_id.trim().is_empty() { + return Err(BitFunError::validation( + "Scheduled job sessionId must not be empty", + )); + } + } + CronJobTarget::Workspace { launch, .. } => { + if launch.agent_type.trim().is_empty() { + return Err(BitFunError::validation( + "Scheduled job launch.agentType must not be empty", + )); + } + } + } + + Ok(()) +} + +fn validate_workspace_ref(workspace: &CronWorkspaceRef) -> BitFunResult<()> { + if workspace.workspace_path.trim().is_empty() { + return Err(BitFunError::validation( + "Scheduled job workspacePath must not be empty", + )); + } + Ok(()) +} + +fn matches_workspace_filter( + workspace: &CronWorkspaceRef, + workspace_path: Option<&str>, + workspace_id: Option<&str>, + remote_connection_id: Option<&str>, +) -> bool { + let workspace_path_matches = workspace_path + .map(|value| workspace.workspace_path == value) + .unwrap_or(true); + let workspace_id_matches = workspace_id + .map(|value| { + workspace.workspace_id.as_deref() == Some(value) || workspace.workspace_id.is_none() + }) + .unwrap_or(true); + let remote_connection_matches = remote_connection_id + .map(|value| workspace.remote_connection_id.as_deref() == Some(value)) + .unwrap_or(true); + + workspace_path_matches && workspace_id_matches && remote_connection_matches +} + fn pending_is_due(job: &CronJob, now_ms: i64) -> bool { let Some(pending_trigger_at_ms) = job.state.pending_trigger_at_ms else { return false; @@ -754,13 +916,27 @@ fn now_ms() -> i64 { } struct EnqueueInput { + job_id: String, + job_name: String, turn_id: String, - session_id: String, - workspace_path: String, + target: CronJobTarget, user_input: String, prepended_messages: Vec, } +struct ResolvedEnqueueSubmission { + session_id: String, + workspace_path: String, + agent_type: String, +} + +fn submit_target_session_id(enqueue_input: &EnqueueInput) -> &str { + match &enqueue_input.target { + CronJobTarget::Session { session_id, .. } => session_id.as_str(), + CronJobTarget::Workspace { .. } => "", + } +} + /// Permanent failure: coordinator cannot load session metadata (session deleted from disk). fn cron_enqueue_error_is_missing_session(error: &str) -> bool { error.contains("Session metadata not found") diff --git a/src/crates/core/src/service/cron/store.rs b/src/crates/core/src/service/cron/store.rs index 2894901f5d..9d17b9ef9b 100644 --- a/src/crates/core/src/service/cron/store.rs +++ b/src/crates/core/src/service/cron/store.rs @@ -1,10 +1,17 @@ //! jobs.json persistence wrapper. -use super::types::{CronJob, CronJobsFile, CRON_JOBS_VERSION}; +use super::types::{ + CronJob, CronJobPayload, CronJobState, CronJobTarget, CronJobsFile, CronSchedule, + CronWorkspaceRef, CRON_JOBS_VERSION, +}; use crate::infrastructure::storage::{PersistenceService, StorageOptions}; use crate::infrastructure::PathManager; use crate::util::errors::{BitFunError, BitFunResult}; +use log::{info, warn}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use tokio::fs; pub struct CronJobStore { persistence: PersistenceService, @@ -24,20 +31,41 @@ impl CronJobStore { }) } - pub fn jobs_file_path(&self) -> std::path::PathBuf { + pub fn jobs_file_path(&self) -> PathBuf { self.path_manager.cron_jobs_file() } pub async fn load(&self) -> BitFunResult { - let data = self.persistence.load_json::("jobs").await?; - match data { - Some(file) if file.version == CRON_JOBS_VERSION => Ok(file), - Some(file) => Err(BitFunError::service(format!( - "Unsupported cron jobs file version {} in {:?}", - file.version, - self.jobs_file_path() - ))), - None => Ok(CronJobsFile::default()), + let jobs_file_path = self.jobs_file_path(); + if !jobs_file_path.exists() { + return Ok(CronJobsFile::default()); + } + + let content = fs::read_to_string(&jobs_file_path) + .await + .map_err(|error| BitFunError::service(format!("Failed to read file: {}", error)))?; + + match parse_jobs_file_content(&content, &jobs_file_path) { + Ok(LoadJobsOutcome::Current(file)) => Ok(file), + Ok(LoadJobsOutcome::Migrated(file)) => { + info!( + "Migrated legacy cron jobs file to version {}: path={}", + CRON_JOBS_VERSION, + jobs_file_path.display() + ); + self.save_jobs(file.jobs.clone()).await?; + Ok(file) + } + Err(error) => { + warn!( + "Failed to load cron jobs file; backing up and resetting to empty state: path={}, error={}", + jobs_file_path.display(), + error + ); + self.backup_incompatible_jobs_file(&jobs_file_path).await?; + self.save_jobs(Vec::new()).await?; + Ok(CronJobsFile::default()) + } } } @@ -58,4 +86,234 @@ impl CronJobStore { .save_json("jobs", &data, StorageOptions::default()) .await } + + async fn backup_incompatible_jobs_file(&self, jobs_file_path: &Path) -> BitFunResult<()> { + let backup_path = incompatible_backup_path(jobs_file_path); + fs::rename(jobs_file_path, &backup_path) + .await + .map_err(|error| { + BitFunError::service(format!( + "Failed to back up incompatible cron jobs file {} to {}: {}", + jobs_file_path.display(), + backup_path.display(), + error + )) + })?; + info!( + "Backed up incompatible cron jobs file: source={}, backup={}", + jobs_file_path.display(), + backup_path.display() + ); + Ok(()) + } +} + +#[derive(Debug)] +enum LoadJobsOutcome { + Current(CronJobsFile), + Migrated(CronJobsFile), +} + +fn parse_jobs_file_content(content: &str, jobs_file_path: &Path) -> BitFunResult { + let value: serde_json::Value = serde_json::from_str(content).map_err(|error| { + BitFunError::service(format!( + "Failed to parse cron jobs file {}: {}", + jobs_file_path.display(), + error + )) + })?; + + let version = value + .get("version") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + BitFunError::service(format!( + "Cron jobs file {} is missing a numeric version field", + jobs_file_path.display() + )) + })?; + + if version == u64::from(CRON_JOBS_VERSION) { + let file: CronJobsFile = serde_json::from_value(value).map_err(|error| { + BitFunError::service(format!( + "Failed to deserialize cron jobs file {} as version {}: {}", + jobs_file_path.display(), + CRON_JOBS_VERSION, + error + )) + })?; + return Ok(LoadJobsOutcome::Current(file)); + } + + if version == 1 { + let legacy: LegacyCronJobsFileV1 = serde_json::from_value(value).map_err(|error| { + BitFunError::service(format!( + "Failed to deserialize legacy cron jobs file {}: {}", + jobs_file_path.display(), + error + )) + })?; + return Ok(LoadJobsOutcome::Migrated(migrate_legacy_jobs_file(legacy))); + } + + Err(BitFunError::service(format!( + "Unsupported cron jobs file version {} in {}", + version, + jobs_file_path.display() + ))) +} + +fn migrate_legacy_jobs_file(legacy: LegacyCronJobsFileV1) -> CronJobsFile { + CronJobsFile { + version: CRON_JOBS_VERSION, + jobs: legacy.jobs.into_iter().map(migrate_legacy_job).collect(), + } +} + +fn migrate_legacy_job(legacy: LegacyCronJobV1) -> CronJob { + CronJob { + id: legacy.id, + name: legacy.name, + schedule: legacy.schedule, + payload: legacy.payload, + enabled: legacy.enabled, + target: CronJobTarget::Session { + session_id: legacy.session_id, + workspace: CronWorkspaceRef { + workspace_id: None, + workspace_path: legacy.workspace_path, + remote_connection_id: None, + remote_ssh_host: None, + }, + }, + created_at_ms: legacy.created_at_ms, + config_updated_at_ms: legacy.config_updated_at_ms, + updated_at_ms: legacy.updated_at_ms, + state: legacy.state, + } +} + +fn incompatible_backup_path(jobs_file_path: &Path) -> PathBuf { + let timestamp_ms = chrono::Utc::now().timestamp_millis(); + let file_name = jobs_file_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("jobs.json"); + jobs_file_path.with_file_name(format!("{}.incompatible.{}.bak", file_name, timestamp_ms)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyCronJobsFileV1 { + #[serde(rename = "version")] + _version: u32, + jobs: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyCronJobV1 { + id: String, + name: String, + schedule: CronSchedule, + payload: CronJobPayload, + enabled: bool, + session_id: String, + workspace_path: String, + created_at_ms: i64, + config_updated_at_ms: i64, + updated_at_ms: i64, + #[serde(default)] + state: CronJobState, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_current_jobs_file_keeps_current_format() { + let content = r#"{ + "version": 2, + "jobs": [ + { + "id": "cron_1", + "name": "daily", + "schedule": { "kind": "cron", "expr": "0 8 * * *" }, + "payload": { "text": "hello" }, + "enabled": true, + "target": { + "kind": "session", + "sessionId": "session_1", + "workspace": { "workspacePath": "E:/workspace" } + }, + "createdAtMs": 1, + "configUpdatedAtMs": 2, + "updatedAtMs": 3, + "state": {} + } + ] + }"#; + + let outcome = parse_jobs_file_content(content, Path::new("jobs.json")).expect("load"); + + match outcome { + LoadJobsOutcome::Current(file) => { + assert_eq!(file.version, CRON_JOBS_VERSION); + assert_eq!(file.jobs.len(), 1); + assert_eq!(file.jobs[0].session_id(), Some("session_1")); + } + LoadJobsOutcome::Migrated(_) => panic!("expected current format"), + } + } + + #[test] + fn parse_legacy_jobs_file_migrates_to_session_target() { + let content = r#"{ + "version": 1, + "jobs": [ + { + "id": "cron_legacy", + "name": "legacy", + "schedule": { "kind": "cron", "expr": "0 8 * * *" }, + "payload": { "text": "hello" }, + "enabled": true, + "sessionId": "session_legacy", + "workspacePath": "E:/workspace", + "createdAtMs": 10, + "configUpdatedAtMs": 20, + "updatedAtMs": 30, + "state": {} + } + ] + }"#; + + let outcome = parse_jobs_file_content(content, Path::new("jobs.json")).expect("load"); + + match outcome { + LoadJobsOutcome::Migrated(file) => { + assert_eq!(file.version, CRON_JOBS_VERSION); + assert_eq!(file.jobs.len(), 1); + let job = &file.jobs[0]; + assert_eq!(job.session_id(), Some("session_legacy")); + assert_eq!(job.workspace().workspace_path, "E:/workspace"); + } + LoadJobsOutcome::Current(_) => panic!("expected migrated format"), + } + } + + #[test] + fn parse_unknown_version_returns_error() { + let content = r#"{ + "version": 99, + "jobs": [] + }"#; + + let error = parse_jobs_file_content(content, Path::new("jobs.json")) + .expect_err("unknown version should fail"); + + assert!(error + .to_string() + .contains("Unsupported cron jobs file version")); + } } diff --git a/src/crates/core/src/service/cron/types.rs b/src/crates/core/src/service/cron/types.rs index 29005b53bc..fc078afd50 100644 --- a/src/crates/core/src/service/cron/types.rs +++ b/src/crates/core/src/service/cron/types.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -pub const CRON_JOBS_VERSION: u32 = 1; +pub const CRON_JOBS_VERSION: u32 = 2; pub const DEFAULT_RETRY_DELAY_MS: i64 = 5_000; @@ -30,8 +30,7 @@ pub struct CronJob { pub schedule: CronSchedule, pub payload: CronJobPayload, pub enabled: bool, - pub session_id: String, - pub workspace_path: String, + pub target: CronJobTarget, pub created_at_ms: i64, pub config_updated_at_ms: i64, pub updated_at_ms: i64, @@ -43,6 +42,103 @@ impl CronJob { pub fn is_one_shot(&self) -> bool { matches!(self.schedule, CronSchedule::At { .. }) } + + pub fn target_kind(&self) -> CronJobTargetKind { + self.target.kind() + } + + pub fn workspace(&self) -> &CronWorkspaceRef { + self.target.workspace() + } + + pub fn session_id(&self) -> Option<&str> { + self.target.session_id() + } + + pub fn launch(&self) -> Option<&CronLaunchSpec> { + self.target.launch() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CronJobTargetKind { + Session, + Workspace, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CronWorkspaceRef { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CronLaunchSpec { + #[serde(default = "default_agent_type")] + pub agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, +} + +impl Default for CronLaunchSpec { + fn default() -> Self { + Self { + agent_type: default_agent_type(), + model_id: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum CronJobTarget { + Session { + #[serde(rename = "sessionId")] + session_id: String, + workspace: CronWorkspaceRef, + }, + Workspace { + workspace: CronWorkspaceRef, + #[serde(default)] + launch: CronLaunchSpec, + }, +} + +impl CronJobTarget { + pub fn kind(&self) -> CronJobTargetKind { + match self { + Self::Session { .. } => CronJobTargetKind::Session, + Self::Workspace { .. } => CronJobTargetKind::Workspace, + } + } + + pub fn workspace(&self) -> &CronWorkspaceRef { + match self { + Self::Session { workspace, .. } | Self::Workspace { workspace, .. } => workspace, + } + } + + pub fn session_id(&self) -> Option<&str> { + match self { + Self::Session { session_id, .. } => Some(session_id.as_str()), + Self::Workspace { .. } => None, + } + } + + pub fn launch(&self) -> Option<&CronLaunchSpec> { + match self { + Self::Session { .. } => None, + Self::Workspace { launch, .. } => Some(launch), + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -119,8 +215,7 @@ pub struct CreateCronJobRequest { pub payload: CronJobPayload, #[serde(default = "default_enabled")] pub enabled: bool, - pub session_id: String, - pub workspace_path: String, + pub target: CronJobTarget, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -130,10 +225,13 @@ pub struct UpdateCronJobRequest { pub schedule: Option, pub payload: Option, pub enabled: Option, - pub session_id: Option, - pub workspace_path: Option, + pub target: Option, } const fn default_enabled() -> bool { true } + +fn default_agent_type() -> String { + "agentic".to_string() +} diff --git a/src/crates/events/src/agentic.rs b/src/crates/events/src/agentic.rs index 7c343b7a02..3d4b1073a3 100644 --- a/src/crates/events/src/agentic.rs +++ b/src/crates/events/src/agentic.rs @@ -75,6 +75,12 @@ pub enum AgenticEvent { /// Workspace path this session belongs to. None for locally-created sessions. #[serde(skip_serializing_if = "Option::is_none")] workspace_path: Option, + /// Remote SSH connection identity for sessions bound to remote workspaces. + #[serde(skip_serializing_if = "Option::is_none")] + remote_connection_id: Option, + /// Remote SSH host for sessions bound to remote workspaces. + #[serde(skip_serializing_if = "Option::is_none")] + remote_ssh_host: Option, }, SessionStateChanged { diff --git a/src/crates/transport/src/adapters/tauri.rs b/src/crates/transport/src/adapters/tauri.rs index c2a0471d13..d6293b829a 100644 --- a/src/crates/transport/src/adapters/tauri.rs +++ b/src/crates/transport/src/adapters/tauri.rs @@ -46,6 +46,8 @@ impl TransportAdapter for TauriTransportAdapter { session_name, agent_type, workspace_path, + remote_connection_id, + remote_ssh_host, } => { self.app_handle.emit( "agentic://session-created", @@ -54,6 +56,8 @@ impl TransportAdapter for TauriTransportAdapter { "sessionName": session_name, "agentType": agent_type, "workspacePath": workspace_path, + "remoteConnectionId": remote_connection_id, + "remoteSshHost": remote_ssh_host, }), )?; } diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 1187a8d592..5654001994 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive } from 'lucide-react'; +import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3 } from 'lucide-react'; import { IconButton, Input, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; @@ -39,6 +39,7 @@ import { import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; +import ScheduledJobsModal from '@/app/components/scheduled-jobs/ScheduledJobsModal'; import './SessionsSection.scss'; /** Top-level parent sessions shown at each expand step (children still nest under visible parents). */ @@ -142,6 +143,7 @@ const SessionsSection: React.FC = ({ const [openMenuSessionId, setOpenMenuSessionId] = useState(null); const [sessionMenuPosition, setSessionMenuPosition] = useState<{ top: number; left: number } | null>(null); const [runningSessionIds, setRunningSessionIds] = useState>(new Set()); + const [scheduledJobsSessionId, setScheduledJobsSessionId] = useState(null); const editInputRef = useRef(null); const sessionMenuPopoverRef = useRef(null); const sessionMenuAnchorRef = useRef(null); @@ -374,6 +376,9 @@ const SessionsSection: React.FC = ({ }, [childrenByParent, sessionDisplayLimit, topLevelSessions]); const activeSessionId = flowChatState.activeSessionId; + const scheduledJobsSession = scheduledJobsSessionId + ? flowChatState.sessions.get(scheduledJobsSessionId) ?? null + : null; const handleSwitch = useCallback( async (sessionId: string) => { @@ -838,6 +843,19 @@ const SessionsSection: React.FC = ({ {t('nav.sessions.rename')} + )} + + setScheduledJobsSessionId(null)} + workspacePath={scheduledJobsSession?.workspacePath || workspacePath} + workspaceId={scheduledJobsSession?.workspaceId || workspaceId} + remoteConnectionId={scheduledJobsSession?.remoteConnectionId || remoteConnectionId} + remoteSshHost={scheduledJobsSession?.remoteSshHost || remoteSshHost} + sessionId={scheduledJobsSession?.sessionId} + targetKind="session" + lockSessionId + title={t('nav.scheduledJobs.title')} + targetLabel={scheduledJobsSession ? resolveSessionTitle(scheduledJobsSession) : undefined} + targetDescription={scheduledJobsSession?.workspacePath || workspacePath} + /> ); }; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 61f7ef73d7..08147e3a39 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, Archive, Loader2 } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, Archive, Loader2, Clock3 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; import { Button, ConfirmDialog, Modal, Tooltip } from '@/component-library'; @@ -38,6 +38,7 @@ import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; import { scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; import { getWorkspaceGitBasicInfoOptions } from './workspaceGitRefreshOptions'; +import ScheduledJobsModal from '@/app/components/scheduled-jobs/ScheduledJobsModal'; interface WorkspaceItemProps { @@ -97,6 +98,7 @@ const WorkspaceItem: React.FC = ({ const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); const [sessionsCollapsed, setSessionsCollapsed] = useState(false); const [searchIndexModalOpen, setSearchIndexModalOpen] = useState(false); + const [scheduledJobsModalOpen, setScheduledJobsModalOpen] = useState(false); const [workspaceSearchEnabled, setWorkspaceSearchEnabled] = useState( () => aiExperienceConfigService.getSettings().enable_workspace_search, ); @@ -459,6 +461,11 @@ const WorkspaceItem: React.FC = ({ } }, [workspace, t]); + const handleOpenScheduledJobs = useCallback(() => { + setMenuOpen(false); + setScheduledJobsModalOpen(true); + }, []); + const handleRequestDeleteAssistant = useCallback(() => { setMenuOpen(false); setDeleteDialogOpen(true); @@ -815,13 +822,21 @@ const WorkspaceItem: React.FC = ({ role="menu" style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }} > - -
- + +
+
); } @@ -1173,6 +1201,14 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.manageRelatedPaths')} +
{isLinkedWorktree ? (
); }; diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.scss b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.scss new file mode 100644 index 0000000000..38426208d0 --- /dev/null +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.scss @@ -0,0 +1,5 @@ +@use '../../../component-library/styles/tokens.scss' as *; + +.scheduled-jobs-modal__body { + padding: $size-gap-4; +} diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx new file mode 100644 index 0000000000..d89f32e6ac --- /dev/null +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsModal.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Modal } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import ScheduledJobsView from '@/app/components/scheduled-jobs/ScheduledJobsView'; +import type { CronJobTargetKind } from '@/infrastructure/api'; +import type { WorkspaceKind } from '@/shared/types'; +import './ScheduledJobsModal.scss'; + +interface ScheduledJobsModalProps { + isOpen: boolean; + onClose: () => void; + workspacePath?: string; + workspaceId?: string; + workspaceKind?: WorkspaceKind; + remoteConnectionId?: string | null; + remoteSshHost?: string | null; + sessionId?: string; + targetKind: CronJobTargetKind; + lockSessionId?: boolean; + title?: string; + targetLabel?: string; + targetDescription?: string; +} + +const ScheduledJobsModal: React.FC = ({ + isOpen, + onClose, + workspacePath, + workspaceId, + workspaceKind, + remoteConnectionId, + remoteSshHost, + sessionId, + targetKind, + lockSessionId = false, + title, + targetLabel, + targetDescription, +}) => { + const { t } = useI18n('common'); + + return ( + +
+ +
+
+ ); +}; + +export default ScheduledJobsModal; diff --git a/src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.scss b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss similarity index 51% rename from src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.scss rename to src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss index 027c05538f..b4f3769286 100644 --- a/src/web-ui/src/app/scenes/my-agent/AssistantScheduleView.scss +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.scss @@ -15,6 +15,11 @@ align-items: center; gap: $size-gap-3; padding-bottom: $size-gap-3; + min-width: 0; + + &--actions-only { + justify-content: flex-end; + } } &__head-title { @@ -26,6 +31,53 @@ color: var(--color-text-muted); } + &__target { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: $size-gap-2; + color: var(--color-text-secondary); + } + + &__target-kind { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--element-bg-base) 78%, transparent); + font-size: 12px; + font-weight: $font-weight-semibold; + color: var(--color-text-secondary); + line-height: 1; + white-space: nowrap; + } + + &__target-main, + &__target-sub { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__target-main { + flex: 0 1 auto; + max-width: 42%; + font-size: 13px; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + } + + &__target-sub { + flex: 1 1 auto; + font-size: 12px; + color: var(--color-text-muted); + } + &__new-job.btn { flex-shrink: 0; background: var(--element-bg-medium); @@ -87,10 +139,10 @@ // ── Job cards ─────────────────────────────────────────────────────────────── &__item { - display: flex; - align-items: flex-start; - gap: $size-gap-3; - padding: $size-gap-3; + display: flex; + align-items: flex-start; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; border: none; border-radius: $size-radius-lg; background: var(--element-bg-base); @@ -119,6 +171,11 @@ outline: 2px solid var(--color-accent-500, #6366f1); outline-offset: 2px; } + + &.is-expanded { + background: var(--element-bg-soft); + box-shadow: var(--shadow-xs, 0 2px 6px rgba(0, 0, 0, 0.16)); + } } &__item-body { @@ -126,18 +183,19 @@ min-width: 0; display: flex; flex-direction: column; - gap: $size-gap-1; + gap: 2px; } &__item-top { display: flex; align-items: center; - gap: $size-gap-3; + gap: $size-gap-2; min-width: 0; + justify-content: space-between; } &__item-name { - flex: 1; + flex: 1 1 auto; min-width: 0; font-size: var(--font-size-sm); font-weight: $font-weight-semibold; @@ -147,16 +205,33 @@ white-space: nowrap; } + &__item-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-3; + min-width: 0; + } + &__item-meta { font-size: var(--font-size-xs); color: var(--color-text-secondary); line-height: $line-height-relaxed; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; &--dim { color: var(--color-text-muted); + flex-shrink: 0; } } + &__item-next-run { + text-align: right; + } + &__item-error { font-size: var(--font-size-xs); color: var(--color-danger-500, #f87171); @@ -165,7 +240,7 @@ } &__item-actions { - flex-shrink: 0; + flex: 0 0 auto; display: flex; align-items: center; gap: $size-gap-2; @@ -181,54 +256,262 @@ min-width: 0; } + &__editor { + display: flex; + flex-direction: column; + gap: $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-xl; + background: + linear-gradient(180deg, color-mix(in srgb, var(--element-bg-soft) 82%, transparent), var(--element-bg-subtle)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 0 10px 24px rgba(0, 0, 0, 0.08); + overflow: hidden; + } + // ── Form ────────────────────────────────────────────────────────────────── &__form { display: flex; flex-direction: column; - gap: $size-gap-4; - padding: $size-gap-4; + gap: $size-gap-2; + padding: $size-gap-3; } &__form-row { display: flex; flex-direction: column; + &--inline { + display: grid; + grid-template-columns: minmax(72px, 88px) minmax(0, 1fr); + gap: $size-gap-2; + align-items: center; + } + &--two { display: grid; grid-template-columns: 1fr 1fr; gap: $size-gap-4; align-items: start; } + + &--prompt { + align-items: start; + } } - &__field { + &__field, + &__field-meta { display: flex; flex-direction: column; - gap: $size-gap-3; + gap: 4px; + min-width: 0; + } + + &__field-control { + min-width: 0; } &__field-label { - font-size: var(--font-size-xs); + font-size: 11px; font-weight: $font-weight-semibold; color: var(--color-text-secondary); + line-height: 1.35; + letter-spacing: 0.03em; + text-transform: uppercase; + } + + &__field-note { + font-size: var(--font-size-2xs); + color: var(--color-text-muted); + line-height: 1.4; + } + + &__control-grid { + display: grid; + gap: $size-gap-2; + min-width: 0; + + &--schedule { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + } + } + + &__toggle-card { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + box-sizing: border-box; + height: 28px; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--element-bg-base) 78%, transparent); + white-space: nowrap; + min-width: 126px; + + .bitfun-switch { + align-items: center; + gap: 0; + } + + .bitfun-switch__wrapper { + margin-top: 0; + } + } + + &__toggle-label { + font-size: var(--font-size-xs); + font-weight: $font-weight-medium; + color: var(--color-text-secondary); + } + + &__agent-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-3; + width: 100%; + min-width: 0; + } + + &__agent-option-label, + &__agent-option-description { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__agent-option-label { + flex: 0 0 auto; + max-width: 40%; + font-weight: $font-weight-medium; + color: var(--color-text-secondary); + } + + &__agent-option-description { + flex: 1 1 auto; + text-align: right; + font-size: 12px; + color: var(--color-text-muted); + } + + &__agent-select { + .select__dropdown { + max-height: 156px; + } + } + + &__session-select { + .select__dropdown { + max-height: 156px; + } } &__warning { - font-size: var(--font-size-sm); + font-size: var(--font-size-xs); color: var(--color-danger-500, #f87171); line-height: $line-height-relaxed; - padding: $size-gap-3 $size-gap-4; + padding: $size-gap-2 $size-gap-3; border-radius: $size-radius-base; background: rgba(248, 113, 113, 0.06); border: 1px solid rgba(248, 113, 113, 0.2); } + &__prompt-textarea { + position: relative; + gap: 0; + + .bitfun-textarea__field { + min-height: 92px; + padding: 10px 12px 24px; + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--element-bg-base) 82%, transparent); + } + + .bitfun-textarea__footer { + position: absolute; + right: 10px; + bottom: 6px; + justify-content: flex-end; + min-height: 0; + pointer-events: none; + } + + .bitfun-textarea__hint-wrapper { + display: none; + } + + .bitfun-textarea__count { + padding: 1px 5px; + border-radius: $size-radius-sm; + background: color-mix(in srgb, var(--element-bg-base) 88%, transparent); + font-size: 11px; + line-height: 1.2; + } + } + &__form-actions { display: flex; justify-content: flex-end; - gap: $size-gap-3; - padding-top: $size-gap-2; + gap: $size-gap-2; + padding-top: $size-gap-3; + margin-top: $size-gap-1; + border-top: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); + } + + &__action-btn.btn { + min-width: 92px; + font-size: 13px; + font-weight: $font-weight-medium; + letter-spacing: 0; + } + + &__action-btn--ghost.btn { + color: var(--color-text-secondary); + } +} + +@media (max-width: 760px) { + .asv { + &__form-row { + &--inline { + grid-template-columns: 1fr; + gap: $size-gap-2; + } + } + + &__control-grid { + &--schedule { + grid-template-columns: 1fr; + } + } + + &__toggle-card { + justify-content: space-between; + } + + &__head { + align-items: flex-start; + } + + &__target { + flex-direction: column; + align-items: flex-start; + gap: $size-gap-1; + } + + &__target-main, + &__target-sub { + max-width: 100%; + width: 100%; + } } } diff --git a/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx new file mode 100644 index 0000000000..456ec5f853 --- /dev/null +++ b/src/web-ui/src/app/components/scheduled-jobs/ScheduledJobsView.tsx @@ -0,0 +1,1174 @@ +/** + * ScheduledJobsView — inline view for managing scheduled jobs. + * + * Designed for compact side panels and modals: single-column layout, + * job list at top, inline editor expands below the selected job. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { RefreshCw, Trash2 } from 'lucide-react'; +import { + Button, + IconButton, + Input, + Select, + Switch, + Textarea, + confirmDanger, +} from '@/component-library'; +import { + cronAPI, + type CreateCronJobRequest, + type CronJob, + type CronJobTarget, + type CronJobTargetKind, + type CronSchedule, + type CronWorkspaceRef, + type UpdateCronJobRequest, +} from '@/infrastructure/api'; +import { agentAPI, type ModeInfo } from '@/infrastructure/api/service-api/AgentAPI'; +import { useI18n } from '@/infrastructure/i18n'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { FlowChatState, Session } from '@/flow_chat/types/flow-chat'; +import { + compareSessionsForDisplay, + sessionBelongsToWorkspaceNavRow, +} from '@/flow_chat/utils/sessionOrdering'; +import { notificationService } from '@/shared/notification-system/services/NotificationService'; +import { createLogger } from '@/shared/utils/logger'; +import { i18nService } from '@/infrastructure/i18n'; +import { resolveSessionTitle } from '@/flow_chat/utils/sessionTitle'; +import { WorkspaceKind } from '@/shared/types'; +import { normalizePath } from '@/shared/utils/pathUtils'; +import './ScheduledJobsView.scss'; + +const log = createLogger('ScheduledJobsView'); +const MINUTE_IN_MS = 60_000; +const NEW_JOB_ID = '__new__'; +const DEFAULT_AGENT_TYPE = 'agentic'; +const ASSISTANT_WORKSPACE_AGENT_TYPE = 'Claw'; +const SCHEDULED_JOBS_CHANGED_EVENT = 'bitfun:scheduled-jobs-changed'; + +type ScheduleKind = CronSchedule['kind']; + +interface JobDraft { + name: string; + text: string; + enabled: boolean; + sessionId: string; + agentType: string; + scheduleKind: ScheduleKind; + at: string; + everyMinutes: string; + anchorMs: string; + expr: string; + tz: string; +} + +interface JobDraftValidationErrors { + name: boolean; + sessionId: boolean; + agentType: boolean; + text: boolean; + at: boolean; + everyMinutes: boolean; + cronExpr: boolean; +} + +export interface ScheduledJobsViewProps { + workspacePath?: string; + workspaceId?: string; + workspaceKind?: WorkspaceKind; + remoteConnectionId?: string | null; + remoteSshHost?: string | null; + sessionId?: string; + assistantName?: string; + headerTitle?: string | null; + targetLabel?: string; + targetDescription?: string; + targetKind?: CronJobTargetKind; + lockSessionId?: boolean; + assistantWorkspaceMode?: boolean; +} + +function getCurrentLocalDateTimeInput(): string { + return toLocalDateTimeInput(new Date().toISOString()); +} + +function toLocalDateTimeInput(isoTimestamp: string): string { + const date = new Date(isoTimestamp); + const timezoneOffset = date.getTimezoneOffset(); + const localDate = new Date(date.getTime() - timezoneOffset * 60_000); + return localDate.toISOString().slice(0, 16); +} + +function timestampMsToLocalDateTimeInput(timestampMs: number): string { + return toLocalDateTimeInput(new Date(timestampMs).toISOString()); +} + +function formatEveryMinutes(everyMs: number): string { + const everyMinutes = everyMs / MINUTE_IN_MS; + if (Number.isInteger(everyMinutes)) return String(everyMinutes); + return everyMinutes.toFixed(2).replace(/\.?0+$/, ''); +} + +function getDefaultAgentType( + targetKind: CronJobTargetKind, + workspaceKind?: WorkspaceKind, +): string { + if (targetKind === 'workspace' && workspaceKind === WorkspaceKind.Assistant) { + return ASSISTANT_WORKSPACE_AGENT_TYPE; + } + return DEFAULT_AGENT_TYPE; +} + +function createEmptyDraft(defaultSessionId = '', defaultAgentType = DEFAULT_AGENT_TYPE): JobDraft { + return { + name: '', + text: '', + enabled: true, + sessionId: defaultSessionId, + agentType: defaultAgentType, + scheduleKind: 'at', + at: getCurrentLocalDateTimeInput(), + everyMinutes: '60', + anchorMs: '', + expr: '0 8 * * *', + tz: '', + }; +} + +function buildWorkspaceRef( + workspacePath?: string, + workspaceId?: string, + remoteConnectionId?: string | null, + remoteSshHost?: string | null, +): CronWorkspaceRef | null { + const normalizedWorkspacePath = normalizePath(workspacePath?.trim() ?? ''); + if (!normalizedWorkspacePath) { + return null; + } + + return { + workspacePath: normalizedWorkspacePath, + workspaceId: workspaceId?.trim() || undefined, + remoteConnectionId: remoteConnectionId?.trim() || undefined, + remoteSshHost: remoteSshHost?.trim() || undefined, + }; +} + +function buildTargetFromDraft( + targetKind: CronJobTargetKind, + draft: JobDraft, + workspace: CronWorkspaceRef, +): CronJobTarget { + if (targetKind === 'session') { + return { + kind: 'session', + sessionId: draft.sessionId.trim(), + workspace, + }; + } + + return { + kind: 'workspace', + workspace, + launch: { + agentType: draft.agentType.trim() || DEFAULT_AGENT_TYPE, + }, + }; +} + +function jobToDraft(job: CronJob, defaultAgentType: string): JobDraft { + const base = createEmptyDraft('', defaultAgentType); + const draft: JobDraft = { + ...base, + name: job.name, + text: job.payload.text, + enabled: job.enabled, + }; + if (job.target.kind === 'session') { + draft.sessionId = job.target.sessionId; + } else { + draft.agentType = job.target.launch.agentType || defaultAgentType; + } + if (job.schedule.kind === 'at') { + draft.scheduleKind = 'at'; + draft.at = toLocalDateTimeInput(job.schedule.at); + } else if (job.schedule.kind === 'every') { + draft.scheduleKind = 'every'; + draft.everyMinutes = formatEveryMinutes(job.schedule.everyMs); + draft.anchorMs = job.schedule.anchorMs != null + ? timestampMsToLocalDateTimeInput(job.schedule.anchorMs) + : ''; + } else { + draft.scheduleKind = 'cron'; + draft.expr = job.schedule.expr; + draft.tz = job.schedule.tz ?? ''; + } + return draft; +} + +function buildScheduleFromDraft(draft: JobDraft): CronSchedule { + if (draft.scheduleKind === 'at') { + return { kind: 'at', at: new Date(draft.at).toISOString() }; + } + if (draft.scheduleKind === 'every') { + const everyMinutes = Number(draft.everyMinutes); + const anchorMs = draft.anchorMs.trim() ? new Date(draft.anchorMs).getTime() : undefined; + return { kind: 'every', everyMs: Math.round(everyMinutes * MINUTE_IN_MS), anchorMs }; + } + return { kind: 'cron', expr: draft.expr.trim(), tz: draft.tz.trim() || undefined }; +} + +function validateDraft( + targetKind: CronJobTargetKind, + draft: JobDraft, +): JobDraftValidationErrors { + const everyMinutes = Number(draft.everyMinutes); + return { + name: !draft.name.trim(), + sessionId: targetKind === 'session' && !draft.sessionId.trim(), + agentType: targetKind === 'workspace' && !draft.agentType.trim(), + text: !draft.text.trim(), + at: draft.scheduleKind === 'at' && !draft.at.trim(), + everyMinutes: + draft.scheduleKind === 'every' + && (!draft.everyMinutes.trim() || !Number.isFinite(everyMinutes) || everyMinutes <= 0), + cronExpr: draft.scheduleKind === 'cron' && !draft.expr.trim(), + }; +} + +function hasValidationErrors(errors: JobDraftValidationErrors): boolean { + return ( + errors.name + || errors.sessionId + || errors.agentType + || errors.text + || errors.at + || errors.everyMinutes + || errors.cronExpr + ); +} + +function getNextExecutionAtMs(job: CronJob): number | null { + return job.state.pendingTriggerAtMs ?? job.state.retryAtMs ?? job.state.nextRunAtMs ?? null; +} + +function formatScheduleSummary( + schedule: CronSchedule, + formatDate: (date: Date | number, options?: Intl.DateTimeFormatOptions) => string, + t: (key: string, params?: Record) => string, +): string { + switch (schedule.kind) { + case 'at': + return `${t('nav.scheduledJobs.scheduleKinds.at')}: ${formatTimestamp(new Date(schedule.at).getTime(), formatDate, t)}`; + case 'every': + return t('nav.scheduledJobs.scheduleSummary.every', { everyMinutes: formatEveryMinutes(schedule.everyMs) }); + case 'cron': + return schedule.tz + ? t('nav.scheduledJobs.scheduleSummary.cronWithTz', { expr: schedule.expr, tz: schedule.tz }) + : t('nav.scheduledJobs.scheduleSummary.cron', { expr: schedule.expr }); + default: + return ''; + } +} + +function formatJobMetaSummary( + job: CronJob, + formatDate: (date: Date | number, options?: Intl.DateTimeFormatOptions) => string, + t: (key: string, params?: Record) => string, + options?: { + showTarget: boolean; + resolveSessionLabel: (sessionId: string) => string | undefined; + }, +): string { + const scheduleSummary = formatScheduleSummary(job.schedule, formatDate, t); + if (options?.showTarget) { + if (job.target.kind === 'session') { + const sessionLabel = options.resolveSessionLabel(job.target.sessionId) || job.target.sessionId; + return `${sessionLabel} · ${scheduleSummary}`; + } + return `${t('nav.scheduledJobs.targets.newSession')} · ${scheduleSummary}`; + } + if (job.target.kind === 'workspace') { + return `${job.target.launch.agentType} · ${scheduleSummary}`; + } + return scheduleSummary; +} + +function formatTimestamp( + timestampMs: number | null | undefined, + formatDate: (date: Date | number, options?: Intl.DateTimeFormatOptions) => string, + t: (key: string, params?: Record) => string, +): string { + if (!timestampMs || !Number.isFinite(timestampMs)) return t('nav.scheduledJobs.never'); + return formatDate(timestampMs, { + month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', + }); +} + +function resolveSessionLabel(session: Session): string { + return resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); +} + +function buildWorkspaceAgentOptions( + modes: ModeInfo[], + currentAgentType: string, + defaultAgentType: string, + workspaceKind?: WorkspaceKind, +) { + if (workspaceKind === WorkspaceKind.Assistant) { + return [ + { + value: defaultAgentType, + label: defaultAgentType, + description: undefined, + }, + ]; + } + + const options = modes + .filter(mode => mode.id !== ASSISTANT_WORKSPACE_AGENT_TYPE) + .map(mode => ({ + value: mode.id, + label: mode.name?.trim() || mode.id, + description: mode.description?.trim() || undefined, + })); + + const fallbackAgentTypes = [currentAgentType, defaultAgentType] + .map(value => value.trim()) + .filter(value => Boolean(value) && value !== ASSISTANT_WORKSPACE_AGENT_TYPE); + + for (const agentType of fallbackAgentTypes) { + if (!options.some(option => option.value === agentType)) { + options.push({ + value: agentType, + label: agentType, + description: undefined, + }); + } + } + + return options; +} + +const ScheduledJobsView: React.FC = ({ + workspacePath, + workspaceId, + workspaceKind, + remoteConnectionId, + remoteSshHost, + sessionId, + headerTitle, + targetLabel, + targetDescription, + targetKind = 'session', + lockSessionId = false, + assistantWorkspaceMode = false, +}) => { + const { t, formatDate } = useI18n('common'); + const instanceIdRef = useRef(`scheduled-jobs-${Math.random().toString(36).slice(2)}`); + const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState()); + const [jobs, setJobs] = useState([]); + const [availableModes, setAvailableModes] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [selectedJobId, setSelectedJobId] = useState(null); + const [expandedJobId, setExpandedJobId] = useState(null); + const [validationErrors, setValidationErrors] = useState({ + name: false, + sessionId: false, + agentType: false, + text: false, + at: false, + everyMinutes: false, + cronExpr: false, + }); + + const defaultAgentType = useMemo( + () => assistantWorkspaceMode + ? ASSISTANT_WORKSPACE_AGENT_TYPE + : getDefaultAgentType(targetKind, workspaceKind), + [assistantWorkspaceMode, targetKind, workspaceKind], + ); + const workspaceRef = useMemo( + () => buildWorkspaceRef(workspacePath, workspaceId, remoteConnectionId, remoteSshHost), + [remoteConnectionId, remoteSshHost, workspaceId, workspacePath], + ); + + const [draft, setDraft] = useState(() => + createEmptyDraft(sessionId ?? '', defaultAgentType), + ); + const draftTargetKind: CronJobTargetKind = assistantWorkspaceMode && !draft.sessionId.trim() + ? 'workspace' + : targetKind; + + const notifyScheduledJobsChanged = useCallback(() => { + window.dispatchEvent(new CustomEvent(SCHEDULED_JOBS_CHANGED_EVENT, { + detail: { sourceId: instanceIdRef.current }, + })); + }, []); + + useEffect(() => { + const unsubscribe = flowChatStore.subscribe((state) => setFlowChatState(state)); + return unsubscribe; + }, []); + + useEffect(() => { + let cancelled = false; + + const loadAvailableModes = async () => { + try { + const modes = await agentAPI.getAvailableModes(); + if (!cancelled) { + setAvailableModes(modes); + } + } catch (error) { + log.error('Failed to load available modes for scheduled job editor', { error }); + } + }; + + if (targetKind === 'workspace' && !assistantWorkspaceMode) { + void loadAvailableModes(); + } + + return () => { + cancelled = true; + }; + }, [assistantWorkspaceMode, targetKind]); + + const workspaceSessions = useMemo(() => { + return Array.from(flowChatState.sessions.values()) + .filter(s => { + if (s.parentSessionId) return false; + if (workspaceId && s.workspaceId && s.workspaceId !== workspaceId) return false; + const trimmedWorkspacePath = workspacePath?.trim() ?? ''; + if (!trimmedWorkspacePath) return !s.workspacePath; + return sessionBelongsToWorkspaceNavRow( + s, + trimmedWorkspacePath, + remoteConnectionId, + remoteSshHost, + ); + }) + .sort(compareSessionsForDisplay); + }, [flowChatState.sessions, remoteConnectionId, remoteSshHost, workspaceId, workspacePath]); + + const defaultSessionIdForWorkspace = useMemo( + () => sessionId || workspaceSessions[0]?.sessionId || '', + [sessionId, workspaceSessions], + ); + + const sortedJobs = useMemo(() => [...jobs].sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + const diff = b.configUpdatedAtMs - a.configUpdatedAtMs; + return diff !== 0 ? diff : b.createdAtMs - a.createdAtMs; + }), [jobs]); + + const loadJobs = useCallback(async () => { + setLoading(true); + const request = { + workspacePath: workspaceRef?.workspacePath, + workspaceId: workspaceRef?.workspaceId ?? undefined, + remoteConnectionId: workspaceRef?.remoteConnectionId ?? undefined, + sessionId: targetKind === 'session' && lockSessionId && !assistantWorkspaceMode ? sessionId || undefined : undefined, + targetKind: assistantWorkspaceMode ? undefined : targetKind, + }; + try { + const result = await cronAPI.listJobs(request); + setJobs(result); + setExpandedJobId(current => { + if (current === NEW_JOB_ID) { + return current; + } + if (current && result.some(j => j.id === current)) return current; + return null; + }); + setSelectedJobId(current => { + if (current && result.some(j => j.id === current)) return current; + return null; + }); + } catch (error) { + log.error('Failed to load scheduled jobs', { error }); + notificationService.error( + t('nav.scheduledJobs.messages.loadFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } finally { + setLoading(false); + } + }, [assistantWorkspaceMode, lockSessionId, sessionId, t, targetKind, workspaceRef]); + + useEffect(() => { void loadJobs(); }, [loadJobs]); + + useEffect(() => { + const handleScheduledJobsChanged = (event: Event) => { + const sourceId = (event as CustomEvent<{ sourceId?: string }>).detail?.sourceId; + if (sourceId === instanceIdRef.current) return; + void loadJobs(); + }; + + window.addEventListener(SCHEDULED_JOBS_CHANGED_EVENT, handleScheduledJobsChanged); + return () => { + window.removeEventListener(SCHEDULED_JOBS_CHANGED_EVENT, handleScheduledJobsChanged); + }; + }, [loadJobs]); + + useEffect(() => { + setDraft(prev => ({ + ...prev, + sessionId: targetKind === 'session' && !assistantWorkspaceMode + ? (lockSessionId ? sessionId || '' : prev.sessionId || defaultSessionIdForWorkspace) + : prev.sessionId, + agentType: targetKind === 'workspace' || assistantWorkspaceMode + ? ( + workspaceKind === WorkspaceKind.Assistant || assistantWorkspaceMode + ? defaultAgentType + : ( + !prev.agentType + || prev.agentType === ASSISTANT_WORKSPACE_AGENT_TYPE + ? defaultAgentType + : prev.agentType + ) + ) + : prev.agentType, + })); + }, [ + assistantWorkspaceMode, + defaultAgentType, + defaultSessionIdForWorkspace, + lockSessionId, + sessionId, + targetKind, + workspaceKind, + ]); + + const resetValidationErrors = useCallback(() => { + setValidationErrors({ + name: false, + sessionId: false, + agentType: false, + text: false, + at: false, + everyMinutes: false, + cronExpr: false, + }); + }, []); + + const handleCreateNew = useCallback(() => { + setSelectedJobId(null); + resetValidationErrors(); + setDraft(createEmptyDraft( + targetKind === 'session' && !assistantWorkspaceMode + ? (lockSessionId ? sessionId || '' : defaultSessionIdForWorkspace) + : '', + defaultAgentType, + )); + setExpandedJobId(NEW_JOB_ID); + }, [ + defaultAgentType, + defaultSessionIdForWorkspace, + assistantWorkspaceMode, + lockSessionId, + resetValidationErrors, + sessionId, + targetKind, + ]); + + const handleEditJob = useCallback((job: CronJob) => { + if (expandedJobId === job.id) { + setExpandedJobId(null); + setSelectedJobId(null); + return; + } + setSelectedJobId(job.id); + resetValidationErrors(); + setDraft(jobToDraft(job, defaultAgentType)); + setExpandedJobId(job.id); + }, [defaultAgentType, expandedJobId, resetValidationErrors]); + + const handleCloseEditor = useCallback(() => { + setExpandedJobId(null); + setSelectedJobId(null); + resetValidationErrors(); + setDraft(createEmptyDraft( + targetKind === 'session' && !assistantWorkspaceMode + ? (lockSessionId ? sessionId || '' : defaultSessionIdForWorkspace) + : '', + defaultAgentType, + )); + }, [ + defaultAgentType, + defaultSessionIdForWorkspace, + assistantWorkspaceMode, + lockSessionId, + resetValidationErrors, + sessionId, + targetKind, + ]); + + const handleDeleteJob = useCallback(async (job: CronJob) => { + const confirmed = await confirmDanger( + t('nav.scheduledJobs.deleteDialog.title', { name: job.name }), + null, + ); + if (!confirmed) return; + try { + await cronAPI.deleteJob(job.id); + if (selectedJobId === job.id || expandedJobId === job.id) { handleCloseEditor(); } + await loadJobs(); + notifyScheduledJobsChanged(); + } catch (error) { + log.error('Failed to delete scheduled job', { jobId: job.id, error }); + notificationService.error( + t('nav.scheduledJobs.messages.deleteFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } + }, [expandedJobId, handleCloseEditor, loadJobs, notifyScheduledJobsChanged, selectedJobId, t]); + + const handleToggleEnabled = useCallback(async (job: CronJob, enabled: boolean) => { + try { + await cronAPI.updateJob(job.id, { enabled }); + await loadJobs(); + notifyScheduledJobsChanged(); + } catch (error) { + log.error('Failed to toggle scheduled job', { jobId: job.id, error }); + notificationService.error( + t('nav.scheduledJobs.messages.updateFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } + }, [loadJobs, notifyScheduledJobsChanged, t]); + + const handleSave = useCallback(async () => { + const nextValidationErrors = validateDraft(draftTargetKind, draft); + setValidationErrors(nextValidationErrors); + if (hasValidationErrors(nextValidationErrors)) { return; } + if (!workspaceRef) { return; } + + const schedule = buildScheduleFromDraft(draft); + const target = buildTargetFromDraft(draftTargetKind, draft, workspaceRef); + + setSaving(true); + try { + if (selectedJobId) { + const request: UpdateCronJobRequest = { + name: draft.name.trim(), + payload: { text: draft.text.trim() }, + enabled: draft.enabled, + schedule, + target, + }; + const updated = await cronAPI.updateJob(selectedJobId, request); + setSelectedJobId(null); + setDraft(jobToDraft(updated, defaultAgentType)); + setExpandedJobId(null); + } else { + const request: CreateCronJobRequest = { + name: draft.name.trim(), + payload: { text: draft.text.trim() }, + enabled: draft.enabled, + schedule, + target, + }; + await cronAPI.createJob(request); + setSelectedJobId(null); + setExpandedJobId(null); + setDraft(createEmptyDraft( + targetKind === 'session' && !assistantWorkspaceMode + ? (lockSessionId ? sessionId || '' : defaultSessionIdForWorkspace) + : '', + defaultAgentType, + )); + } + await loadJobs(); + notifyScheduledJobsChanged(); + } catch (error) { + log.error('Failed to save scheduled job', { error }); + notificationService.error( + t('nav.scheduledJobs.messages.saveFailed', { + error: error instanceof Error ? error.message : String(error), + }), + ); + } finally { + setSaving(false); + } + }, [ + defaultAgentType, + defaultSessionIdForWorkspace, + draft, + draftTargetKind, + loadJobs, + notifyScheduledJobsChanged, + assistantWorkspaceMode, + lockSessionId, + selectedJobId, + sessionId, + t, + targetKind, + workspaceRef, + ]); + + const sessionOptions = useMemo( + () => workspaceSessions.map(s => ({ + value: s.sessionId, + label: resolveSessionLabel(s), + })), + [workspaceSessions], + ); + const sessionLabelById = useMemo(() => { + const labels = new Map(); + workspaceSessions.forEach(session => { + labels.set(session.sessionId, resolveSessionLabel(session)); + }); + return labels; + }, [workspaceSessions]); + const workspaceAgentOptions = useMemo( + () => buildWorkspaceAgentOptions( + availableModes, + draft.agentType, + defaultAgentType, + workspaceKind, + ), + [availableModes, defaultAgentType, draft.agentType, workspaceKind], + ); + + const canSave = assistantWorkspaceMode + ? Boolean(workspaceRef) + : targetKind === 'session' + ? Boolean(workspaceRef && draft.sessionId.trim()) + : Boolean(workspaceRef && draft.agentType.trim()); + const effectiveHeaderTitle = headerTitle === undefined ? t('nav.scheduledJobs.title') : headerTitle; + const targetTypeLabel = targetKind === 'session' + ? t('nav.scheduledJobs.targets.session') + : t('shared:features.workspace'); + const hasHeaderContent = Boolean(targetLabel?.trim() || effectiveHeaderTitle); + const emptyTitle = targetKind === 'workspace' && !assistantWorkspaceMode + ? t('nav.scheduledJobs.empty.workspaceTitle') + : t('nav.scheduledJobs.empty.title'); + + return ( +
+
+ {targetLabel?.trim() ? ( +
+ {targetTypeLabel} + {targetLabel} + {targetDescription?.trim() ? ( + {targetDescription} + ) : null} +
+ ) : effectiveHeaderTitle ? ( + {effectiveHeaderTitle} + ) : null} + +
+ + {expandedJobId === NEW_JOB_ID ? renderEditor() : null} + + {loading ? ( +
+ +
+ ) : sortedJobs.length === 0 && expandedJobId !== NEW_JOB_ID ? ( +
+

{emptyTitle}

+
+ ) : sortedJobs.length > 0 ? ( +
+ {sortedJobs.map(job => { + const isExpanded = expandedJobId === job.id; + return ( + +
handleEditJob(job)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleEditJob(job); + } + }} + > +
+
+ {job.name} +
+
e.stopPropagation()} + role="presentation" + > + { + void handleToggleEnabled(job, e.currentTarget.checked); + }} + aria-label={t('nav.scheduledJobs.actions.toggleEnabled')} + /> +
+ { e.stopPropagation(); void handleDeleteJob(job); }} + > + + +
+
+
+
+ {formatJobMetaSummary(job, formatDate, t, { + showTarget: assistantWorkspaceMode, + resolveSessionLabel: sessionId => sessionLabelById.get(sessionId), + })} +
+
+ {t('nav.scheduledJobs.nextRunLabel')}: {formatTimestamp(getNextExecutionAtMs(job), formatDate, t)} +
+
+ {job.state.lastError ? ( +
{job.state.lastError}
+ ) : null} +
+
+ {isExpanded ? renderEditor() : null} +
+ ); + })} +
+ ) : null} +
+ ); + + function renderEditor() { + return ( +
+ {renderForm()} +
+ ); + } + + function renderForm() { + return ( +
+ {targetKind === 'session' && !assistantWorkspaceMode && !canSave ? ( +

{t('nav.scheduledJobs.messages.sessionRequired')}

+ ) : null} + +
+
+ {t('nav.scheduledJobs.fields.name')} +
+
+ { + const name = e.currentTarget.value; + setValidationErrors(current => ({ ...current, name: false })); + setDraft(c => ({ ...c, name })); + }} + error={validationErrors.name} + placeholder={t('nav.scheduledJobs.placeholders.name')} + /> +
+
+ +
+
+ {t('nav.scheduledJobs.fields.scheduleKind')} +
+
+
+ { + const at = e.currentTarget.value; + setValidationErrors(current => ({ ...current, at: false })); + setDraft(c => ({ ...c, at })); + }} + /> +
+
+ )} + + {draft.scheduleKind === 'every' && ( + <> +
+
+ {t('nav.scheduledJobs.fields.everyMs')} +
+
+ { + const everyMinutes = e.currentTarget.value; + setValidationErrors(current => ({ ...current, everyMinutes: false })); + setDraft(c => ({ ...c, everyMinutes })); + }} + placeholder="60" + /> +
+
+
+
+ {t('nav.scheduledJobs.fields.anchorMs')} +
+
+ { + const anchorMs = e.currentTarget.value; + setDraft(c => ({ ...c, anchorMs })); + }} + placeholder={t('nav.scheduledJobs.placeholders.anchorMs')} + /> +
+
+ + )} + + {draft.scheduleKind === 'cron' && ( + <> +
+
+ {t('nav.scheduledJobs.fields.cronExpr')} +
+
+ { + const expr = e.currentTarget.value; + setValidationErrors(current => ({ ...current, cronExpr: false })); + setDraft(c => ({ ...c, expr })); + }} + placeholder="0 8 * * *" + /> + + {t('nav.scheduledJobs.hints.cronExpr')} + +
+
+
+
+ {t('nav.scheduledJobs.fields.timezone')} +
+
+ { + const tz = e.currentTarget.value; + setDraft(c => ({ ...c, tz })); + }} + placeholder={t('nav.scheduledJobs.placeholders.timezone')} + /> +
+
+ + )} + + {assistantWorkspaceMode ? ( +
+
+ {t('nav.scheduledJobs.fields.session')} +
+
+ ( +
+ {option.label} + {option.description ? ( + {option.description} + ) : null} +
+ )} + onChange={value => { + const agentType = String(value); + setValidationErrors(current => ({ ...current, agentType: false })); + setDraft(c => ({ ...c, agentType })); + }} + placeholder={t('nav.scheduledJobs.placeholders.agentType')} + /> +
+
+ ) : !lockSessionId ? ( +
+
+ {t('nav.scheduledJobs.fields.session')} +
+
+