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:
parent
3fedc102cc
commit
2bfa5bc1a8
7 changed files with 205 additions and 17 deletions
|
|
@ -1100,6 +1100,10 @@ footer .banner-thin {
|
|||
opacity: 0.75;
|
||||
}
|
||||
.schedules-table-row-cancelled td { opacity: 0.55; }
|
||||
.schedules-table-row-paused td { opacity: 0.75; }
|
||||
.sched-paused-label { color: var(--yellow); font-size: 0.9em; }
|
||||
.btn-pause-schedule { color: var(--teal); }
|
||||
.btn-resume-schedule { color: var(--green); }
|
||||
.schedules-table-body-cell {
|
||||
max-width: 30em;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
|
|
@ -390,9 +390,10 @@ function renderSchedulesList() {
|
|||
table.append(renderSchedulesTableHead(agents));
|
||||
const tbody = el('tbody', {});
|
||||
const sorted = schedulesState.slice().sort((a, b) => {
|
||||
const aDone = a.cancelled_at_unix ? 1 : 0;
|
||||
const bDone = b.cancelled_at_unix ? 1 : 0;
|
||||
if (aDone !== bDone) return aDone - bDone;
|
||||
// Cancelled → bucket 2, paused → bucket 1, active → bucket 0.
|
||||
const aOrd = a.cancelled_at_unix ? 2 : a.paused_at_unix ? 1 : 0;
|
||||
const bOrd = b.cancelled_at_unix ? 2 : b.paused_at_unix ? 1 : 0;
|
||||
if (aOrd !== bOrd) return aOrd - bOrd;
|
||||
return a.next_fire_at_unix - b.next_fire_at_unix;
|
||||
});
|
||||
for (const s of sorted) {
|
||||
|
|
@ -637,8 +638,11 @@ function renderSchedulesTableHead(agents) {
|
|||
}
|
||||
function renderScheduleRow(s, agents) {
|
||||
const cancelled = !!s.cancelled_at_unix;
|
||||
const paused = !cancelled && !!s.paused_at_unix;
|
||||
const tr = el('tr', {
|
||||
class: 'schedules-table-row' + (cancelled ? ' schedules-table-row-cancelled' : ''),
|
||||
class: 'schedules-table-row'
|
||||
+ (cancelled ? ' schedules-table-row-cancelled' : '')
|
||||
+ (paused ? ' schedules-table-row-paused' : ''),
|
||||
});
|
||||
|
||||
tr.append(el('td', { class: 'meta schedules-table-id' }, '#' + s.id));
|
||||
|
|
@ -649,11 +653,20 @@ function renderScheduleRow(s, agents) {
|
|||
srcKind === 'approval' ? 'approval' : 'operator')));
|
||||
|
||||
// "next" cell — relative due-in for active schedules, "cancelled"
|
||||
// for cancelled ones. Both carry the absolute ISO in the title.
|
||||
// for cancelled ones, "paused" for paused ones. Both carry the
|
||||
// absolute ISO in the title.
|
||||
const nextCell = el('td', { class: 'meta schedules-table-next-col' });
|
||||
if (cancelled) {
|
||||
nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString();
|
||||
nextCell.textContent = 'cancelled';
|
||||
} else if (paused) {
|
||||
nextCell.title = 'paused since '
|
||||
+ new Date(s.paused_at_unix * 1000).toISOString()
|
||||
+ '\nwould fire at '
|
||||
+ new Date(s.next_fire_at_unix * 1000).toISOString();
|
||||
nextCell.append(
|
||||
el('span', { class: 'sched-paused-label' }, '⏸ paused'),
|
||||
);
|
||||
} else {
|
||||
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000);
|
||||
nextCell.title = new Date(s.next_fire_at_unix * 1000).toISOString();
|
||||
|
|
@ -723,10 +736,10 @@ function renderScheduleRow(s, agents) {
|
|||
tr.append(td);
|
||||
}
|
||||
|
||||
// Actions cell — fire / edit / cancel-all. Glyph-only to fit a
|
||||
// compact column; the buttons keep their existing colour classes
|
||||
// so the visual cue (mauve = fire, yellow = edit, red = cancel)
|
||||
// carries over from the card layout.
|
||||
// Actions cell — fire / pause-toggle / edit / cancel-all.
|
||||
// Glyph-only to fit a compact column; colour classes carry the
|
||||
// visual cue: mauve = fire, teal = pause/resume, yellow = edit,
|
||||
// red = cancel.
|
||||
const actionsCell = el('td', { class: 'schedules-table-actions' });
|
||||
if (!cancelled) {
|
||||
const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix);
|
||||
|
|
@ -738,14 +751,28 @@ function renderScheduleRow(s, agents) {
|
|||
fireBtn.title = isOneShot
|
||||
? 'fire once — one-shot, consumed after the manual fire'
|
||||
: 'fire once now — recurring; choose whether to reset the next-fire timer';
|
||||
if (!activeTargets.length) {
|
||||
if (!activeTargets.length || paused) {
|
||||
fireBtn.disabled = true;
|
||||
fireBtn.title = 'every target is cancelled — nothing to fire';
|
||||
fireBtn.title = paused
|
||||
? 'resume the schedule first to use fire-now'
|
||||
: 'every target is cancelled — nothing to fire';
|
||||
}
|
||||
fireBtn.addEventListener('click', () =>
|
||||
fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target), fireBtn));
|
||||
actionsCell.append(fireBtn);
|
||||
|
||||
// Pause / resume toggle — only meaningful for recurring schedules.
|
||||
// One-shots can still be paused (to delay a one-time fire), so we
|
||||
// show the button for both cases.
|
||||
const pauseBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'btn btn-pause-schedule btn-inline-small' + (paused ? ' btn-resume-schedule' : ''),
|
||||
}, paused ? '▶' : '⏸');
|
||||
pauseBtn.title = paused ? 'resume schedule' : 'pause schedule';
|
||||
pauseBtn.addEventListener('click', () =>
|
||||
paused ? resumeSchedule(s.id) : pauseSchedule(s.id));
|
||||
actionsCell.append(pauseBtn);
|
||||
|
||||
const editingThis = editingSchedules.has(s.id);
|
||||
const editBtn = el('button', {
|
||||
type: 'button',
|
||||
|
|
@ -1089,6 +1116,37 @@ async function postScheduleCancel(id, targets) {
|
|||
}
|
||||
}
|
||||
|
||||
async function pauseSchedule(id) {
|
||||
try {
|
||||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/pause', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
themedToast('pause failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
return;
|
||||
}
|
||||
await refreshSchedules();
|
||||
} catch (err) {
|
||||
themedToast('pause failed: ' + err, { type: 'error' });
|
||||
}
|
||||
}
|
||||
async function resumeSchedule(id) {
|
||||
try {
|
||||
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/resume', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
themedToast('resume failed: http ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
return;
|
||||
}
|
||||
await refreshSchedules();
|
||||
} catch (err) {
|
||||
themedToast('resume failed: ' + err, { type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
export function applySchedulesChanged(ev) {
|
||||
schedulesState = (ev.schedules || []).slice();
|
||||
renderSchedulesList();
|
||||
|
|
|
|||
Loading…
Reference in a new issue