feat(schedules): make schedules pausable

Adds pause/resume support for scheduled prompts.

Backend:
- New paused_at_unix column on scheduled_prompts table (added via
  ALTER TABLE migration so existing databases are upgraded on first
  start). The due-rows index is dropped and recreated to also exclude
  paused rows so the worker never fires them while paused.
- Worker's due() query gains AND paused_at_unix IS NULL filter.
- New pause(id) and resume(id) methods on ScheduledPrompts; both are
  idempotent and refuse cancelled rows.
- New POST /api/schedules/{id}/pause and /api/schedules/{id}/resume
  dashboard endpoints (operator-direct, no approval gate). Both emit
  a schedules snapshot on success so the tab updates live.
- WireSchedule gains paused_at_unix: Option<i64> so the frontend can
  render the state without an extra fetch.

Frontend:
- Paused rows render with a distinct row class + muted opacity.
- The next-fire cell shows a yellow pause glyph + tooltip with the
  paused-since timestamp and the would-have-fired time.
- Actions column: pause/resume toggle button (⏸/▶) beside fire/edit/cancel.
  Fire-now is disabled while paused (resume first).
- Sort order: active → paused → cancelled (paused slot keeps schedules
  visible without mixing them into the active top section).
- pauseSchedule() / resumeSchedule() async functions POST to the new
  endpoints and refresh the table on success.
This commit is contained in:
iris 2026-06-27 13:50:18 +02:00 committed by mara
commit 2bfa5bc1a8
7 changed files with 205 additions and 17 deletions

View file

@ -228,6 +228,52 @@ pub(super) async fn patch_schedule(
}
}
/// `POST /api/schedules/{id}/pause` — pause a schedule so the worker
/// skips it until explicitly resumed. Idempotent; no-op on an already-
/// paused row. Returns 404 when the schedule is cancelled or not found.
pub(super) async fn post_schedule_pause(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Response {
match state.coord.scheduled_prompts.pause(id) {
Ok(()) => {
state.coord.emit_schedules_snapshot();
(StatusCode::OK, "ok").into_response()
}
Err(e) => {
let msg = format!("{e:#}");
if msg.contains("not found") || msg.contains("cancelled") {
(StatusCode::NOT_FOUND, msg).into_response()
} else {
error_response(&format!("pause schedule {id}: {msg}"))
}
}
}
}
/// `POST /api/schedules/{id}/resume` — resume a paused schedule.
/// Idempotent; no-op on an already-active row. Returns 404 when the
/// schedule is cancelled or not found.
pub(super) async fn post_schedule_resume(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Response {
match state.coord.scheduled_prompts.resume(id) {
Ok(()) => {
state.coord.emit_schedules_snapshot();
(StatusCode::OK, "ok").into_response()
}
Err(e) => {
let msg = format!("{e:#}");
if msg.contains("not found") || msg.contains("cancelled") {
(StatusCode::NOT_FOUND, msg).into_response()
} else {
error_response(&format!("resume schedule {id}: {msg}"))
}
}
}
}
/// `POST /api/schedules/{id}/cancel` — operator-side cancel
/// (whole schedule when no `targets` field, partial when one is
/// provided). Operator bypasses the topology check; the manager