mirror of
https://github.com/aljazceru/goose.git
synced 2026-02-22 23:14:30 +01:00
feat: implement proper task cancellation for scheduled jobs (#2731)
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user