feat: implement proper task cancellation for scheduled jobs (#2731)

This commit is contained in:
Max Novich
2025-05-29 18:33:27 -07:00
committed by GitHub
parent a05029773d
commit bd430866e8
12 changed files with 2945 additions and 237 deletions

View File

@@ -34,6 +34,8 @@ pub async fn handle_schedule_add(
last_run: None,
currently_running: false,
paused: false,
current_session_id: None,
process_start_time: None,
};
let scheduler_storage_path =

View File

@@ -45,6 +45,8 @@ use utoipa::OpenApi;
super::routes::schedule::run_now_handler,
super::routes::schedule::pause_schedule,
super::routes::schedule::unpause_schedule,
super::routes::schedule::kill_running_job,
super::routes::schedule::inspect_running_job,
super::routes::schedule::sessions_handler
),
components(schemas(
@@ -95,6 +97,8 @@ use utoipa::OpenApi;
SessionMetadata,
super::routes::schedule::CreateScheduleRequest,
super::routes::schedule::UpdateScheduleRequest,
super::routes::schedule::KillJobResponse,
super::routes::schedule::InspectJobResponse,
goose::scheduler::ScheduledJob,
super::routes::schedule::RunNowResponse,
super::routes::schedule::ListSchedulesResponse,

View File

@@ -31,6 +31,21 @@ pub struct ListSchedulesResponse {
jobs: Vec<ScheduledJob>,
}
// Response for the kill endpoint
#[derive(Serialize, utoipa::ToSchema)]
pub struct KillJobResponse {
message: String,
}
// Response for the inspect endpoint
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct InspectJobResponse {
session_id: Option<String>,
process_start_time: Option<String>,
running_duration_seconds: Option<i64>,
}
// Response for the run_now endpoint
#[derive(Serialize, utoipa::ToSchema)]
pub struct RunNowResponse {
@@ -100,6 +115,8 @@ async fn create_schedule(
last_run: None,
currently_running: false,
paused: false,
current_session_id: None,
process_start_time: None,
};
scheduler
.add_scheduled_job(job.clone())
@@ -199,6 +216,17 @@ async fn run_now_handler(
eprintln!("Error running schedule '{}' now: {:?}", id, e);
match e {
goose::scheduler::SchedulerError::JobNotFound(_) => Err(StatusCode::NOT_FOUND),
goose::scheduler::SchedulerError::AnyhowError(ref err) => {
// Check if this is a cancellation error
if err.to_string().contains("was successfully cancelled") {
// Return a special session_id to indicate cancellation
Ok(Json(RunNowResponse {
session_id: "CANCELLED".to_string(),
}))
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
@@ -389,6 +417,92 @@ async fn update_schedule(
Ok(Json(updated_job))
}
#[utoipa::path(
post,
path = "/schedule/{id}/kill",
responses(
(status = 200, description = "Running job killed successfully"),
),
tag = "schedule"
)]
#[axum::debug_handler]
pub async fn kill_running_job(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Json<KillJobResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
scheduler.kill_running_job(&id).await.map_err(|e| {
eprintln!("Error killing running job '{}': {:?}", id, e);
match e {
goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND,
goose::scheduler::SchedulerError::AnyhowError(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
Ok(Json(KillJobResponse {
message: format!("Successfully killed running job '{}'", id),
}))
}
#[utoipa::path(
get,
path = "/schedule/{id}/inspect",
params(
("id" = String, Path, description = "ID of the schedule to inspect")
),
responses(
(status = 200, description = "Running job information", body = InspectJobResponse),
(status = 404, description = "Scheduled job not found"),
(status = 500, description = "Internal server error")
),
tag = "schedule"
)]
#[axum::debug_handler]
pub async fn inspect_running_job(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Json<InspectJobResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;
let scheduler = state
.scheduler()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match scheduler.get_running_job_info(&id).await {
Ok(info) => {
if let Some((session_id, start_time)) = info {
let duration = chrono::Utc::now().signed_duration_since(start_time);
Ok(Json(InspectJobResponse {
session_id: Some(session_id),
process_start_time: Some(start_time.to_rfc3339()),
running_duration_seconds: Some(duration.num_seconds()),
}))
} else {
Ok(Json(InspectJobResponse {
session_id: None,
process_start_time: None,
running_duration_seconds: None,
}))
}
}
Err(e) => {
eprintln!("Error inspecting running job '{}': {:?}", id, e);
match e {
goose::scheduler::SchedulerError::JobNotFound(_) => Err(StatusCode::NOT_FOUND),
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
}
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/schedule/create", post(create_schedule))
@@ -398,6 +512,8 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/schedule/{id}/run_now", post(run_now_handler)) // Corrected
.route("/schedule/{id}/pause", post(pause_schedule))
.route("/schedule/{id}/unpause", post(unpause_schedule))
.route("/schedule/{id}/kill", post(kill_running_job))
.route("/schedule/{id}/inspect", get(inspect_running_job))
.route("/schedule/{id}/sessions", get(sessions_handler)) // Corrected
.with_state(state)
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,10 @@ use crate::recipe::Recipe;
use crate::session;
use crate::session::storage::SessionMetadata;
// Track running tasks with their abort handles
type RunningTasksMap = HashMap<String, tokio::task::AbortHandle>;
type JobsMap = HashMap<String, (JobId, ScheduledJob)>;
pub fn get_default_scheduler_storage_path() -> Result<PathBuf, io::Error> {
let strategy = choose_app_strategy(config::APP_STRATEGY.clone())
.map_err(|e| io::Error::new(io::ErrorKind::NotFound, e.to_string()))?;
@@ -111,11 +115,15 @@ pub struct ScheduledJob {
pub currently_running: bool,
#[serde(default)]
pub paused: bool,
#[serde(default)]
pub current_session_id: Option<String>,
#[serde(default)]
pub process_start_time: Option<DateTime<Utc>>,
}
async fn persist_jobs_from_arc(
storage_path: &Path,
jobs_arc: &Arc<Mutex<HashMap<String, (JobId, ScheduledJob)>>>,
jobs_arc: &Arc<Mutex<JobsMap>>,
) -> Result<(), SchedulerError> {
let jobs_guard = jobs_arc.lock().await;
let list: Vec<ScheduledJob> = jobs_guard.values().map(|(_, j)| j.clone()).collect();
@@ -129,8 +137,9 @@ async fn persist_jobs_from_arc(
pub struct Scheduler {
internal_scheduler: TokioJobScheduler,
jobs: Arc<Mutex<HashMap<String, (JobId, ScheduledJob)>>>,
jobs: Arc<Mutex<JobsMap>>,
storage_path: PathBuf,
running_tasks: Arc<Mutex<RunningTasksMap>>,
}
impl Scheduler {
@@ -140,11 +149,13 @@ impl Scheduler {
.map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?;
let jobs = Arc::new(Mutex::new(HashMap::new()));
let running_tasks = Arc::new(Mutex::new(HashMap::new()));
let arc_self = Arc::new(Self {
internal_scheduler,
jobs,
storage_path,
running_tasks,
});
arc_self.load_jobs_from_storage().await?;
@@ -208,17 +219,21 @@ impl Scheduler {
let mut stored_job = original_job_spec.clone();
stored_job.source = destination_recipe_path.to_string_lossy().into_owned();
stored_job.current_session_id = None;
stored_job.process_start_time = None;
tracing::info!("Updated job source path to: {}", stored_job.source);
let job_for_task = stored_job.clone();
let jobs_arc_for_task = self.jobs.clone();
let storage_path_for_task = self.storage_path.clone();
let running_tasks_for_task = self.running_tasks.clone();
let cron_task = Job::new_async(&stored_job.cron, move |_uuid, _l| {
let task_job_id = job_for_task.id.clone();
let current_jobs_arc = jobs_arc_for_task.clone();
let local_storage_path = storage_path_for_task.clone();
let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal
let running_tasks_arc = running_tasks_for_task.clone();
Box::pin(async move {
// Check if the job is paused before executing
@@ -243,6 +258,7 @@ impl Scheduler {
if let Some((_, current_job_in_map)) = jobs_map_guard.get_mut(&task_job_id) {
current_job_in_map.last_run = Some(current_time);
current_job_in_map.currently_running = true;
current_job_in_map.process_start_time = Some(current_time);
needs_persist = true;
}
}
@@ -258,14 +274,37 @@ impl Scheduler {
);
}
}
// Pass None for provider_override in normal execution
let result = run_scheduled_job_internal(job_to_execute, None).await;
// Spawn the job execution as an abortable task
let job_task = tokio::spawn(run_scheduled_job_internal(
job_to_execute.clone(),
None,
Some(current_jobs_arc.clone()),
Some(task_job_id.clone()),
));
// Store the abort handle at the scheduler level
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.insert(task_job_id.clone(), job_task.abort_handle());
}
// Wait for the job to complete or be aborted
let result = job_task.await;
// Remove the abort handle
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.remove(&task_job_id);
}
// Update the job status after execution
{
let mut jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, current_job_in_map)) = jobs_map_guard.get_mut(&task_job_id) {
current_job_in_map.currently_running = false;
current_job_in_map.current_session_id = None;
current_job_in_map.process_start_time = None;
needs_persist = true;
}
}
@@ -282,12 +321,27 @@ impl Scheduler {
}
}
if let Err(e) = result {
tracing::error!(
"Scheduled job '{}' execution failed: {}",
&e.job_id,
e.error
);
match result {
Ok(Ok(_session_id)) => {
tracing::info!("Scheduled job '{}' completed successfully", &task_job_id);
}
Ok(Err(e)) => {
tracing::error!(
"Scheduled job '{}' execution failed: {}",
&e.job_id,
e.error
);
}
Err(join_error) if join_error.is_cancelled() => {
tracing::info!("Scheduled job '{}' was cancelled/killed", &task_job_id);
}
Err(join_error) => {
tracing::error!(
"Scheduled job '{}' task failed: {}",
&task_job_id,
join_error
);
}
}
})
})
@@ -328,12 +382,14 @@ impl Scheduler {
let job_for_task = job_to_load.clone();
let jobs_arc_for_task = self.jobs.clone();
let storage_path_for_task = self.storage_path.clone();
let running_tasks_for_task = self.running_tasks.clone();
let cron_task = Job::new_async(&job_to_load.cron, move |_uuid, _l| {
let task_job_id = job_for_task.id.clone();
let current_jobs_arc = jobs_arc_for_task.clone();
let local_storage_path = storage_path_for_task.clone();
let job_to_execute = job_for_task.clone(); // Clone for run_scheduled_job_internal
let running_tasks_arc = running_tasks_for_task.clone();
Box::pin(async move {
// Check if the job is paused before executing
@@ -358,6 +414,7 @@ impl Scheduler {
if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) {
stored_job.last_run = Some(current_time);
stored_job.currently_running = true;
stored_job.process_start_time = Some(current_time);
needs_persist = true;
}
}
@@ -373,14 +430,37 @@ impl Scheduler {
);
}
}
// Pass None for provider_override in normal execution
let result = run_scheduled_job_internal(job_to_execute, None).await;
// Spawn the job execution as an abortable task
let job_task = tokio::spawn(run_scheduled_job_internal(
job_to_execute,
None,
Some(current_jobs_arc.clone()),
Some(task_job_id.clone()),
));
// Store the abort handle at the scheduler level
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.insert(task_job_id.clone(), job_task.abort_handle());
}
// Wait for the job to complete or be aborted
let result = job_task.await;
// Remove the abort handle
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.remove(&task_job_id);
}
// Update the job status after execution
{
let mut jobs_map_guard = current_jobs_arc.lock().await;
if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) {
stored_job.currently_running = false;
stored_job.current_session_id = None;
stored_job.process_start_time = None;
needs_persist = true;
}
}
@@ -397,12 +477,30 @@ impl Scheduler {
}
}
if let Err(e) = result {
tracing::error!(
"Scheduled job '{}' execution failed: {}",
&e.job_id,
e.error
);
match result {
Ok(Ok(_session_id)) => {
tracing::info!(
"Scheduled job '{}' completed successfully",
&task_job_id
);
}
Ok(Err(e)) => {
tracing::error!(
"Scheduled job '{}' execution failed: {}",
&e.job_id,
e.error
);
}
Err(join_error) if join_error.is_cancelled() => {
tracing::info!("Scheduled job '{}' was cancelled/killed", &task_job_id);
}
Err(join_error) => {
tracing::error!(
"Scheduled job '{}' task failed: {}",
&task_job_id,
join_error
);
}
}
})
})
@@ -421,7 +519,7 @@ impl Scheduler {
// Renamed and kept for direct use when a guard is already held (e.g. add/remove)
async fn persist_jobs_to_storage_with_guard(
&self,
jobs_guard: &tokio::sync::MutexGuard<'_, HashMap<String, (JobId, ScheduledJob)>>,
jobs_guard: &tokio::sync::MutexGuard<'_, JobsMap>,
) -> Result<(), SchedulerError> {
let list: Vec<ScheduledJob> = jobs_guard.values().map(|(_, j)| j.clone()).collect();
if let Some(parent) = self.storage_path.parent() {
@@ -523,14 +621,36 @@ impl Scheduler {
}
};
// Pass None for provider_override in normal execution
let run_result = run_scheduled_job_internal(job_to_run.clone(), None).await;
// Spawn the job execution as an abortable task for run_now
let job_task = tokio::spawn(run_scheduled_job_internal(
job_to_run.clone(),
None,
Some(self.jobs.clone()),
Some(sched_id.to_string()),
));
// Store the abort handle for run_now jobs
{
let mut running_tasks_guard = self.running_tasks.lock().await;
running_tasks_guard.insert(sched_id.to_string(), job_task.abort_handle());
}
// Wait for the job to complete or be aborted
let run_result = job_task.await;
// Remove the abort handle
{
let mut running_tasks_guard = self.running_tasks.lock().await;
running_tasks_guard.remove(sched_id);
}
// Clear the currently_running flag after execution
{
let mut jobs_guard = self.jobs.lock().await;
if let Some((_tokio_job_id, job_in_map)) = jobs_guard.get_mut(sched_id) {
job_in_map.currently_running = false;
job_in_map.current_session_id = None;
job_in_map.process_start_time = None;
job_in_map.last_run = Some(Utc::now());
} // MutexGuard is dropped here
}
@@ -539,12 +659,24 @@ impl Scheduler {
self.persist_jobs().await?;
match run_result {
Ok(session_id) => Ok(session_id),
Err(e) => Err(SchedulerError::AnyhowError(anyhow!(
Ok(Ok(session_id)) => Ok(session_id),
Ok(Err(e)) => Err(SchedulerError::AnyhowError(anyhow!(
"Failed to execute job '{}' immediately: {}",
sched_id,
e.error
))),
Err(join_error) if join_error.is_cancelled() => {
tracing::info!("Run now job '{}' was cancelled/killed", sched_id);
Err(SchedulerError::AnyhowError(anyhow!(
"Job '{}' was successfully cancelled",
sched_id
)))
}
Err(join_error) => Err(SchedulerError::AnyhowError(anyhow!(
"Failed to execute job '{}' immediately: {}",
sched_id,
join_error
))),
}
}
@@ -608,12 +740,14 @@ impl Scheduler {
let job_for_task = job_def.clone();
let jobs_arc_for_task = self.jobs.clone();
let storage_path_for_task = self.storage_path.clone();
let running_tasks_for_task = self.running_tasks.clone();
let cron_task = Job::new_async(&new_cron, move |_uuid, _l| {
let task_job_id = job_for_task.id.clone();
let current_jobs_arc = jobs_arc_for_task.clone();
let local_storage_path = storage_path_for_task.clone();
let job_to_execute = job_for_task.clone();
let running_tasks_arc = running_tasks_for_task.clone();
Box::pin(async move {
// Check if the job is paused before executing
@@ -641,6 +775,7 @@ impl Scheduler {
{
current_job_in_map.last_run = Some(current_time);
current_job_in_map.currently_running = true;
current_job_in_map.process_start_time = Some(current_time);
needs_persist = true;
}
}
@@ -657,7 +792,29 @@ impl Scheduler {
}
}
let result = run_scheduled_job_internal(job_to_execute, None).await;
// Spawn the job execution as an abortable task
let job_task = tokio::spawn(run_scheduled_job_internal(
job_to_execute,
None,
Some(current_jobs_arc.clone()),
Some(task_job_id.clone()),
));
// Store the abort handle at the scheduler level
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard
.insert(task_job_id.clone(), job_task.abort_handle());
}
// Wait for the job to complete or be aborted
let result = job_task.await;
// Remove the abort handle
{
let mut running_tasks_guard = running_tasks_arc.lock().await;
running_tasks_guard.remove(&task_job_id);
}
// Update the job status after execution
{
@@ -666,6 +823,8 @@ impl Scheduler {
jobs_map_guard.get_mut(&task_job_id)
{
current_job_in_map.currently_running = false;
current_job_in_map.current_session_id = None;
current_job_in_map.process_start_time = None;
needs_persist = true;
}
}
@@ -682,12 +841,33 @@ impl Scheduler {
}
}
if let Err(e) = result {
tracing::error!(
"Scheduled job '{}' execution failed: {}",
&e.job_id,
e.error
);
match result {
Ok(Ok(_session_id)) => {
tracing::info!(
"Scheduled job '{}' completed successfully",
&task_job_id
);
}
Ok(Err(e)) => {
tracing::error!(
"Scheduled job '{}' execution failed: {}",
&e.job_id,
e.error
);
}
Err(join_error) if join_error.is_cancelled() => {
tracing::info!(
"Scheduled job '{}' was cancelled/killed",
&task_job_id
);
}
Err(join_error) => {
tracing::error!(
"Scheduled job '{}' task failed: {}",
&task_job_id,
join_error
);
}
}
})
})
@@ -709,6 +889,70 @@ impl Scheduler {
None => Err(SchedulerError::JobNotFound(sched_id.to_string())),
}
}
pub async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> {
let mut jobs_guard = self.jobs.lock().await;
match jobs_guard.get_mut(sched_id) {
Some((_, job_def)) => {
if !job_def.currently_running {
return Err(SchedulerError::AnyhowError(anyhow!(
"Schedule '{}' is not currently running",
sched_id
)));
}
tracing::info!("Killing running job '{}'", sched_id);
// Abort the running task if it exists
{
let mut running_tasks_guard = self.running_tasks.lock().await;
if let Some(abort_handle) = running_tasks_guard.remove(sched_id) {
abort_handle.abort();
tracing::info!("Aborted running task for job '{}'", sched_id);
} else {
tracing::warn!(
"No abort handle found for job '{}' in running tasks map",
sched_id
);
}
}
// Mark the job as no longer running
job_def.currently_running = false;
job_def.current_session_id = None;
job_def.process_start_time = None;
self.persist_jobs_to_storage_with_guard(&jobs_guard).await?;
tracing::info!("Successfully killed job '{}'", sched_id);
Ok(())
}
None => Err(SchedulerError::JobNotFound(sched_id.to_string())),
}
}
pub async fn get_running_job_info(
&self,
sched_id: &str,
) -> Result<Option<(String, DateTime<Utc>)>, SchedulerError> {
let jobs_guard = self.jobs.lock().await;
match jobs_guard.get(sched_id) {
Some((_, job_def)) => {
if job_def.currently_running {
if let (Some(session_id), Some(start_time)) =
(&job_def.current_session_id, &job_def.process_start_time)
{
Ok(Some((session_id.clone(), *start_time)))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
None => Err(SchedulerError::JobNotFound(sched_id.to_string())),
}
}
}
#[derive(Debug)]
@@ -720,6 +964,8 @@ struct JobExecutionError {
async fn run_scheduled_job_internal(
job: ScheduledJob,
provider_override: Option<Arc<dyn GooseProvider>>, // New optional parameter
jobs_arc: Option<Arc<Mutex<JobsMap>>>,
job_id: Option<String>,
) -> std::result::Result<String, JobExecutionError> {
tracing::info!("Executing job: {} (Source: {})", job.id, job.source);
@@ -811,6 +1057,15 @@ async fn run_scheduled_job_internal(
tracing::info!("Agent configured with provider for job '{}'", job.id);
let session_id_for_return = session::generate_session_id();
// Update the job with the session ID if we have access to the jobs arc
if let (Some(jobs_arc), Some(job_id_str)) = (jobs_arc.as_ref(), job_id.as_ref()) {
let mut jobs_guard = jobs_arc.lock().await;
if let Some((_, job_def)) = jobs_guard.get_mut(job_id_str) {
job_def.current_session_id = Some(session_id_for_return.clone());
}
}
let session_file_path = crate::session::storage::get_path(
crate::session::storage::Identifier::Name(session_id_for_return.clone()),
);
@@ -843,6 +1098,9 @@ async fn run_scheduled_job_internal(
use futures::StreamExt;
while let Some(message_result) = stream.next().await {
// Check if the task has been cancelled
tokio::task::yield_now().await;
match message_result {
Ok(msg) => {
if msg.role == mcp_core::role::Role::Assistant {
@@ -1053,6 +1311,8 @@ mod tests {
last_run: None,
currently_running: false,
paused: false,
current_session_id: None,
process_start_time: None,
};
// Create the mock provider instance for the test
@@ -1061,7 +1321,7 @@ mod tests {
// Call run_scheduled_job_internal, passing the mock provider
let created_session_id =
run_scheduled_job_internal(dummy_job.clone(), Some(mock_provider_instance))
run_scheduled_job_internal(dummy_job.clone(), Some(mock_provider_instance), None, None)
.await
.expect("run_scheduled_job_internal failed");

View File

@@ -589,6 +589,66 @@
}
}
},
"/schedule/{id}/inspect": {
"get": {
"tags": [
"schedule"
],
"operationId": "inspect_running_job",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of the schedule to inspect",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Running job information",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InspectJobResponse"
}
}
}
},
"404": {
"description": "Scheduled job not found"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/schedule/{id}/kill": {
"post": {
"tags": [
"schedule"
],
"operationId": "kill_running_job",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Running job killed successfully"
}
}
}
},
"/schedule/{id}/pause": {
"post": {
"tags": [
@@ -1332,6 +1392,35 @@
}
}
},
"InspectJobResponse": {
"type": "object",
"properties": {
"processStartTime": {
"type": "string",
"nullable": true
},
"runningDurationSeconds": {
"type": "integer",
"format": "int64",
"nullable": true
},
"sessionId": {
"type": "string",
"nullable": true
}
}
},
"KillJobResponse": {
"type": "object",
"required": [
"message"
],
"properties": {
"message": {
"type": "string"
}
}
},
"ListSchedulesResponse": {
"type": "object",
"required": [
@@ -1805,6 +1894,10 @@
"cron": {
"type": "string"
},
"current_session_id": {
"type": "string",
"nullable": true
},
"currently_running": {
"type": "boolean"
},
@@ -1819,6 +1912,11 @@
"paused": {
"type": "boolean"
},
"process_start_time": {
"type": "string",
"format": "date-time",
"nullable": true
},
"source": {
"type": "string"
}

View File

@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, InspectRunningJobData, InspectRunningJobResponse, KillRunningJobData, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -180,6 +180,20 @@ export const updateSchedule = <ThrowOnError extends boolean = false>(options: Op
});
};
export const inspectRunningJob = <ThrowOnError extends boolean = false>(options: Options<InspectRunningJobData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<InspectRunningJobResponse, unknown, ThrowOnError>({
url: '/schedule/{id}/inspect',
...options
});
};
export const killRunningJob = <ThrowOnError extends boolean = false>(options: Options<KillRunningJobData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
url: '/schedule/{id}/kill',
...options
});
};
export const pauseSchedule = <ThrowOnError extends boolean = false>(options: Options<PauseScheduleData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<PauseScheduleResponse, unknown, ThrowOnError>({
url: '/schedule/{id}/pause',

View File

@@ -172,6 +172,16 @@ export type ImageContent = {
mimeType: string;
};
export type InspectJobResponse = {
processStartTime?: string | null;
runningDurationSeconds?: number | null;
sessionId?: string | null;
};
export type KillJobResponse = {
message: string;
};
export type ListSchedulesResponse = {
jobs: Array<ScheduledJob>;
};
@@ -304,10 +314,12 @@ export type RunNowResponse = {
export type ScheduledJob = {
cron: string;
current_session_id?: string | null;
currently_running?: boolean;
id: string;
last_run?: string | null;
paused?: boolean;
process_start_time?: string | null;
source: string;
};
@@ -1004,6 +1016,54 @@ export type UpdateScheduleResponses = {
export type UpdateScheduleResponse = UpdateScheduleResponses[keyof UpdateScheduleResponses];
export type InspectRunningJobData = {
body?: never;
path: {
/**
* ID of the schedule to inspect
*/
id: string;
};
query?: never;
url: '/schedule/{id}/inspect';
};
export type InspectRunningJobErrors = {
/**
* Scheduled job not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type InspectRunningJobResponses = {
/**
* Running job information
*/
200: InspectJobResponse;
};
export type InspectRunningJobResponse = InspectRunningJobResponses[keyof InspectRunningJobResponses];
export type KillRunningJobData = {
body?: never;
path: {
id: string;
};
query?: never;
url: '/schedule/{id}/kill';
};
export type KillRunningJobResponses = {
/**
* Running job killed successfully
*/
200: unknown;
};
export type PauseScheduleData = {
body?: never;
path: {

View File

@@ -2,7 +2,7 @@ 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 { Select } from '../ui/Select';
import cronstrue from 'cronstrue';
type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';

View File

@@ -5,11 +5,21 @@ import BackButton from '../ui/BackButton';
import { Card } from '../ui/card';
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
import { fetchSessionDetails, SessionDetails } from '../../sessions';
import { getScheduleSessions, runScheduleNow, pauseSchedule, unpauseSchedule, updateSchedule, listSchedules, ScheduledJob } from '../../schedule';
import {
getScheduleSessions,
runScheduleNow,
pauseSchedule,
unpauseSchedule,
updateSchedule,
listSchedules,
killRunningJob,
inspectRunningJob,
ScheduledJob,
} from '../../schedule';
import SessionHistoryView from '../sessions/SessionHistoryView';
import { EditScheduleModal } from './EditScheduleModal';
import { toastError, toastSuccess } from '../../toasts';
import { Loader2, Pause, Play, Edit } from 'lucide-react';
import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react';
import cronstrue from 'cronstrue';
interface ScheduleSessionMeta {
@@ -40,9 +50,14 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
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);
// Track if we explicitly killed a job to distinguish from natural completion
const [jobWasKilled, setJobWasKilled] = useState(false);
const [selectedSessionDetails, setSelectedSessionDetails] = useState<SessionDetails | null>(null);
const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false);
@@ -68,25 +83,34 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
}
}, []);
const fetchScheduleDetails = useCallback(async (sId: string) => {
if (!sId) return;
setIsLoadingSchedule(true);
setScheduleError(null);
try {
const allSchedules = await listSchedules();
const schedule = allSchedules.find((s) => s.id === sId);
if (schedule) {
setScheduleDetails(schedule);
} else {
setScheduleError('Schedule not found');
const fetchScheduleDetails = useCallback(
async (sId: string) => {
if (!sId) return;
setIsLoadingSchedule(true);
setScheduleError(null);
try {
const allSchedules = await listSchedules();
const schedule = allSchedules.find((s) => s.id === sId);
if (schedule) {
// Only reset runNowLoading if we explicitly killed the job
// This prevents interfering with natural job completion
if (!schedule.currently_running && runNowLoading && jobWasKilled) {
setRunNowLoading(false);
setJobWasKilled(false); // Reset the flag
}
setScheduleDetails(schedule);
} else {
setScheduleError('Schedule not found');
}
} catch (err) {
console.error('Failed to fetch schedule details:', err);
setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule details');
} finally {
setIsLoadingSchedule(false);
}
} catch (err) {
console.error('Failed to fetch schedule details:', err);
setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule details');
} finally {
setIsLoadingSchedule(false);
}
}, []);
},
[runNowLoading, jobWasKilled]
);
const getReadableCron = (cronString: string) => {
try {
@@ -108,6 +132,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
setSelectedSessionDetails(null);
setScheduleDetails(null);
setScheduleError(null);
setJobWasKilled(false); // Reset kill flag when changing schedules
}
}, [scheduleId, fetchScheduleSessions, fetchScheduleDetails, selectedSessionDetails]);
@@ -115,11 +140,18 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
if (!scheduleId) return;
setRunNowLoading(true);
try {
const newSessionId = await runScheduleNow(scheduleId); // MODIFIED
toastSuccess({
title: 'Schedule Triggered',
msg: `Successfully triggered schedule. New session ID: ${newSessionId}`,
});
const newSessionId = await runScheduleNow(scheduleId);
if (newSessionId === 'CANCELLED') {
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}`,
});
}
setTimeout(() => {
if (scheduleId) {
fetchScheduleSessions(scheduleId);
@@ -183,9 +215,60 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
setEditApiError(null);
};
const handleKillRunningJob = async () => {
if (!scheduleId) return;
setKillJobLoading(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);
} 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 });
} finally {
setKillJobLoading(false);
}
};
const handleInspectRunningJob = async () => {
if (!scheduleId) return;
setInspectJobLoading(true);
try {
const result = await inspectRunningJob(scheduleId);
if (result.sessionId) {
const duration = result.runningDurationSeconds
? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s`
: 'Unknown';
toastSuccess({
title: 'Job Inspection',
msg: `Session: ${result.sessionId}\nRunning for: ${duration}`,
});
} else {
toastSuccess({
title: 'Job Inspection',
msg: 'No detailed information available for this job',
});
}
} 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 {
@@ -226,6 +309,18 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
};
}, [scheduleId, fetchScheduleDetails]);
// 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
}
}
}, [scheduleDetails, runNowLoading, jobWasKilled]);
const loadAndShowSessionDetails = async (sessionId: string) => {
setIsLoadingSessionDetails(true);
setSessionDetailsError(null);
@@ -364,6 +459,18 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
? new Date(scheduleDetails.last_run).toLocaleString()
: 'Never'}
</p>
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
<p className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-semibold">Current Session:</span>{' '}
{scheduleDetails.current_session_id}
</p>
)}
{scheduleDetails.currently_running && scheduleDetails.process_start_time && (
<p className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-semibold">Process Started:</span>{' '}
{new Date(scheduleDetails.process_start_time).toLocaleString()}
</p>
)}
</div>
</Card>
)}
@@ -379,7 +486,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
>
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
</Button>
{scheduleDetails && !scheduleDetails.currently_running && (
<>
<Button
@@ -415,17 +522,41 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
</Button>
</>
)}
{scheduleDetails && scheduleDetails.currently_running && (
<>
<Button
onClick={handleInspectRunningJob}
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}
>
<Eye className="w-4 h-4" />
{inspectJobLoading ? 'Inspecting...' : 'Inspect Running Job'}
</Button>
<Button
onClick={handleKillRunningJob}
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}
>
<Square className="w-4 h-4" />
{killJobLoading ? 'Killing...' : 'Kill Running Job'}
</Button>
</>
)}
</div>
{scheduleDetails?.currently_running && (
<p className="text-sm text-amber-600 dark:text-amber-400 mt-2">
Cannot trigger or modify a schedule while it's already running.
</p>
)}
{scheduleDetails?.paused && (
<p className="text-sm text-orange-600 dark:text-orange-400 mt-2">
This schedule is paused and will not run automatically. Use "Run Schedule Now" to trigger it manually or unpause to resume automatic execution.
This schedule is paused and will not run automatically. Use "Run Schedule Now" to
trigger it manually or unpause to resume automatic execution.
</p>
)}
</section>

View File

@@ -1,12 +1,22 @@
import React, { useState, useEffect } from 'react';
import { listSchedules, createSchedule, deleteSchedule, pauseSchedule, unpauseSchedule, updateSchedule, ScheduledJob } from '../../schedule';
import {
listSchedules,
createSchedule,
deleteSchedule,
pauseSchedule,
unpauseSchedule,
updateSchedule,
killRunningJob,
inspectRunningJob,
ScheduledJob,
} from '../../schedule';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { TrashIcon } from '../icons/TrashIcon';
import { Plus, RefreshCw, Pause, Play, Edit } from 'lucide-react';
import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye } from 'lucide-react';
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
import { EditScheduleModal } from './EditScheduleModal';
import ScheduleDetailView from './ScheduleDetailView';
@@ -27,10 +37,12 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<ScheduledJob | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// 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 [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
@@ -125,7 +137,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
const handleEditScheduleSubmit = async (cron: string) => {
if (!editingSchedule) return;
setIsSubmitting(true);
setSubmitApiError(null);
try {
@@ -153,10 +165,10 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
const handleDeleteSchedule = async (idToDelete: string) => {
if (!window.confirm(`Are you sure you want to delete schedule "${idToDelete}"?`)) return;
// Immediately add to deleting set to disable button
setDeletingScheduleIds(prev => new Set(prev).add(idToDelete));
setDeletingScheduleIds((prev) => new Set(prev).add(idToDelete));
if (viewingScheduleId === idToDelete) {
setViewingScheduleId(null);
}
@@ -171,7 +183,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
);
} finally {
// Remove from deleting set
setDeletingScheduleIds(prev => {
setDeletingScheduleIds((prev) => {
const newSet = new Set(prev);
newSet.delete(idToDelete);
return newSet;
@@ -181,8 +193,8 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
const handlePauseSchedule = async (idToPause: string) => {
// Immediately add to pausing set to disable button
setPausingScheduleIds(prev => new Set(prev).add(idToPause));
setPausingScheduleIds((prev) => new Set(prev).add(idToPause));
setApiError(null);
try {
await pauseSchedule(idToPause);
@@ -193,7 +205,8 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
await fetchSchedules();
} catch (error) {
console.error(`Failed to pause schedule "${idToPause}":`, error);
const errorMsg = error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`;
const errorMsg =
error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`;
setApiError(errorMsg);
toastError({
title: 'Pause Schedule Error',
@@ -201,7 +214,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
});
} finally {
// Remove from pausing set
setPausingScheduleIds(prev => {
setPausingScheduleIds((prev) => {
const newSet = new Set(prev);
newSet.delete(idToPause);
return newSet;
@@ -211,8 +224,8 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
const handleUnpauseSchedule = async (idToUnpause: string) => {
// Immediately add to pausing set to disable button
setPausingScheduleIds(prev => new Set(prev).add(idToUnpause));
setPausingScheduleIds((prev) => new Set(prev).add(idToUnpause));
setApiError(null);
try {
await unpauseSchedule(idToUnpause);
@@ -223,7 +236,8 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
await fetchSchedules();
} catch (error) {
console.error(`Failed to unpause schedule "${idToUnpause}":`, error);
const errorMsg = error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`;
const errorMsg =
error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`;
setApiError(errorMsg);
toastError({
title: 'Unpause Schedule Error',
@@ -231,7 +245,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
});
} finally {
// Remove from pausing set
setPausingScheduleIds(prev => {
setPausingScheduleIds((prev) => {
const newSet = new Set(prev);
newSet.delete(idToUnpause);
return newSet;
@@ -239,6 +253,77 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
}
};
const handleKillRunningJob = async (scheduleId: string) => {
// Immediately add to killing set to disable button
setKillingScheduleIds((prev) => new Set(prev).add(scheduleId));
setApiError(null);
try {
const result = await killRunningJob(scheduleId);
toastSuccess({
title: 'Job Killed',
msg: result.message,
});
await fetchSchedules();
} catch (error) {
console.error(`Failed to kill running job "${scheduleId}":`, error);
const errorMsg =
error instanceof Error ? error.message : `Unknown error killing job "${scheduleId}".`;
setApiError(errorMsg);
toastError({
title: 'Kill Job Error',
msg: errorMsg,
});
} finally {
// Remove from killing set
setKillingScheduleIds((prev) => {
const newSet = new Set(prev);
newSet.delete(scheduleId);
return newSet;
});
}
};
const handleInspectRunningJob = async (scheduleId: string) => {
// Immediately add to inspecting set to disable button
setInspectingScheduleIds((prev) => new Set(prev).add(scheduleId));
setApiError(null);
try {
const result = await inspectRunningJob(scheduleId);
if (result.sessionId) {
const duration = result.runningDurationSeconds
? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s`
: 'Unknown';
toastSuccess({
title: 'Job Inspection',
msg: `Session: ${result.sessionId}\nRunning for: ${duration}`,
});
} else {
toastSuccess({
title: 'Job Inspection',
msg: 'No detailed information available for this job',
});
}
} catch (error) {
console.error(`Failed to inspect running job "${scheduleId}":`, error);
const errorMsg =
error instanceof Error ? error.message : `Unknown error inspecting job "${scheduleId}".`;
setApiError(errorMsg);
toastError({
title: 'Inspect Job Error',
msg: errorMsg,
});
} finally {
// Remove from inspecting set
setInspectingScheduleIds((prev) => {
const newSet = new Set(prev);
newSet.delete(scheduleId);
return newSet;
});
}
};
const handleNavigateToScheduleDetail = (scheduleId: string) => {
setViewingScheduleId(scheduleId);
};
@@ -372,7 +457,11 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
}}
className="text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-blue-100/50 dark:hover:bg-blue-900/30"
title={`Edit schedule ${job.id}`}
disabled={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id) || isSubmitting}
disabled={
pausingScheduleIds.has(job.id) ||
deletingScheduleIds.has(job.id) ||
isSubmitting
}
>
<Edit className="w-4 h-4" />
</Button>
@@ -392,10 +481,54 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
? 'text-green-500 dark:text-green-400 hover:text-green-600 dark:hover:text-green-300 hover:bg-green-100/50 dark:hover:bg-green-900/30'
: 'text-orange-500 dark:text-orange-400 hover:text-orange-600 dark:hover:text-orange-300 hover:bg-orange-100/50 dark:hover:bg-orange-900/30'
}`}
title={job.paused ? `Unpause schedule ${job.id}` : `Pause schedule ${job.id}`}
disabled={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id)}
title={
job.paused
? `Unpause schedule ${job.id}`
: `Pause schedule ${job.id}`
}
disabled={
pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id)
}
>
{job.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{job.paused ? (
<Play className="w-4 h-4" />
) : (
<Pause className="w-4 h-4" />
)}
</Button>
</>
)}
{job.currently_running && (
<>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleInspectRunningJob(job.id);
}}
className="text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-blue-100/50 dark:hover:bg-blue-900/30"
title={`Inspect running job ${job.id}`}
disabled={
inspectingScheduleIds.has(job.id) || killingScheduleIds.has(job.id)
}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleKillRunningJob(job.id);
}}
className="text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300 hover:bg-red-100/50 dark:hover:bg-red-900/30"
title={`Kill running job ${job.id}`}
disabled={
killingScheduleIds.has(job.id) || inspectingScheduleIds.has(job.id)
}
>
<Square className="w-4 h-4" />
</Button>
</>
)}
@@ -408,7 +541,12 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
}}
className="text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-900/30"
title={`Delete schedule ${job.id}`}
disabled={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id)}
disabled={
pausingScheduleIds.has(job.id) ||
deletingScheduleIds.has(job.id) ||
killingScheduleIds.has(job.id) ||
inspectingScheduleIds.has(job.id)
}
>
<TrashIcon className="w-5 h-5" />
</Button>

View File

@@ -7,6 +7,8 @@ import {
updateSchedule as apiUpdateSchedule,
sessionsHandler as apiGetScheduleSessions,
runNowHandler as apiRunScheduleNow,
killRunningJob as apiKillRunningJob,
inspectRunningJob as apiInspectRunningJob,
} from './api';
export interface ScheduledJob {
@@ -16,6 +18,8 @@ export interface ScheduledJob {
last_run?: string | null;
currently_running?: boolean;
paused?: boolean;
current_session_id?: string | null;
process_start_time?: string | null;
}
export interface ScheduleSession {
@@ -151,3 +155,47 @@ export async function updateSchedule(scheduleId: string, cron: string): Promise<
throw error;
}
}
export interface KillJobResponse {
message: string;
}
export interface InspectJobResponse {
sessionId?: string | null;
processStartTime?: string | null;
runningDurationSeconds?: number | null;
}
export async function killRunningJob(scheduleId: string): Promise<KillJobResponse> {
try {
const response = await apiKillRunningJob<true>({
path: { id: scheduleId },
});
if (response && response.data) {
return response.data as KillJobResponse;
}
console.error('Unexpected response format from apiKillRunningJob', response);
throw new Error('Failed to kill running job: Unexpected response format');
} catch (error) {
console.error(`Error killing running job ${scheduleId}:`, error);
throw error;
}
}
export async function inspectRunningJob(scheduleId: string): Promise<InspectJobResponse> {
try {
const response = await apiInspectRunningJob<true>({
path: { id: scheduleId },
});
if (response && response.data) {
return response.data as InspectJobResponse;
}
console.error('Unexpected response format from apiInspectRunningJob', response);
throw new Error('Failed to inspect running job: Unexpected response format');
} catch (error) {
console.error(`Error inspecting running job ${scheduleId}:`, error);
throw error;
}
}